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:
Bernd Storath
2026-06-12 13:38:32 +02:00
committed by GitHub
co-authored by Daniel Molenda
parent c70ad1d08b
commit ac547bbf7c
44 changed files with 2848 additions and 222 deletions
@@ -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
}
]
}
+31 -19
View File
@@ -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),
+240 -33
View File
@@ -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,
})