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,87 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { config, provider, providerConfig } = await buildOauthConfig(event);
|
||||
|
||||
const session = await useWGSession(event);
|
||||
if (
|
||||
!session.data.oauth_nonce ||
|
||||
!session.data.oauth_verifier ||
|
||||
!session.data.oauth_state
|
||||
) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Missing OAuth State',
|
||||
});
|
||||
}
|
||||
|
||||
const userInfo = await getUserInfo(
|
||||
event,
|
||||
config,
|
||||
{
|
||||
oauth_nonce: session.data.oauth_nonce,
|
||||
oauth_verifier: session.data.oauth_verifier,
|
||||
oauth_state: session.data.oauth_state,
|
||||
},
|
||||
providerConfig
|
||||
);
|
||||
|
||||
const result = await Database.users.loginWithOAuth(
|
||||
provider,
|
||||
userInfo.sub,
|
||||
userInfo.preferred_username || userInfo.email,
|
||||
userInfo.email,
|
||||
userInfo.name || 'User'
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
switch (result.error) {
|
||||
case 'TOTP_REQUIRED':
|
||||
await session.update({
|
||||
pendingLogin: {
|
||||
type: 'oauth',
|
||||
userId: result.userId,
|
||||
remember: false,
|
||||
},
|
||||
oauth_nonce: undefined,
|
||||
oauth_state: undefined,
|
||||
oauth_verifier: undefined,
|
||||
});
|
||||
return sendRedirect(event, '/login/2fa');
|
||||
case 'USER_DISABLED':
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'User disabled',
|
||||
});
|
||||
case 'USER_ALREADY_LINKED':
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage:
|
||||
'User already linked with different account or provider',
|
||||
});
|
||||
case 'AUTO_REGISTER_DISABLED':
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Auto registration is disabled',
|
||||
});
|
||||
case 'UNEXPECTED_ERROR':
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Unexpected error',
|
||||
});
|
||||
}
|
||||
assertUnreachable(result);
|
||||
}
|
||||
|
||||
// Create session
|
||||
const data = await session.update({
|
||||
userId: result.user.id,
|
||||
oauth_nonce: undefined,
|
||||
oauth_state: undefined,
|
||||
oauth_verifier: undefined,
|
||||
});
|
||||
|
||||
SERVER_DEBUG(
|
||||
`New OAuth Session: ${data.id} for ${result.user.id} (${result.user.username}) with ${provider}`
|
||||
);
|
||||
|
||||
return sendRedirect(event, '/');
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as client from 'openid-client';
|
||||
import { z } from 'zod';
|
||||
|
||||
const OauthQuerySchema = z.object({
|
||||
link: z.coerce.boolean().optional(),
|
||||
});
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = await getValidatedQuery(
|
||||
event,
|
||||
validateZod(OauthQuerySchema, event)
|
||||
);
|
||||
|
||||
const { config, provider, providerConfig } = await buildOauthConfig(event);
|
||||
|
||||
const host = getRequestHost(event);
|
||||
const protocol = WG_ENV.INSECURE ? 'http' : 'https';
|
||||
const baseUri = `${protocol}://${host}/api/auth/${provider}`;
|
||||
|
||||
let redirectUri = `${baseUri}/callback`;
|
||||
if (params.link) {
|
||||
redirectUri = `${baseUri}/link`;
|
||||
}
|
||||
|
||||
const codeVerifier = client.randomPKCECodeVerifier();
|
||||
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
||||
const nonce = client.randomNonce();
|
||||
const state = client.randomState();
|
||||
|
||||
const parameters: Record<string, string> = {
|
||||
...providerConfig.params,
|
||||
redirect_uri: redirectUri,
|
||||
scope: providerConfig.scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
nonce: nonce,
|
||||
state: state,
|
||||
};
|
||||
|
||||
const session = await useWGSession(event);
|
||||
await session.update({
|
||||
oauth_nonce: nonce,
|
||||
oauth_verifier: codeVerifier,
|
||||
oauth_state: state,
|
||||
});
|
||||
|
||||
const redirectTo = client.buildAuthorizationUrl(config, parameters);
|
||||
|
||||
return sendRedirect(event, redirectTo.toString());
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
export default definePermissionEventHandler(
|
||||
'me',
|
||||
'update',
|
||||
async ({ event, user, checkPermissions }) => {
|
||||
checkPermissions(user);
|
||||
|
||||
const { config, provider, providerConfig } = await buildOauthConfig(event);
|
||||
|
||||
const session = await useWGSession(event);
|
||||
if (
|
||||
!session.data.oauth_nonce ||
|
||||
!session.data.oauth_verifier ||
|
||||
!session.data.oauth_state
|
||||
) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Missing OAuth State',
|
||||
});
|
||||
}
|
||||
|
||||
const userInfo = await getUserInfo(
|
||||
event,
|
||||
config,
|
||||
{
|
||||
oauth_nonce: session.data.oauth_nonce,
|
||||
oauth_verifier: session.data.oauth_verifier,
|
||||
oauth_state: session.data.oauth_state,
|
||||
},
|
||||
providerConfig
|
||||
);
|
||||
|
||||
await Database.users.linkOauth(user.id, provider, userInfo.sub);
|
||||
|
||||
return sendRedirect(event, '/me');
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await useWGSession(event, false);
|
||||
await session.update({
|
||||
pendingLogin: undefined,
|
||||
oauth_nonce: undefined,
|
||||
oauth_state: undefined,
|
||||
oauth_verifier: undefined,
|
||||
});
|
||||
return { success: true as const };
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
export default defineEventHandler(() => {
|
||||
return {
|
||||
providers: WG_ENV.OAUTH_PROVIDERS?.reduce(
|
||||
(acc, curr) => {
|
||||
acc[curr] = {
|
||||
enabled: true,
|
||||
friendlyName: OAUTH_PROVIDERS[curr].friendlyName,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<OAUTH_PROVIDER, { enabled: true; friendlyName: string }>
|
||||
),
|
||||
oauthEnabled:
|
||||
WG_ENV.OAUTH_PROVIDERS !== undefined && WG_ENV.OAUTH_PROVIDERS.length > 0,
|
||||
passwordDisabled: WG_ENV.DISABLE_PASSWORD_AUTH,
|
||||
autoLaunchProvider: WG_ENV.OAUTH_AUTO_LAUNCH,
|
||||
};
|
||||
});
|
||||
@@ -1,12 +1,21 @@
|
||||
import { UserLoginSchema } from '#db/repositories/user/types';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { username, password, remember, totpCode } = await readValidatedBody(
|
||||
if (WG_ENV.DISABLE_PASSWORD_AUTH) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Password authentication is disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const { username, password, remember } = await readValidatedBody(
|
||||
event,
|
||||
validateZod(UserLoginSchema, event)
|
||||
);
|
||||
|
||||
const result = await Database.users.login(username, password, totpCode);
|
||||
const result = await Database.users.login(username, password);
|
||||
|
||||
const session = await useWGSession(event, remember);
|
||||
|
||||
// TODO: add localization support
|
||||
|
||||
@@ -18,6 +27,13 @@ export default defineEventHandler(async (event) => {
|
||||
statusMessage: 'Invalid username or password',
|
||||
});
|
||||
case 'TOTP_REQUIRED':
|
||||
await session.update({
|
||||
pendingLogin: {
|
||||
type: 'password',
|
||||
userId: result.userId,
|
||||
remember,
|
||||
},
|
||||
});
|
||||
return { status: 'TOTP_REQUIRED' as const };
|
||||
case 'INVALID_TOTP_CODE':
|
||||
return { status: 'INVALID_TOTP_CODE' as const };
|
||||
@@ -32,13 +48,11 @@ export default defineEventHandler(async (event) => {
|
||||
statusMessage: 'Unexpected error',
|
||||
});
|
||||
}
|
||||
assertUnreachable(result.error);
|
||||
assertUnreachable(result);
|
||||
}
|
||||
|
||||
const user = result.user;
|
||||
|
||||
const session = await useWGSession(event, remember);
|
||||
|
||||
const data = await session.update({
|
||||
userId: user.id,
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await useWGSession(event);
|
||||
|
||||
if (!session.data.pendingLogin) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'No pending authentication',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: session.data.pendingLogin.type,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export default definePermissionEventHandler(
|
||||
'me',
|
||||
'update',
|
||||
async ({ user, checkPermissions }) => {
|
||||
checkPermissions(user);
|
||||
|
||||
await Database.users.unlinkOauth(user.id);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const Verify2faSchema = z.object({
|
||||
totpCode: z.string().min(6).max(6),
|
||||
});
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { totpCode } = await readValidatedBody(
|
||||
event,
|
||||
validateZod(Verify2faSchema, event)
|
||||
);
|
||||
const session = await useWGSession(event);
|
||||
|
||||
const pendingLogin = session.data.pendingLogin;
|
||||
if (!pendingLogin) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'No pending authentication',
|
||||
});
|
||||
}
|
||||
|
||||
const totpStatus = await Database.users.validateTotpCode(
|
||||
pendingLogin.userId,
|
||||
totpCode
|
||||
);
|
||||
|
||||
switch (totpStatus) {
|
||||
case 'INVALID_TOTP_CODE':
|
||||
return { status: 'INVALID_TOTP_CODE' as const };
|
||||
case 'USER_DISABLED':
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'User disabled',
|
||||
});
|
||||
case 'success':
|
||||
break;
|
||||
default:
|
||||
assertUnreachable(totpStatus);
|
||||
}
|
||||
|
||||
await session.update({
|
||||
userId: pendingLogin.userId,
|
||||
pendingLogin: undefined,
|
||||
oauth_nonce: undefined,
|
||||
oauth_state: undefined,
|
||||
oauth_verifier: undefined,
|
||||
});
|
||||
|
||||
return { status: 'success' as const };
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { SharedPublicUser } from '~~/shared/utils/permissions';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await useWGSession(event);
|
||||
|
||||
if (!session.data.userId) {
|
||||
if (!session.data.userId || session.data.pendingLogin) {
|
||||
// not logged in
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
@@ -18,6 +16,12 @@ export default defineEventHandler(async (event) => {
|
||||
statusMessage: 'Not found in Database',
|
||||
});
|
||||
}
|
||||
if (!user.enabled) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'User is disabled',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -26,5 +30,7 @@ export default defineEventHandler(async (event) => {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
totpVerified: user.totpVerified,
|
||||
oauthProvider: user.oauthProvider,
|
||||
hasPassword: user.password !== null,
|
||||
} satisfies SharedPublicUser;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
console.log(`====================================================`);
|
||||
console.log(` wg-easy - https://github.com/wg-easy/wg-easy `);
|
||||
console.log(`====================================================`);
|
||||
console.log(`| wg-easy: ${RELEASE.padEnd(38)} |`);
|
||||
console.log(`| Node: ${process.version.padEnd(38)} |`);
|
||||
console.log(`| Platform: ${process.platform.padEnd(38)} |`);
|
||||
console.log(`| Arch: ${process.arch.padEnd(38)} |`);
|
||||
console.log(`====================================================`);
|
||||
console.log(`
|
||||
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ wg-easy - https://github.com/wg-easy/wg-easy ┃
|
||||
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
┃ wg-easy: ${RELEASE.padEnd(38)} ┃
|
||||
┃ Node: ${process.version.padEnd(38)} ┃
|
||||
┃ Platform: ${process.platform.padEnd(38)} ┃
|
||||
┃ Arch: ${process.arch.padEnd(38)} ┃
|
||||
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
`);
|
||||
nitroApp.hooks.hook('close', async () => {
|
||||
console.log('Shutting down');
|
||||
await WireGuard.Shutdown();
|
||||
|
||||
@@ -30,6 +30,11 @@ const detectAwg = async (): Promise<'awg' | 'wg'> => {
|
||||
} else return 'wg';
|
||||
};
|
||||
|
||||
const oauthProviders = process.env.OAUTH_PROVIDERS?.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => isValidOauthProvider(v))
|
||||
.filter((v) => isConfiguredOauthProvider(OAUTH_PROVIDERS[v]));
|
||||
|
||||
export const WG_ENV = {
|
||||
/** UI is hosted on HTTP instead of HTTPS */
|
||||
INSECURE: process.env.INSECURE === 'true',
|
||||
@@ -39,8 +44,31 @@ export const WG_ENV = {
|
||||
DISABLE_IPV6: process.env.DISABLE_IPV6 === 'true',
|
||||
WG_EXECUTABLE: await detectAwg(),
|
||||
DISABLE_VERSION_CHECK: process.env.DISABLE_VERSION_CHECK === 'true',
|
||||
/** List of enabled OAuth providers */
|
||||
OAUTH_PROVIDERS: oauthProviders,
|
||||
/** List of allowed OAuth domains */
|
||||
OAUTH_ALLOWED_DOMAINS: process.env.OAUTH_ALLOWED_DOMAINS?.split(',').map(
|
||||
(v) => v.trim()
|
||||
),
|
||||
/** Automatically register users that log in with an OAuth provider */
|
||||
OAUTH_AUTO_REGISTER: process.env.OAUTH_AUTO_REGISTER === 'true',
|
||||
/** Which OAuth provider to automatically launch */
|
||||
OAUTH_AUTO_LAUNCH:
|
||||
oauthProviders?.find((p) => p === process.env.OAUTH_AUTO_LAUNCH) ?? null,
|
||||
/** Disable password authentication */
|
||||
DISABLE_PASSWORD_AUTH: process.env.DISABLE_PASSWORD_AUTH === 'true',
|
||||
};
|
||||
|
||||
if (WG_ENV.OAUTH_PROVIDERS && WG_ENV.OAUTH_PROVIDERS.length > 0) {
|
||||
SERVER_DEBUG(`
|
||||
Enabled OAuth providers: ${WG_ENV.OAUTH_PROVIDERS.join(', ')}
|
||||
Allowed OAuth domains: ${WG_ENV.OAUTH_ALLOWED_DOMAINS?.join(', ') ?? 'All'}
|
||||
OAuth auto register: ${WG_ENV.OAUTH_AUTO_REGISTER ? 'Enabled' : 'Disabled'}
|
||||
Password authentication: ${WG_ENV.DISABLE_PASSWORD_AUTH ? 'Disabled' : 'Enabled'}
|
||||
Auto launch OAuth provider: ${WG_ENV.OAUTH_AUTO_LAUNCH ?? 'None'}
|
||||
`);
|
||||
}
|
||||
|
||||
export const WG_INITIAL_ENV = {
|
||||
ENABLED: process.env.INIT_ENABLED === 'true',
|
||||
USERNAME: process.env.INIT_USERNAME,
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { H3Event } from 'h3';
|
||||
import * as client from 'openid-client';
|
||||
|
||||
type OAuthConfig = {
|
||||
friendlyName: string;
|
||||
server: string;
|
||||
scope: string;
|
||||
clientId: string | undefined;
|
||||
clientSecret: string | undefined;
|
||||
params: Record<string, string>;
|
||||
isOIDC?: false;
|
||||
userInfoFlow?: 'github';
|
||||
};
|
||||
|
||||
const GoogleConfig: OAuthConfig = {
|
||||
friendlyName: 'Google',
|
||||
server: 'https://accounts.google.com',
|
||||
scope: 'openid email profile',
|
||||
clientId: process.env.OAUTH_GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.OAUTH_GOOGLE_CLIENT_SECRET,
|
||||
params: {
|
||||
access_type: 'online',
|
||||
prompt: 'select_account',
|
||||
},
|
||||
};
|
||||
const GithubConfig: OAuthConfig = {
|
||||
friendlyName: 'GitHub',
|
||||
server: 'https://github.com/login/oauth',
|
||||
scope: 'read:user user:email',
|
||||
clientId: process.env.OAUTH_GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.OAUTH_GITHUB_CLIENT_SECRET,
|
||||
params: {
|
||||
allow_signup: 'false',
|
||||
prompt: 'select_account',
|
||||
},
|
||||
isOIDC: false,
|
||||
userInfoFlow: 'github',
|
||||
};
|
||||
const OidcConfig: OAuthConfig = {
|
||||
friendlyName: process.env.OAUTH_OIDC_NAME ?? 'OIDC',
|
||||
server: process.env.OAUTH_OIDC_SERVER ?? '',
|
||||
scope: 'openid email profile',
|
||||
clientId: process.env.OAUTH_OIDC_CLIENT_ID,
|
||||
clientSecret: process.env.OAUTH_OIDC_CLIENT_SECRET,
|
||||
params: {},
|
||||
};
|
||||
|
||||
export const OAUTH_PROVIDERS = {
|
||||
google: GoogleConfig,
|
||||
github: GithubConfig,
|
||||
oidc: OidcConfig,
|
||||
};
|
||||
|
||||
export type OAUTH_PROVIDER = keyof typeof OAUTH_PROVIDERS;
|
||||
|
||||
export function isValidOauthProvider(
|
||||
provider: string
|
||||
): provider is OAUTH_PROVIDER {
|
||||
if (provider in OAUTH_PROVIDERS) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isConfiguredOauthProvider(
|
||||
oauthProvider: (typeof OAUTH_PROVIDERS)[OAUTH_PROVIDER]
|
||||
): oauthProvider is (typeof OAUTH_PROVIDERS)[OAUTH_PROVIDER] & {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
} {
|
||||
if (!oauthProvider.clientId || !oauthProvider.clientSecret) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isEnabledProvider(provider: OAUTH_PROVIDER) {
|
||||
return WG_ENV.OAUTH_PROVIDERS?.includes(provider);
|
||||
}
|
||||
|
||||
// TODO: simplify logic between WG_ENV.OAUTH_PROVIDERS and buildOauthConfig
|
||||
export async function buildOauthConfig(event: H3Event) {
|
||||
const provider = getRouterParam(event, 'provider');
|
||||
if (!provider || !isValidOauthProvider(provider)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid provider',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isEnabledProvider(provider)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Provider is not enabled',
|
||||
});
|
||||
}
|
||||
|
||||
const oauthProvider = OAUTH_PROVIDERS[provider];
|
||||
|
||||
if (!isConfiguredOauthProvider(oauthProvider)) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Provider is not configured',
|
||||
});
|
||||
}
|
||||
|
||||
const config = await client.discovery(
|
||||
new URL(oauthProvider.server),
|
||||
oauthProvider.clientId,
|
||||
{
|
||||
client_secret: oauthProvider.clientSecret,
|
||||
}
|
||||
);
|
||||
|
||||
return { config, providerConfig: oauthProvider, provider };
|
||||
}
|
||||
|
||||
export async function githubUserInfoFlow(accessToken: string) {
|
||||
const OAUTH_GITHUB_FLOW = {
|
||||
userinfo_endpoint: 'https://api.github.com/user',
|
||||
email_endpoint: 'https://api.github.com/user/emails',
|
||||
};
|
||||
type OAUTH_GITHUB_USERINFO = {
|
||||
id: number;
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
};
|
||||
type OAUTH_GITHUB_EMAIL = {
|
||||
email: string;
|
||||
primary: boolean;
|
||||
verified: boolean;
|
||||
visibility: string | null;
|
||||
}[];
|
||||
|
||||
const response = await $fetch<OAUTH_GITHUB_USERINFO>(
|
||||
OAUTH_GITHUB_FLOW.userinfo_endpoint,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'wg-easy',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.email) {
|
||||
const emailResponse = await $fetch<OAUTH_GITHUB_EMAIL>(
|
||||
OAUTH_GITHUB_FLOW.email_endpoint,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'wg-easy',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const primaryEmail = emailResponse.find((v) => v.primary && v.verified);
|
||||
response.email = primaryEmail?.email || null;
|
||||
}
|
||||
return {
|
||||
sub: response.id.toString(),
|
||||
email: response.email ?? undefined,
|
||||
email_verified: true,
|
||||
preferred_username: response.login,
|
||||
name: response.name || response.login,
|
||||
};
|
||||
}
|
||||
|
||||
type OauthState = {
|
||||
oauth_nonce: string;
|
||||
oauth_verifier: string;
|
||||
oauth_state: string;
|
||||
};
|
||||
|
||||
export async function getUserInfo(
|
||||
event: H3Event,
|
||||
config: client.Configuration,
|
||||
state: OauthState,
|
||||
providerConfig: OAuthConfig
|
||||
) {
|
||||
const currentUrl = getRequestURL(event);
|
||||
|
||||
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
|
||||
pkceCodeVerifier: state.oauth_verifier,
|
||||
expectedNonce:
|
||||
providerConfig.isOIDC === false ? undefined : state.oauth_nonce,
|
||||
expectedState: state.oauth_state,
|
||||
idTokenExpected: providerConfig.isOIDC ?? true,
|
||||
});
|
||||
|
||||
type SubjectType = string | undefined | typeof client.skipSubjectCheck;
|
||||
let subject: SubjectType = tokens.claims()?.sub;
|
||||
if (providerConfig.isOIDC === false) {
|
||||
subject = client.skipSubjectCheck;
|
||||
}
|
||||
|
||||
if (!subject) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Can't get subject",
|
||||
});
|
||||
}
|
||||
|
||||
let userInfo: client.UserInfoResponse;
|
||||
if (providerConfig.userInfoFlow === 'github') {
|
||||
userInfo = await githubUserInfoFlow(tokens.access_token);
|
||||
} else {
|
||||
userInfo = await client.fetchUserInfo(config, tokens.access_token, subject);
|
||||
}
|
||||
|
||||
assertHasOauthProps(userInfo);
|
||||
|
||||
if (!isAllowedDomain(userInfo.email)) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Email domain not allowed',
|
||||
});
|
||||
}
|
||||
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>>;
|
||||
|
||||
function assertHasOauthProps<T extends client.UserInfoResponse>(
|
||||
userInfo: T
|
||||
): asserts userInfo is T & RequireKeys<T, 'sub' | 'email' | 'email_verified'> {
|
||||
if (!userInfo.sub) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'No sub set',
|
||||
});
|
||||
}
|
||||
|
||||
if (!userInfo.email) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'No email set',
|
||||
});
|
||||
}
|
||||
|
||||
if (!userInfo.email_verified) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Email is not verified',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedDomain(email: string) {
|
||||
const emailDomain = email.slice(email.lastIndexOf('@') + 1);
|
||||
if (
|
||||
WG_ENV.OAUTH_ALLOWED_DOMAINS &&
|
||||
!WG_ENV.OAUTH_ALLOWED_DOMAINS.includes(emailDomain)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -3,13 +3,22 @@
|
||||
import argon2 from 'argon2';
|
||||
import { deserialize } from '@phc/format';
|
||||
|
||||
const DUMMY_HASH =
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$jsh6z1/SbZHYAiO/Ww9HZw$ikzkoXWqc2b0Pc4O8ZNJjp1xKZSb7SNM/3dPMNUPk9Y';
|
||||
|
||||
/**
|
||||
* Checks if `password` matches the hash.
|
||||
* Checks if `password` matches the `hash`.
|
||||
*
|
||||
* Checks against `DUMMY_HASH` and returns false if `hash` is null
|
||||
*/
|
||||
export function isPasswordValid(
|
||||
export async function isPasswordValid(
|
||||
password: string,
|
||||
hash: string
|
||||
hash: string | null
|
||||
): Promise<boolean> {
|
||||
if (hash === null) {
|
||||
await argon2.verify(DUMMY_HASH, password);
|
||||
return false;
|
||||
}
|
||||
return argon2.verify(hash, password);
|
||||
}
|
||||
|
||||
|
||||
+13
-11
@@ -3,6 +3,15 @@ import type { UserType } from '#db/repositories/user/types';
|
||||
|
||||
export type WGSession = Partial<{
|
||||
userId: ID;
|
||||
// TODO: add pending login expiration
|
||||
pendingLogin: {
|
||||
type: 'password' | 'oauth';
|
||||
userId: ID;
|
||||
remember: boolean;
|
||||
};
|
||||
oauth_verifier: string;
|
||||
oauth_nonce: string;
|
||||
oauth_state: string;
|
||||
}>;
|
||||
|
||||
const name = 'wg-easy';
|
||||
@@ -70,21 +79,14 @@ export async function getCurrentUser(event: H3Event) {
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: timing can be used to enumerate usernames
|
||||
|
||||
const foundUser = await Database.users.getByUsername(username);
|
||||
|
||||
if (!foundUser) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Session failed',
|
||||
});
|
||||
}
|
||||
|
||||
const userHashPassword = foundUser.password;
|
||||
// always check to avoid timing attack
|
||||
const userHashPassword = foundUser?.password ?? null;
|
||||
const passwordValid = await isPasswordValid(password, userHashPassword);
|
||||
|
||||
if (!passwordValid) {
|
||||
// can't login through basic auth if 2fa enabled
|
||||
if (!foundUser || !passwordValid || foundUser.totpVerified) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Session failed',
|
||||
|
||||
Reference in New Issue
Block a user