Files
fluxer/fluxer_api/src/api/auth/AuthController.ts
T

689 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: AGPL-3.0-or-later
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
import {Config} from '@app/api/Config';
import {DefaultUserOnly, LoginRequiredAllowSuspicious} from '@app/api/middleware/AuthMiddleware';
import {CaptchaMiddleware} from '@app/api/middleware/CaptchaMiddleware';
import {LocalAuthMiddleware} from '@app/api/middleware/LocalAuthMiddleware';
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 {Validator} from '@app/api/Validator';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {
AuthLoginResponse,
AuthorizeIpRequest,
AuthRegisterResponse,
AuthSessionsResponse,
AuthTokenWithUserIdResponse,
EmailRevertRequest,
ForgotPasswordRequest,
HandoffCancelRequest,
HandoffCodeParam,
HandoffCompleteRequest,
HandoffInfoResponse,
HandoffInitiateResponse,
HandoffStatusRequest,
HandoffStatusResponse,
IpAuthorizationPollQuery,
IpAuthorizationPollResponse,
LoginRequest,
LogoutAuthSessionsWithVerificationRequest,
MfaTicketRequest,
MfaTotpRequest,
RegisterRequest,
ResetPasswordRequest,
ResetPasswordTokenParam,
SsoCompleteRequest,
SsoCompleteResponse,
SsoStartRequest,
SsoStartResponse,
SsoStatusResponse,
UsernameSuggestionsRequest,
UsernameSuggestionsResponse,
ValidateResetPasswordTokenResponse,
VerifyEmailRequest,
WebAuthnAuthenticateRequest,
WebAuthnAuthenticationOptionsResponse,
WebAuthnMfaRequest,
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
export function AuthController(app: HonoApp) {
app.get(
'/auth/sso/status',
RateLimitMiddleware(RateLimitConfigs.AUTH_SSO_START),
OpenAPI({
operationId: 'get_sso_status',
summary: 'Get SSO status',
responseSchema: SsoStatusResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description: 'Retrieve the current status of the SSO authentication session without authentication required.',
}),
async (ctx) => {
const status = await ctx.get('authRequestService').getSsoStatus();
return ctx.json(status);
},
);
app.post(
'/auth/sso/start',
RateLimitMiddleware(RateLimitConfigs.AUTH_SSO_START),
Validator('json', SsoStartRequest),
OpenAPI({
operationId: 'start_sso',
summary: 'Start SSO',
responseSchema: SsoStartResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Initiate a new Single Sign-On (SSO) session. Returns a session URL to be completed with SSO provider credentials.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').startSso(ctx.req.valid('json'));
return ctx.json(result);
},
);
app.post(
'/auth/sso/complete',
RateLimitMiddleware(RateLimitConfigs.AUTH_SSO_COMPLETE),
Validator('json', SsoCompleteRequest),
OpenAPI({
operationId: 'complete_sso',
summary: 'Complete SSO',
responseSchema: SsoCompleteResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Complete the SSO authentication flow with the authorization code from the SSO provider. Returns authentication token and user information.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').completeSso(ctx.req.valid('json'), ctx.req.raw);
return ctx.json(result);
},
);
app.post(
'/auth/register',
LocalAuthMiddleware,
CaptchaMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_REGISTER),
Validator('json', RegisterRequest),
OpenAPI({
operationId: 'register_account',
summary: 'Register account',
responseSchema: AuthRegisterResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Create a new user account with email and password. Requires CAPTCHA verification. User account is created but must verify email before logging in.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').register({
data: ctx.req.valid('json'),
request: ctx.req.raw,
requestCache: ctx.get('requestCache'),
});
return ctx.json(result);
},
);
app.post(
'/auth/login',
LocalAuthMiddleware,
CaptchaMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN),
Validator('json', LoginRequest),
OpenAPI({
operationId: 'login_user',
summary: 'Login account',
responseSchema: AuthLoginResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Authenticate with email and password. Returns authentication token if credentials are valid and MFA is not required. If MFA is enabled, returns a ticket for MFA verification.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').login({
data: ctx.req.valid('json'),
request: ctx.req.raw,
requestCache: ctx.get('requestCache'),
});
return ctx.json(result);
},
);
app.post(
'/auth/login/mfa/totp',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
Validator('json', MfaTotpRequest),
OpenAPI({
operationId: 'login_with_totp',
summary: 'Login with TOTP',
responseSchema: AuthTokenWithUserIdResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Complete login by verifying TOTP code during multi-factor authentication. Requires the MFA ticket from initial login attempt.',
}),
async (ctx) => {
const {code, ticket} = ctx.req.valid('json');
const result = await ctx.get('authRequestService').loginMfaTotp({code, ticket, request: ctx.req.raw});
return ctx.json(result);
},
);
app.post(
'/auth/logout',
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGOUT),
LoginRequiredAllowSuspicious,
OpenAPI({
operationId: 'logout_user',
summary: 'Logout account',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Auth'],
description:
'Invalidate the current authentication token and end the session. The auth token in the Authorization header will no longer be valid. A bot token has no session to end, so the call answers 204 and the token stays valid.',
}),
async (ctx) => {
await ctx.get('authRequestService').logout({authToken: ctx.get('authToken')});
return ctx.body(null, 204);
},
);
app.post(
'/auth/verify',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_VERIFY_EMAIL),
Validator('json', VerifyEmailRequest),
OpenAPI({
operationId: 'verify_email',
summary: 'Verify email',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description:
'Verify user email address using the code sent during registration. Email verification is required before the account becomes fully usable.',
}),
async (ctx) => {
await ctx.get('authRequestService').verifyEmail(ctx.req.valid('json'));
return ctx.body(null, 204);
},
);
app.post(
'/auth/verify/resend',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_RESEND_VERIFICATION),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
OpenAPI({
operationId: 'resend_verification_email',
summary: 'Resend verification email',
responseSchema: null,
statusCode: 204,
security: ['bearerToken', 'sessionToken'],
tags: ['Auth'],
description:
'Request a new email verification code to be sent. Requires authentication. Use this if the original verification email was lost or expired.',
}),
async (ctx) => {
await ctx.get('authRequestService').resendVerificationEmail(ctx.get('user'));
return ctx.body(null, 204);
},
);
app.post(
'/auth/forgot',
LocalAuthMiddleware,
CaptchaMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_FORGOT_PASSWORD),
Validator('json', ForgotPasswordRequest),
OpenAPI({
operationId: 'forgot_password',
summary: 'Forgot password',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description:
"Initiate password reset process by email. A password reset link will be sent to the user's email address. Requires CAPTCHA verification.",
}),
async (ctx) => {
await ctx.get('authRequestService').forgotPassword({
data: ctx.req.valid('json'),
request: ctx.req.raw,
});
return ctx.body(null, 204);
},
);
app.get(
'/auth/reset/:token',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_VALIDATE_RESET_TOKEN),
Validator('param', ResetPasswordTokenParam),
OpenAPI({
operationId: 'validate_reset_password_token',
summary: 'Validate reset password token',
responseSchema: ValidateResetPasswordTokenResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Check whether a password reset token is valid and unexpired before allowing the user to submit a new password. Does not consume the token.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').validateResetPasswordToken(ctx.req.valid('param').token);
return ctx.json(result);
},
);
app.post(
'/auth/reset',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_RESET_PASSWORD),
Validator('json', ResetPasswordRequest),
OpenAPI({
operationId: 'reset_password',
summary: 'Reset password',
responseSchema: AuthLoginResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Complete the password reset flow using the token from the reset email. Returns authentication token after successful password reset.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').resetPassword({
data: ctx.req.valid('json'),
request: ctx.req.raw,
});
return ctx.json(result);
},
);
app.post(
'/auth/email-revert',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_EMAIL_REVERT),
Validator('json', EmailRevertRequest),
OpenAPI({
operationId: 'revert_email_change',
summary: 'Revert email change',
responseSchema: AuthLoginResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Revert a pending email change using the verification token sent to the old email. Returns authentication token after successful revert.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').revertEmailChange({
data: ctx.req.valid('json'),
request: ctx.req.raw,
});
return ctx.json(result);
},
);
app.get(
'/auth/sessions',
RateLimitMiddleware(RateLimitConfigs.AUTH_SESSIONS_GET),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
OpenAPI({
operationId: 'list_auth_sessions',
summary: 'List auth sessions',
responseSchema: AuthSessionsResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Auth'],
description: 'Retrieve all active authentication sessions for the current user. Requires authentication.',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const currentSessionIdHash = ctx.get('authSession')?.sessionIdHash;
return ctx.json(await ctx.get('authRequestService').getAuthSessions(userId, currentSessionIdHash));
},
);
app.post(
'/auth/sessions/logout',
RateLimitMiddleware(RateLimitConfigs.AUTH_SESSIONS_LOGOUT),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', LogoutAuthSessionsWithVerificationRequest),
OpenAPI({
operationId: 'logout_all_sessions',
summary: 'Logout all sessions',
responseSchema: null,
statusCode: 204,
security: ['bearerToken', 'sessionToken'],
tags: ['Auth'],
description:
'Invalidate all active authentication sessions for the current user. Requires sudo mode verification for security.',
}),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body);
await ctx.get('authRequestService').logoutAuthSessions({user, data: body});
return ctx.body(null, 204);
},
);
app.post(
'/auth/authorize-ip',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_AUTHORIZE_IP),
Validator('json', AuthorizeIpRequest),
OpenAPI({
operationId: 'authorize_ip_address',
summary: 'Authorize IP address',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description:
'Verify and authorize a new IP address using the confirmation code sent via email. Completes IP authorization flow.',
}),
async (ctx) => {
await ctx.get('authRequestService').completeIpAuthorization({data: ctx.req.valid('json')});
return ctx.body(null, 204);
},
);
app.post(
'/auth/ip-authorization/resend',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_IP_AUTHORIZATION_RESEND),
Validator('json', MfaTicketRequest),
OpenAPI({
operationId: 'resend_ip_authorization',
summary: 'Resend IP authorization',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description:
'Request a new IP authorization verification code to be sent via email. Use if the original code was lost or expired.',
}),
async (ctx) => {
await ctx.get('authRequestService').resendIpAuthorization(ctx.req.valid('json'));
return ctx.body(null, 204);
},
);
app.get(
'/auth/ip-authorization/poll',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_IP_AUTHORIZATION_POLL),
Validator('query', IpAuthorizationPollQuery),
OpenAPI({
operationId: 'poll_ip_authorization',
summary: 'Poll IP authorization',
responseSchema: IpAuthorizationPollResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Poll the status of an IP authorization request. Use the ticket parameter to check if verification has been completed.',
}),
async (ctx) => {
const {ticket} = ctx.req.valid('query');
return ctx.json(await ctx.get('authRequestService').pollIpAuthorization({ticket}));
},
);
app.post(
'/auth/webauthn/authentication-options',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_WEBAUTHN_OPTIONS),
OpenAPI({
operationId: 'get_webauthn_authentication_options',
summary: 'Get WebAuthn authentication options',
responseSchema: WebAuthnAuthenticationOptionsResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Retrieve WebAuthn authentication challenge and options for passwordless login with biometrics or security keys.',
}),
async (ctx) => {
return ctx.json(await ctx.get('authRequestService').getWebAuthnAuthenticationOptions(ctx.req.header('origin')));
},
);
app.post(
'/auth/webauthn/authenticate',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_WEBAUTHN_AUTHENTICATE),
Validator('json', WebAuthnAuthenticateRequest),
OpenAPI({
operationId: 'authenticate_with_webauthn',
summary: 'Authenticate with WebAuthn',
responseSchema: AuthTokenWithUserIdResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Complete passwordless login using WebAuthn (biometrics or security key). Returns authentication token on success.',
}),
async (ctx) => {
return ctx.json(
await ctx.get('authRequestService').authenticateWebAuthnDiscoverable({
data: ctx.req.valid('json'),
request: ctx.req.raw,
}),
);
},
);
app.post(
'/auth/login/mfa/webauthn/authentication-options',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
Validator('json', MfaTicketRequest),
OpenAPI({
operationId: 'get_webauthn_mfa_options',
summary: 'Get WebAuthn MFA options',
responseSchema: WebAuthnAuthenticationOptionsResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Retrieve WebAuthn challenge and options for multi-factor authentication. Requires the MFA ticket from initial login.',
}),
async (ctx) => {
return ctx.json(
await ctx.get('authRequestService').getWebAuthnMfaOptions(ctx.req.valid('json'), ctx.req.header('origin')),
);
},
);
app.post(
'/auth/login/mfa/webauthn',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
Validator('json', WebAuthnMfaRequest),
OpenAPI({
operationId: 'login_with_webauthn_mfa',
summary: 'Login with WebAuthn MFA',
responseSchema: AuthTokenWithUserIdResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Complete login by verifying WebAuthn response during MFA. Requires the MFA ticket from initial login attempt.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').loginMfaWebAuthn({
data: ctx.req.valid('json'),
request: ctx.req.raw,
});
return ctx.json(result);
},
);
app.post(
'/auth/username-suggestions',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_REGISTER),
Validator('json', UsernameSuggestionsRequest),
OpenAPI({
operationId: 'get_username_suggestions',
summary: 'Get username suggestions',
responseSchema: UsernameSuggestionsResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description: 'Generate username suggestions based on a provided global name for new account registration.',
}),
async (ctx) => {
const response = ctx.get('authRequestService').getUsernameSuggestions({
globalName: ctx.req.valid('json').global_name,
});
return ctx.json(response);
},
);
app.post(
'/auth/handoff/initiate',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_INITIATE),
OpenAPI({
operationId: 'initiate_handoff',
summary: 'Initiate handoff',
responseSchema: HandoffInitiateResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking.',
}),
async (ctx) => {
return ctx.json(await ctx.get('authRequestService').initiateHandoff({request: ctx.req.raw}));
},
);
app.get(
'/auth/handoff/:code/info',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_INFO),
Validator('param', HandoffCodeParam),
OpenAPI({
operationId: 'get_handoff_info',
summary: 'Get handoff info',
responseSchema: HandoffInfoResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Retrieve device and location information about a pending handoff request. Non-destructive – the code remains valid after this call.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const response = await ctx.get('authRequestService').getHandoffInfo({
code: ctx.req.valid('param').code,
clientIp,
});
return ctx.json(response);
},
);
app.post(
'/auth/handoff/complete',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_COMPLETE),
Validator('json', HandoffCompleteRequest),
OpenAPI({
operationId: 'complete_handoff',
summary: 'Complete handoff',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description: 'Complete the handoff process and authenticate on the target device using the handoff code.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
await ctx.get('authRequestService').completeHandoff({
data: ctx.req.valid('json'),
clientIp,
authToken: ctx.get('authToken') ?? undefined,
approverOrigin: ctx.req.header('origin'),
});
return ctx.body(null, 204);
},
);
app.get(
'/auth/handoff/:code/status',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_STATUS),
Validator('param', HandoffCodeParam),
OpenAPI({
operationId: 'get_handoff_status',
summary: 'Get handoff status',
responseSchema: HandoffStatusResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Check the status of a handoff session. Returns whether the handoff has been completed or is still pending.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const response = await ctx.get('authRequestService').getHandoffStatus({
code: ctx.req.valid('param').code,
clientIp,
});
return ctx.json(response);
},
);
app.post(
'/auth/handoff/:code/status',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_STATUS),
Validator('param', HandoffCodeParam),
Validator('json', HandoffStatusRequest),
OpenAPI({
operationId: 'get_handoff_status_with_secret',
summary: 'Get handoff status with secret',
responseSchema: HandoffStatusResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Check the status of a handoff session using the poll secret from initiation. Returns the authentication token once the handoff is complete and the presented secret matches.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const response = await ctx.get('authRequestService').getHandoffStatus({
code: ctx.req.valid('param').code,
clientIp,
pollSecret: ctx.req.valid('json').poll_secret,
});
return ctx.json(response);
},
);
app.delete(
'/auth/handoff/:code',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_CANCEL),
Validator('param', HandoffCodeParam),
Validator('json', HandoffCancelRequest),
OpenAPI({
operationId: 'cancel_handoff',
summary: 'Cancel handoff',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description: 'Cancel an ongoing handoff session. The handoff code will no longer be valid for authentication.',
}),
async (ctx) => {
await ctx.get('authRequestService').cancelHandoff({
code: ctx.req.valid('param').code,
pollSecret: ctx.req.valid('json').poll_secret,
});
return ctx.body(null, 204);
},
);
}