mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
510 lines
18 KiB
TypeScript
510 lines
18 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {
|
|
completePasskeyMigration,
|
|
getPasskeyMigration,
|
|
getPasskeyMigrationRegistrationOptions,
|
|
} from '@app/api/auth/services/PasskeyMigrationService';
|
|
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
|
|
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
|
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
|
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
|
import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
|
|
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
|
import type {HonoApp} from '@app/api/types/HonoEnv';
|
|
import {assertValidTotpSetupCode} from '@app/api/user/services/UserAuth';
|
|
import {Validator} from '@app/api/Validator';
|
|
import {
|
|
DisableTotpRequest,
|
|
EnableMfaTotpRequest,
|
|
MfaBackupCodesChallengeRegenerateRequest,
|
|
MfaBackupCodesChallengeResendRequest,
|
|
MfaBackupCodesChallengeStartResponse,
|
|
MfaBackupCodesChallengeVerifyRequest,
|
|
MfaBackupCodesChallengeVerifyResponse,
|
|
MfaBackupCodesRequest,
|
|
MfaBackupCodesResponse,
|
|
SudoMfaMethodsResponse,
|
|
SudoVerificationSchema,
|
|
WebAuthnChallengeResponse,
|
|
WebAuthnCredentialListResponse,
|
|
WebAuthnCredentialUpdateRequest,
|
|
WebAuthnRegisterRequest,
|
|
WebAuthnTwoFactorRequest,
|
|
WebAuthnTwoFactorResponse,
|
|
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
|
import {
|
|
PasskeyMigrationCompleteRequest,
|
|
PasskeyMigrationResponse,
|
|
} from '@fluxer/schema/src/domains/auth/PasskeyMigrationSchemas';
|
|
import {CredentialIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
|
import {EmptyBodyRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
|
|
|
export function UserAuthController(app: HonoApp) {
|
|
app.post(
|
|
'/users/@me/mfa/totp/enable',
|
|
RateLimitMiddleware(RateLimitConfigs.USER_MFA_TOTP_ENABLE),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
SudoModeMiddleware,
|
|
Validator('json', EnableMfaTotpRequest),
|
|
OpenAPI({
|
|
operationId: 'enable_totp_mfa',
|
|
summary: 'Enable TOTP multi-factor authentication',
|
|
responseSchema: MfaBackupCodesResponse,
|
|
statusCode: 200,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Enable time-based one-time password (TOTP) MFA on the current account. Returns backup codes for account recovery. Requires sudo mode verification.',
|
|
}),
|
|
async (ctx) => {
|
|
const body = ctx.req.valid('json');
|
|
const user = ctx.get('user');
|
|
await assertValidTotpSetupCode(body.secret, body.code);
|
|
const sudoResult = await requireSudoMode(ctx, user, body);
|
|
return ctx.json(
|
|
await ctx.get('userAuthRequestService').enableTotp({
|
|
user,
|
|
data: body,
|
|
sudoContext: sudoResult,
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
app.post(
|
|
'/users/@me/mfa/totp/disable',
|
|
RateLimitMiddleware(RateLimitConfigs.USER_MFA_TOTP_DISABLE),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
SudoModeMiddleware,
|
|
Validator('json', DisableTotpRequest),
|
|
OpenAPI({
|
|
operationId: 'disable_totp_mfa',
|
|
summary: 'Disable TOTP multi-factor authentication',
|
|
responseSchema: null,
|
|
statusCode: 204,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Disable TOTP multi-factor authentication on the current account. Requires sudo mode verification for security.',
|
|
}),
|
|
async (ctx) => {
|
|
const body = ctx.req.valid('json');
|
|
const user = ctx.get('user');
|
|
const sudoBody =
|
|
body.mfa_method || !user.totpSecret ? body : {...body, mfa_method: 'totp' as const, mfa_code: body.code};
|
|
const sudoResult = await requireSudoMode(ctx, user, sudoBody);
|
|
await ctx.get('userAuthRequestService').disableTotp({user, data: body, sudoContext: sudoResult});
|
|
return ctx.body(null, 204);
|
|
},
|
|
);
|
|
app.post(
|
|
'/users/@me/mfa/backup-codes',
|
|
RateLimitMiddleware(RateLimitConfigs.USER_MFA_BACKUP_CODES),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
SudoModeMiddleware,
|
|
Validator('json', MfaBackupCodesRequest),
|
|
OpenAPI({
|
|
operationId: 'get_backup_codes_mfa',
|
|
summary: 'Get backup codes for multi-factor authentication',
|
|
responseSchema: MfaBackupCodesResponse,
|
|
statusCode: 200,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Generate and retrieve new backup codes for account recovery. Requires sudo mode verification. Old codes are invalidated.',
|
|
}),
|
|
async (ctx) => {
|
|
const body = ctx.req.valid('json');
|
|
const user = ctx.get('user');
|
|
const sudoResult = await requireSudoMode(ctx, user, body);
|
|
return ctx.json(
|
|
await ctx.get('userAuthRequestService').getBackupCodes({user, data: body, sudoContext: sudoResult}),
|
|
);
|
|
},
|
|
);
|
|
app.post(
|
|
'/users/@me/mfa/backup-codes/challenge',
|
|
RateLimitMiddleware(RateLimitConfigs.USER_MFA_BACKUP_CODES_CHALLENGE_START),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
Validator('json', EmptyBodyRequest),
|
|
OpenAPI({
|
|
operationId: 'start_backup_codes_challenge',
|
|
summary: 'Start backup codes challenge',
|
|
responseSchema: MfaBackupCodesChallengeStartResponse,
|
|
statusCode: 200,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
"Initiates the challenge required to view existing backup codes. Sends a verification code to the user's email address. Returns a ticket for use in the remaining challenge steps.",
|
|
}),
|
|
async (ctx) => {
|
|
const user = ctx.get('user');
|
|
return ctx.json(await ctx.get('mfaBackupCodesChallengeService').start(user));
|
|
},
|
|
);
|
|
app.post(
|
|
'/users/@me/mfa/backup-codes/challenge/resend',
|
|
RateLimitMiddleware(RateLimitConfigs.USER_MFA_BACKUP_CODES_CHALLENGE_RESEND),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
Validator('json', MfaBackupCodesChallengeResendRequest),
|
|
OpenAPI({
|
|
operationId: 'resend_backup_codes_challenge',
|
|
summary: 'Resend backup codes challenge code',
|
|
responseSchema: null,
|
|
statusCode: 204,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Resends the verification code for a backup codes challenge. Use if the original code was not received. Requires a valid backup codes challenge ticket.',
|
|
}),
|
|
async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const body = ctx.req.valid('json');
|
|
await ctx.get('mfaBackupCodesChallengeService').resend(user, body.ticket);
|
|
return ctx.body(null, 204);
|
|
},
|
|
);
|
|
app.post(
|
|
'/users/@me/mfa/backup-codes/challenge/verify',
|
|
RateLimitMiddleware(RateLimitConfigs.USER_MFA_BACKUP_CODES_CHALLENGE_VERIFY),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
Validator('json', MfaBackupCodesChallengeVerifyRequest),
|
|
OpenAPI({
|
|
operationId: 'verify_backup_codes_challenge',
|
|
summary: 'Verify backup codes challenge code',
|
|
responseSchema: MfaBackupCodesChallengeVerifyResponse,
|
|
statusCode: 200,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Verifies the email code sent during a backup codes challenge and returns the existing backup codes along with a proof token. The code is consumed on success and the proof token authorizes regeneration on the same ticket.',
|
|
}),
|
|
async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const body = ctx.req.valid('json');
|
|
return ctx.json(await ctx.get('mfaBackupCodesChallengeService').verify(user, body.ticket, body.code));
|
|
},
|
|
);
|
|
app.post(
|
|
'/users/@me/mfa/backup-codes/challenge/regenerate',
|
|
RateLimitMiddleware(RateLimitConfigs.USER_MFA_BACKUP_CODES_CHALLENGE_REGENERATE),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
Validator('json', MfaBackupCodesChallengeRegenerateRequest),
|
|
OpenAPI({
|
|
operationId: 'regenerate_backup_codes_challenge',
|
|
summary: 'Regenerate backup codes with a verified challenge',
|
|
responseSchema: MfaBackupCodesResponse,
|
|
statusCode: 200,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Replaces the account backup codes using the proof token from a verified backup codes challenge. Old codes are invalidated.',
|
|
}),
|
|
async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const body = ctx.req.valid('json');
|
|
return ctx.json(
|
|
await ctx.get('mfaBackupCodesChallengeService').regenerate(user, body.ticket, body.verification_proof),
|
|
);
|
|
},
|
|
);
|
|
app.delete(
|
|
'/users/@me/authorized-ips',
|
|
RateLimitMiddleware(RateLimitConfigs.USER_AUTHORIZED_IPS_FORGET),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
SudoModeMiddleware,
|
|
Validator('json', SudoVerificationSchema),
|
|
OpenAPI({
|
|
operationId: 'forget_authorized_ips',
|
|
summary: 'Forget authorized IPs for current user',
|
|
responseSchema: null,
|
|
statusCode: 204,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Clears all authorized IP addresses for the current user. After calling this endpoint, the user will be required to re-authorize any new IP addresses they log in from. Requires sudo mode verification.',
|
|
}),
|
|
async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const body = ctx.req.valid('json');
|
|
await requireSudoMode(ctx, user, body);
|
|
await ctx.get('userAuthRequestService').forgetAuthorizedIps(user);
|
|
return ctx.body(null, 204);
|
|
},
|
|
);
|
|
app.get(
|
|
'/users/@me/mfa/webauthn/credentials',
|
|
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_LIST),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
OpenAPI({
|
|
operationId: 'list_webauthn_credentials',
|
|
summary: 'List WebAuthn credentials',
|
|
responseSchema: WebAuthnCredentialListResponse,
|
|
statusCode: 200,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Retrieve all registered WebAuthn credentials (security keys, biometric devices) for the current user. Requires authentication.',
|
|
}),
|
|
async (ctx) => {
|
|
return ctx.json(await ctx.get('userAuthRequestService').listWebAuthnCredentials(ctx.get('user')));
|
|
},
|
|
);
|
|
app.post(
|
|
'/users/@me/mfa/webauthn/credentials/registration-options',
|
|
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_REGISTRATION_OPTIONS),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
SudoModeMiddleware,
|
|
Validator('json', SudoVerificationSchema),
|
|
OpenAPI({
|
|
operationId: 'get_webauthn_registration_options',
|
|
summary: 'Get WebAuthn registration options',
|
|
responseSchema: WebAuthnChallengeResponse,
|
|
statusCode: 200,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Generate challenge and options to register a new WebAuthn credential. Requires sudo mode verification.',
|
|
}),
|
|
async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const body = ctx.req.valid('json');
|
|
await requireSudoMode(ctx, user, body, {
|
|
issueSudoToken: false,
|
|
});
|
|
return ctx.json(
|
|
await ctx.get('userAuthRequestService').generateWebAuthnRegistrationOptions(user, ctx.req.header('origin')),
|
|
);
|
|
},
|
|
);
|
|
app.post(
|
|
'/users/@me/mfa/webauthn/credentials',
|
|
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_REGISTER),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
Validator('json', WebAuthnRegisterRequest),
|
|
OpenAPI({
|
|
operationId: 'register_webauthn_credential',
|
|
summary: 'Register WebAuthn credential',
|
|
responseSchema: null,
|
|
statusCode: 204,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Complete registration of a new WebAuthn credential (security key or biometric device) using a challenge created after sudo mode verification.',
|
|
}),
|
|
async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const body = ctx.req.valid('json');
|
|
await ctx.get('userAuthRequestService').registerWebAuthnCredential({
|
|
user,
|
|
data: body,
|
|
});
|
|
return ctx.body(null, 204);
|
|
},
|
|
);
|
|
app.patch(
|
|
'/users/@me/mfa/webauthn/credentials/:credential_id',
|
|
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_UPDATE),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
Validator('param', CredentialIdParam),
|
|
Validator('json', WebAuthnCredentialUpdateRequest),
|
|
SudoModeMiddleware,
|
|
OpenAPI({
|
|
operationId: 'update_webauthn_credential',
|
|
summary: 'Update WebAuthn credential',
|
|
responseSchema: null,
|
|
statusCode: 204,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description: 'Update the name or settings of a registered WebAuthn credential. Requires sudo mode verification.',
|
|
}),
|
|
async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const {credential_id} = ctx.req.valid('param');
|
|
const {name, ...sudoBody} = ctx.req.valid('json');
|
|
await requireSudoMode(ctx, user, sudoBody);
|
|
await ctx.get('userAuthRequestService').renameWebAuthnCredential({
|
|
user,
|
|
credentialId: credential_id,
|
|
data: {name},
|
|
});
|
|
return ctx.body(null, 204);
|
|
},
|
|
);
|
|
app.delete(
|
|
'/users/@me/mfa/webauthn/credentials/:credential_id',
|
|
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_DELETE),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
Validator('param', CredentialIdParam),
|
|
SudoModeMiddleware,
|
|
Validator('json', SudoVerificationSchema),
|
|
OpenAPI({
|
|
operationId: 'delete_webauthn_credential',
|
|
summary: 'Delete WebAuthn credential',
|
|
responseSchema: null,
|
|
statusCode: 204,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Remove a registered WebAuthn credential from the current account. Requires sudo mode verification for security.',
|
|
}),
|
|
async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const {credential_id} = ctx.req.valid('param');
|
|
const body = ctx.req.valid('json');
|
|
await requireSudoMode(ctx, user, body);
|
|
await ctx.get('userAuthRequestService').deleteWebAuthnCredential({user, credentialId: credential_id});
|
|
return ctx.body(null, 204);
|
|
},
|
|
);
|
|
app.get(
|
|
'/users/@me/mfa/webauthn/migration',
|
|
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_MIGRATION),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
OpenAPI({
|
|
operationId: 'get_webauthn_migration',
|
|
summary: 'Get pending passkey update',
|
|
responseSchema: PasskeyMigrationResponse,
|
|
statusCode: 200,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Return the passkey this session can update to the new domain after using it within the last five minutes, or null.',
|
|
}),
|
|
async (ctx) => {
|
|
return ctx.json(await getPasskeyMigration(ctx.get('apiContext'), ctx.get('user').id, ctx.get('authSession')));
|
|
},
|
|
);
|
|
app.post(
|
|
'/users/@me/mfa/webauthn/migration/registration-options',
|
|
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_MIGRATION),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
OpenAPI({
|
|
operationId: 'get_webauthn_migration_registration_options',
|
|
summary: 'Get passkey update registration options',
|
|
responseSchema: WebAuthnChallengeResponse,
|
|
statusCode: 200,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Generate registration options for the passkey that replaces the pending one. Requires a pending passkey update for this session.',
|
|
}),
|
|
async (ctx) => {
|
|
return ctx.json(
|
|
await getPasskeyMigrationRegistrationOptions(
|
|
ctx.get('apiContext'),
|
|
ctx.get('user').id,
|
|
ctx.get('authSession'),
|
|
ctx.req.header('origin'),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
app.post(
|
|
'/users/@me/mfa/webauthn/migration',
|
|
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_MIGRATION),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
Validator('json', PasskeyMigrationCompleteRequest),
|
|
OpenAPI({
|
|
operationId: 'complete_webauthn_migration',
|
|
summary: 'Complete passkey update',
|
|
responseSchema: null,
|
|
statusCode: 204,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Register the replacement passkey under the name of the pending one. The old passkey stops appearing in lists and is removed together with its replacement.',
|
|
}),
|
|
async (ctx) => {
|
|
await completePasskeyMigration(
|
|
ctx.get('apiContext'),
|
|
ctx.get('user').id,
|
|
ctx.get('authSession'),
|
|
ctx.req.header('origin'),
|
|
ctx.req.valid('json'),
|
|
);
|
|
return ctx.body(null, 204);
|
|
},
|
|
);
|
|
app.put(
|
|
'/users/@me/mfa/webauthn/two-factor',
|
|
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_TWO_FACTOR),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
SudoModeMiddleware,
|
|
Validator('json', WebAuthnTwoFactorRequest),
|
|
OpenAPI({
|
|
operationId: 'set_webauthn_two_factor',
|
|
summary: 'Set WebAuthn two-factor authentication',
|
|
responseSchema: WebAuthnTwoFactorResponse,
|
|
statusCode: 200,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Choose whether registered passkeys are required as a second factor when signing in with email and password. Enabling requires at least one registered credential and mints backup codes when the account has none. Requires sudo mode verification.',
|
|
}),
|
|
async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const body = ctx.req.valid('json');
|
|
await requireSudoMode(ctx, user, body);
|
|
return ctx.json(await ctx.get('userAuthRequestService').setWebAuthnTwoFactor({user, data: body}));
|
|
},
|
|
);
|
|
app.get(
|
|
'/users/@me/sudo/mfa-methods',
|
|
RateLimitMiddleware(RateLimitConfigs.SUDO_MFA_METHODS),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
OpenAPI({
|
|
operationId: 'list_sudo_mfa_methods',
|
|
summary: 'List sudo multi-factor authentication methods',
|
|
responseSchema: SudoMfaMethodsResponse,
|
|
statusCode: 200,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Retrieve all available MFA methods for sudo mode verification (TOTP, WebAuthn). Requires authentication.',
|
|
}),
|
|
async (ctx) => {
|
|
return ctx.json(await ctx.get('userAuthRequestService').listSudoMfaMethods(ctx.get('user')));
|
|
},
|
|
);
|
|
app.post(
|
|
'/users/@me/sudo/webauthn/authentication-options',
|
|
RateLimitMiddleware(RateLimitConfigs.SUDO_WEBAUTHN_OPTIONS),
|
|
LoginRequired,
|
|
DefaultUserOnly,
|
|
OpenAPI({
|
|
operationId: 'get_sudo_webauthn_authentication_options',
|
|
summary: 'Get sudo WebAuthn authentication options',
|
|
responseSchema: WebAuthnChallengeResponse,
|
|
statusCode: 200,
|
|
security: ['bearerToken', 'sessionToken'],
|
|
tags: ['Users'],
|
|
description:
|
|
'Generate WebAuthn challenge for sudo mode verification using a registered security key or biometric device.',
|
|
}),
|
|
async (ctx) => {
|
|
return ctx.json(
|
|
await ctx.get('userAuthRequestService').getSudoWebAuthnOptions(ctx.get('user'), ctx.req.header('origin')),
|
|
);
|
|
},
|
|
);
|
|
}
|