158 lines
4.7 KiB
TypeScript
158 lines
4.7 KiB
TypeScript
/*
|
|
* Copyright (C) 2026 Fluxer Contributors
|
|
*
|
|
* This file is part of Fluxer.
|
|
*
|
|
* Fluxer is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* Fluxer is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import type {AuthenticationResponseJSON} from '@simplewebauthn/server';
|
|
import type {Context} from 'hono';
|
|
import type {HonoEnv} from '~/App';
|
|
import type {AuthService} from '~/auth/AuthService';
|
|
import type {AuthMfaService} from '~/auth/services/AuthMfaService';
|
|
import {getSudoModeService} from '~/auth/services/SudoModeService';
|
|
import {InputValidationError} from '~/Errors';
|
|
import {SudoModeRequiredError} from '~/errors/SudoModeRequiredError';
|
|
import type {User} from '~/Models';
|
|
import {SUDO_MODE_HEADER} from '~/middleware/SudoModeMiddleware';
|
|
import {setSudoCookie} from '~/utils/SudoCookieUtils';
|
|
|
|
interface SudoVerificationBody {
|
|
password?: string;
|
|
mfa_method?: 'totp' | 'sms' | 'webauthn';
|
|
mfa_code?: string;
|
|
webauthn_response?: AuthenticationResponseJSON;
|
|
webauthn_challenge?: string;
|
|
}
|
|
|
|
type SudoVerificationMethod = 'password' | 'mfa' | 'sudo_token';
|
|
|
|
export function userHasMfa(user: {authenticatorTypes?: Set<number> | null}): boolean {
|
|
return (user.authenticatorTypes?.size ?? 0) > 0;
|
|
}
|
|
|
|
export interface SudoVerificationResult {
|
|
verified: boolean;
|
|
method: SudoVerificationMethod;
|
|
sudoToken?: string;
|
|
}
|
|
|
|
export interface SudoVerificationOptions {
|
|
issueSudoToken?: boolean;
|
|
}
|
|
|
|
async function verifySudoMode(
|
|
ctx: Context<HonoEnv>,
|
|
user: User,
|
|
body: SudoVerificationBody,
|
|
authService: AuthService,
|
|
mfaService: AuthMfaService,
|
|
options: SudoVerificationOptions = {},
|
|
): Promise<SudoVerificationResult> {
|
|
if (user.isBot) {
|
|
return {verified: true, method: 'sudo_token'};
|
|
}
|
|
|
|
const hasMfa = userHasMfa(user);
|
|
const issueSudoToken = options.issueSudoToken ?? hasMfa;
|
|
|
|
if (hasMfa && ctx.get('sudoModeValid')) {
|
|
const sudoToken = ctx.get('sudoModeToken') ?? ctx.req.header(SUDO_MODE_HEADER) ?? undefined;
|
|
return {verified: true, method: 'sudo_token', sudoToken: issueSudoToken ? sudoToken : undefined};
|
|
}
|
|
|
|
const incomingToken = ctx.req.header(SUDO_MODE_HEADER);
|
|
if (!hasMfa && incomingToken && ctx.get('sudoModeValid')) {
|
|
return {verified: true, method: 'sudo_token', sudoToken: issueSudoToken ? incomingToken : undefined};
|
|
}
|
|
|
|
if (hasMfa && body.mfa_method) {
|
|
const result = await mfaService.verifySudoMfa({
|
|
userId: user.id,
|
|
method: body.mfa_method,
|
|
code: body.mfa_code,
|
|
webauthnResponse: body.webauthn_response,
|
|
webauthnChallenge: body.webauthn_challenge,
|
|
});
|
|
|
|
if (!result.success) {
|
|
throw InputValidationError.create('mfa_code', result.error ?? 'Invalid MFA code');
|
|
}
|
|
|
|
const sudoModeService = getSudoModeService();
|
|
const sudoToken = issueSudoToken ? await sudoModeService.generateSudoToken(user.id) : undefined;
|
|
|
|
return {verified: true, sudoToken, method: 'mfa'};
|
|
}
|
|
|
|
const isUnclaimedAccount = user.isUnclaimedAccount();
|
|
if (isUnclaimedAccount && !hasMfa) {
|
|
return {verified: true, method: 'password'};
|
|
}
|
|
|
|
if (body.password && !hasMfa) {
|
|
if (!user.passwordHash) {
|
|
throw InputValidationError.create('password', 'Password not set');
|
|
}
|
|
|
|
const passwordValid = await authService.verifyPassword({
|
|
password: body.password,
|
|
passwordHash: user.passwordHash,
|
|
});
|
|
|
|
if (!passwordValid) {
|
|
throw InputValidationError.create('password', 'Invalid password');
|
|
}
|
|
|
|
return {verified: true, method: 'password'};
|
|
}
|
|
|
|
throw new SudoModeRequiredError(hasMfa);
|
|
}
|
|
|
|
function setSudoTokenHeader(
|
|
ctx: Context<HonoEnv>,
|
|
result: SudoVerificationResult,
|
|
options: SudoVerificationOptions = {},
|
|
): void {
|
|
const issueSudoToken = options.issueSudoToken ?? true;
|
|
|
|
if (!issueSudoToken) {
|
|
return;
|
|
}
|
|
|
|
const tokenToSet = result.sudoToken ?? ctx.req.header(SUDO_MODE_HEADER);
|
|
if (tokenToSet) {
|
|
ctx.header(SUDO_MODE_HEADER, tokenToSet);
|
|
const user = ctx.get('user');
|
|
if (user) {
|
|
setSudoCookie(ctx, tokenToSet, user.id.toString());
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function requireSudoMode(
|
|
ctx: Context<HonoEnv>,
|
|
user: User,
|
|
body: SudoVerificationBody,
|
|
authService: AuthService,
|
|
mfaService: AuthMfaService,
|
|
options: SudoVerificationOptions = {},
|
|
): Promise<SudoVerificationResult> {
|
|
const sudoResult = await verifySudoMode(ctx, user, body, authService, mfaService, options);
|
|
setSudoTokenHeader(ctx, sudoResult, options);
|
|
return sudoResult;
|
|
}
|