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
+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();
});
}
}