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
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user