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,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { UserLoginSchema } from '#db/repositories/user/types';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
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);
|
||||
|
||||
const session = await useWGSession(event, remember);
|
||||
|
||||
// TODO: add localization support
|
||||
|
||||
if (!result.success) {
|
||||
switch (result.error) {
|
||||
case 'INCORRECT_CREDENTIALS':
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
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 };
|
||||
case 'USER_DISABLED':
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'User disabled',
|
||||
});
|
||||
case 'UNEXPECTED_ERROR':
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Unexpected error',
|
||||
});
|
||||
}
|
||||
assertUnreachable(result);
|
||||
}
|
||||
|
||||
const user = result.user;
|
||||
|
||||
const data = await session.update({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// TODO?: create audit log
|
||||
|
||||
SERVER_DEBUG(`New Session: ${data.id} for ${user.id} (${user.username})`);
|
||||
|
||||
return { status: 'success' as const };
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
Reference in New Issue
Block a user