feat: oauth integration (#2659)
* feat: add Google OAuth login support (#2625) * 🔧 Add login via google * 🔧 Update code style and docs * Add fix for db migrate * 🔧 Update docker-compose * 🔧 Fix sqlite" * 🔧 Update docker-compose * ⚰️ Remove environments * 🔧 Fix: remove ensureGoogleIdColumn workaround from sqlite.ts * 🔧 Remove space * move oauth section * add openid client * wip make oauth more generic * properly allow multiple providers * fix type import * github login flow * adjust github logo with theme * move docs to own page * nullable password, prevent timing attack this prevents timing attacks by always checking hash even if there is none prevents using basic auth if 2fa is enabled * support generic oidc * add ability to set password for oidc users this allows oidc users to add password login cant be removed after * move password login route move password login route from /api/session to /api/auth/password align with oauth * unique index on oauth * link/unlink logic * improve docs * support allowed domains * support auto register * refactoring * disable pw auth * move 2fa to its own page * 2fa for oauth, rework 2fa system * fix design, fix link Closes #2650 * add auto launch * improve docs * improvements --------- Co-authored-by: Daniel Molenda <dm@fotc.com>
This commit is contained in:
co-authored by
Daniel Molenda
parent
c70ad1d08b
commit
ac547bbf7c
@@ -0,0 +1,23 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_users_table` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`username` text NOT NULL,
|
||||
`password` text,
|
||||
`email` text,
|
||||
`name` text NOT NULL,
|
||||
`role` integer NOT NULL,
|
||||
`totp_key` text,
|
||||
`totp_verified` integer NOT NULL,
|
||||
`enabled` integer NOT NULL,
|
||||
`oauth_provider` text,
|
||||
`oauth_id` text,
|
||||
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
|
||||
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_users_table`("id", "username", "password", "email", "name", "role", "totp_key", "totp_verified", "enabled", "created_at", "updated_at") SELECT "id", "username", "password", "email", "name", "role", "totp_key", "totp_verified", "enabled", "created_at", "updated_at" FROM `users_table`;--> statement-breakpoint
|
||||
DROP TABLE `users_table`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_users_table` RENAME TO `users_table`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_table_username_unique` ON `users_table` (`username`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_provider_id_unique` ON `users_table` (`oauth_provider`,`oauth_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
||||
"when": 1771352889394,
|
||||
"tag": "0004_optimal_mandrill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1780035570366,
|
||||
"tag": "0005_clumsy_korg",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,26 +1,38 @@
|
||||
import { sql, relations } from 'drizzle-orm';
|
||||
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { int, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
import { client } from '../../schema';
|
||||
|
||||
export const user = sqliteTable('users_table', {
|
||||
id: int().primaryKey({ autoIncrement: true }),
|
||||
username: text().notNull().unique(),
|
||||
password: text().notNull(),
|
||||
email: text(),
|
||||
name: text().notNull(),
|
||||
role: int().$type<Role>().notNull(),
|
||||
totpKey: text('totp_key'),
|
||||
totpVerified: int('totp_verified', { mode: 'boolean' }).notNull(),
|
||||
enabled: int({ mode: 'boolean' }).notNull(),
|
||||
createdAt: text('created_at')
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: text('updated_at')
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`)
|
||||
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
export const user = sqliteTable(
|
||||
'users_table',
|
||||
{
|
||||
id: int().primaryKey({ autoIncrement: true }),
|
||||
username: text().notNull().unique(),
|
||||
/** `password == null` means password login disabled */
|
||||
password: text(),
|
||||
email: text(),
|
||||
name: text().notNull(),
|
||||
role: int().$type<Role>().notNull(),
|
||||
totpKey: text('totp_key'),
|
||||
totpVerified: int('totp_verified', { mode: 'boolean' }).notNull(),
|
||||
enabled: int({ mode: 'boolean' }).notNull(),
|
||||
oauthProvider: text('oauth_provider').$type<OAUTH_PROVIDER>(),
|
||||
oauthId: text('oauth_id'),
|
||||
createdAt: text('created_at')
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: text('updated_at')
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`)
|
||||
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('oauth_provider_id_unique').on(
|
||||
table.oauthProvider,
|
||||
table.oauthId
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
export const usersRelations = relations(user, ({ many }) => ({
|
||||
clients: many(client),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { eq, sql, and } from 'drizzle-orm';
|
||||
import { TOTP } from 'otpauth';
|
||||
import { user } from './schema';
|
||||
import type { UserType } from './types';
|
||||
@@ -13,10 +13,33 @@ type LoginResult =
|
||||
success: false;
|
||||
error:
|
||||
| 'INCORRECT_CREDENTIALS'
|
||||
| 'TOTP_REQUIRED'
|
||||
| 'USER_DISABLED'
|
||||
| 'INVALID_TOTP_CODE'
|
||||
| 'UNEXPECTED_ERROR';
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: 'TOTP_REQUIRED';
|
||||
userId: ID;
|
||||
};
|
||||
|
||||
type LoginWithOAuthResult =
|
||||
| {
|
||||
success: true;
|
||||
user: UserType;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error:
|
||||
| 'USER_DISABLED'
|
||||
| 'USER_ALREADY_LINKED'
|
||||
| 'UNEXPECTED_ERROR'
|
||||
| 'AUTO_REGISTER_DISABLED';
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: 'TOTP_REQUIRED';
|
||||
userId: ID;
|
||||
};
|
||||
|
||||
function createPreparedStatement(db: DBType) {
|
||||
@@ -113,7 +136,11 @@ export class UserService {
|
||||
return this.#statements.update.execute({ id, name, email });
|
||||
}
|
||||
|
||||
async updatePassword(id: ID, currentPassword: string, newPassword: string) {
|
||||
async updatePassword(
|
||||
id: ID,
|
||||
currentPassword: string | null,
|
||||
newPassword: string
|
||||
) {
|
||||
const hash = await hashPassword(newPassword);
|
||||
|
||||
return this.#db.transaction(async (tx) => {
|
||||
@@ -126,13 +153,20 @@ export class UserService {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const passwordValid = await isPasswordValid(
|
||||
currentPassword,
|
||||
txUser.password
|
||||
);
|
||||
// only check password if already set
|
||||
if (txUser.password !== null) {
|
||||
if (!currentPassword) {
|
||||
throw new Error('Invalid password');
|
||||
}
|
||||
|
||||
if (!passwordValid) {
|
||||
throw new Error('Invalid password');
|
||||
const passwordValid = await isPasswordValid(
|
||||
currentPassword,
|
||||
txUser.password
|
||||
);
|
||||
|
||||
if (!passwordValid) {
|
||||
throw new Error('Invalid password');
|
||||
}
|
||||
}
|
||||
|
||||
await tx
|
||||
@@ -147,42 +181,32 @@ export class UserService {
|
||||
return this.#statements.updateKey.execute({ id, key });
|
||||
}
|
||||
|
||||
login(username: string, password: string, code: string | undefined) {
|
||||
login(username: string, password: string) {
|
||||
return this.#db.transaction(async (tx): Promise<LoginResult> => {
|
||||
const txUser = await tx.query.user
|
||||
.findFirst({ where: eq(user.username, username) })
|
||||
.execute();
|
||||
|
||||
if (!txUser) {
|
||||
// always check to avoid timing attack
|
||||
const userHashPassword = txUser?.password ?? null;
|
||||
const passwordValid = await isPasswordValid(password, userHashPassword);
|
||||
|
||||
if (!txUser || !passwordValid) {
|
||||
return { success: false, error: 'INCORRECT_CREDENTIALS' };
|
||||
}
|
||||
|
||||
const passwordValid = await isPasswordValid(password, txUser.password);
|
||||
|
||||
if (!passwordValid) {
|
||||
return { success: false, error: 'INCORRECT_CREDENTIALS' };
|
||||
}
|
||||
|
||||
if (txUser.totpVerified) {
|
||||
if (!code) {
|
||||
return { success: false, error: 'TOTP_REQUIRED' };
|
||||
} else {
|
||||
const totpKey = txUser.totpKey;
|
||||
if (!totpKey) {
|
||||
return { success: false, error: 'UNEXPECTED_ERROR' };
|
||||
}
|
||||
|
||||
const totp = this.#createTotp({ username: txUser.username, totpKey });
|
||||
if (totp.validate({ token: code, window: 1 }) === null) {
|
||||
return { success: false, error: 'INVALID_TOTP_CODE' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!txUser.enabled) {
|
||||
return { success: false, error: 'USER_DISABLED' };
|
||||
}
|
||||
|
||||
if (txUser.totpVerified) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'TOTP_REQUIRED',
|
||||
userId: txUser.id,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, user: txUser };
|
||||
});
|
||||
}
|
||||
@@ -241,4 +265,187 @@ export class UserService {
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
async validateTotpCode(id: ID, code: string) {
|
||||
const txUser = await this.#db.query.user
|
||||
.findFirst({ where: eq(user.id, id) })
|
||||
.execute();
|
||||
|
||||
if (!txUser || !txUser.totpVerified || !txUser.totpKey) {
|
||||
return 'INVALID_TOTP_CODE' as const;
|
||||
}
|
||||
|
||||
const totp = this.#createTotp({
|
||||
username: txUser.username,
|
||||
totpKey: txUser.totpKey,
|
||||
});
|
||||
const isValid = totp.validate({ token: code, window: 1 }) !== null;
|
||||
|
||||
if (!isValid) {
|
||||
return 'INVALID_TOTP_CODE' as const;
|
||||
}
|
||||
|
||||
if (!txUser.enabled) {
|
||||
return 'USER_DISABLED' as const;
|
||||
}
|
||||
|
||||
return 'success' as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login or register user with OAuth provider.
|
||||
* If user with the same email already exists, link account with OAuth provider.
|
||||
* Otherwise, create new user.
|
||||
*/
|
||||
async loginWithOAuth(
|
||||
provider: OAUTH_PROVIDER,
|
||||
oauthId: string,
|
||||
username: string,
|
||||
email: string,
|
||||
name: string
|
||||
): Promise<LoginWithOAuthResult> {
|
||||
return this.#db.transaction(async (tx) => {
|
||||
const userById = await tx.query.user
|
||||
.findFirst({
|
||||
where: and(
|
||||
eq(user.oauthProvider, provider),
|
||||
eq(user.oauthId, oauthId)
|
||||
),
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (userById) {
|
||||
if (!userById.enabled) {
|
||||
return { success: false, error: 'USER_DISABLED' };
|
||||
}
|
||||
if (userById.totpVerified) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'TOTP_REQUIRED',
|
||||
userId: userById.id,
|
||||
};
|
||||
}
|
||||
return { success: true, user: userById };
|
||||
}
|
||||
|
||||
const userByEmail = await tx.query.user
|
||||
.findFirst({
|
||||
where: eq(user.email, email),
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (userByEmail) {
|
||||
if (!userByEmail.enabled) {
|
||||
return { success: false, error: 'USER_DISABLED' };
|
||||
}
|
||||
if (userByEmail.oauthProvider && userByEmail.oauthId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'USER_ALREADY_LINKED',
|
||||
};
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(user)
|
||||
.set({ oauthProvider: provider, oauthId: oauthId })
|
||||
.where(eq(user.id, userByEmail.id))
|
||||
.execute();
|
||||
|
||||
if (userByEmail.totpVerified) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'TOTP_REQUIRED',
|
||||
userId: userByEmail.id,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: return updated user
|
||||
return { success: true, user: userByEmail };
|
||||
}
|
||||
|
||||
if (!WG_ENV.OAUTH_AUTO_REGISTER) {
|
||||
return { success: false, error: 'AUTO_REGISTER_DISABLED' };
|
||||
}
|
||||
|
||||
// Create new user
|
||||
const newUsers = await tx
|
||||
.insert(user)
|
||||
.values({
|
||||
username,
|
||||
password: null,
|
||||
email,
|
||||
name,
|
||||
role: roles.ADMIN,
|
||||
totpVerified: false,
|
||||
enabled: true,
|
||||
oauthProvider: provider,
|
||||
oauthId,
|
||||
})
|
||||
.returning();
|
||||
const newUser = newUsers[0];
|
||||
|
||||
if (!newUser) {
|
||||
return { success: false as const, error: 'UNEXPECTED_ERROR' as const };
|
||||
}
|
||||
return { success: true as const, user: newUser };
|
||||
});
|
||||
}
|
||||
|
||||
unlinkOauth(id: ID) {
|
||||
return this.#db.transaction(async (tx) => {
|
||||
const txUser = await tx.query.user
|
||||
.findFirst({ where: eq(user.id, id) })
|
||||
.execute();
|
||||
|
||||
if (!txUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// can't unlink if no way to log back in
|
||||
if (!txUser.password) {
|
||||
throw new Error('Password login not enabled');
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(user)
|
||||
.set({ oauthProvider: null, oauthId: null })
|
||||
.where(eq(user.id, id))
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
async linkOauth(id: ID, provider: OAUTH_PROVIDER, oauthId: string) {
|
||||
return this.#db.transaction(async (tx) => {
|
||||
const txUser = await tx.query.user
|
||||
.findFirst({ where: eq(user.id, id) })
|
||||
.execute();
|
||||
|
||||
if (!txUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
if (txUser.oauthProvider || txUser.oauthId) {
|
||||
throw new Error('User already linked with an OAuth provider');
|
||||
}
|
||||
|
||||
const existingUser = await tx.query.user
|
||||
.findFirst({
|
||||
where: and(
|
||||
eq(user.oauthProvider, provider),
|
||||
eq(user.oauthId, oauthId)
|
||||
),
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (existingUser) {
|
||||
throw new Error('OAuth account already linked with another user');
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(user)
|
||||
.set({ oauthProvider: provider, oauthId: oauthId })
|
||||
.where(eq(user.id, id))
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ export const UserLoginSchema = z.object({
|
||||
username: username,
|
||||
password: password,
|
||||
remember: remember,
|
||||
totpCode: totpCode.optional(),
|
||||
});
|
||||
|
||||
export const UserSetupSchema = z
|
||||
@@ -57,7 +56,7 @@ export const UserUpdateSchema = z.object({
|
||||
|
||||
export const UserUpdatePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: password,
|
||||
currentPassword: password.nullable(),
|
||||
newPassword: password,
|
||||
confirmPassword: password,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user