fix(auth): correct mfa errors, sudo methods and birth dates (#2498)

This commit is contained in:
Hampus
2026-09-06 15:27:30 +02:00
committed by GitHub
parent 150115cc0c
commit f392636857
12 changed files with 193 additions and 32 deletions
+1 -1
View File
@@ -388,7 +388,7 @@ export async function loginMfaTotp(
const {users, cache, rateLimit} = ctx.services;
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.SESSION_TIMEOUT);
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
}
const user = await users.findUnique(createUserID(BigInt(userId)));
if (!user) {
+5 -3
View File
@@ -30,6 +30,7 @@ import type {WebAuthnCredential} from '../models/WebAuthnCredential';
import {getUserSearchService} from '../SearchFactory';
import {mapUserToPrivateResponse} from '../user/UserMappers';
import {TotpGenerator} from '../utils/TotpGenerator';
import {deriveSudoMethods, userHasMfa} from './services/SudoMethods';
type WebAuthnChallengeContext = 'registration' | 'discoverable' | 'mfa' | 'sudo';
@@ -481,10 +482,11 @@ export async function getAvailableMfaMethods(ctx: ApiContext, userId: UserID): P
if (!user) {
return {totp: false, webauthn: false, has_mfa: false};
}
const methods = deriveSudoMethods(user);
return {
totp: user.totpSecret !== null,
webauthn: user.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false,
has_mfa: (user.authenticatorTypes?.size ?? 0) > 0,
totp: methods.totp,
webauthn: methods.webauthn,
has_mfa: userHasMfa(user),
};
}
+11 -4
View File
@@ -69,11 +69,18 @@ function throwRegistrationRateLimit(result: RateLimitResult): never {
}
function parseDobLocalDate(dateOfBirth: string): types.LocalDate {
try {
return types.LocalDate.fromString(dateOfBirth);
} catch {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateOfBirth);
if (!match) {
throw InputValidationError.fromCode('date_of_birth', ValidationErrorCodes.INVALID_DATE_OF_BIRTH_FORMAT);
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const probe = new Date(Date.UTC(year, month - 1, day));
if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) {
throw InputValidationError.fromCode('date_of_birth', ValidationErrorCodes.INVALID_DATE_OF_BIRTH_FORMAT);
}
return new types.LocalDate(year, month, day);
}
interface RegisterParams {
@@ -156,11 +163,11 @@ export async function register(
if (!dateOfBirthInput) {
throw InputValidationError.fromCode('date_of_birth', ValidationErrorCodes.INVALID_DATE_OF_BIRTH_FORMAT);
}
dateOfBirth = parseDobLocalDate(dateOfBirthInput);
const minAge = accountPolicyEvaluator.getMinimumAgeForRegion(countryCode, DEFAULT_MINIMUM_AGE);
if (!AuthUtility.validateAge(ctx, {dateOfBirth: dateOfBirthInput, minAge})) {
throw InputValidationError.fromCode('date_of_birth', ValidationErrorCodes.MUST_BE_MINIMUM_AGE, {minAge});
}
dateOfBirth = parseDobLocalDate(dateOfBirthInput);
isAdult = AgeUtils.isUserAdult(dateOfBirthInput);
}
if (data.password && (await AuthPassword.isPasswordPwned(ctx, data.password))) {
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import type {SudoModeMethods} from '@fluxer/errors/src/domains/auth/SudoModeRequiredError';
export function userHasMfa(user: {authenticatorTypes?: Set<number> | null}): boolean {
return (
(user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP) ?? false) ||
(user.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false)
);
}
export function deriveSudoMethods(user: {
totpSecret?: string | null;
authenticatorTypes?: Set<number> | null;
}): SudoModeMethods {
const authenticatorTypes = user.authenticatorTypes ?? null;
return {
totp: (user.totpSecret ?? null) !== null && (authenticatorTypes?.has(UserAuthenticatorTypes.TOTP) ?? false),
webauthn: authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false,
};
}
@@ -1,8 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {type SudoModeMethods, SudoModeRequiredError} from '@fluxer/errors/src/domains/auth/SudoModeRequiredError';
import {SudoModeRequiredError} from '@fluxer/errors/src/domains/auth/SudoModeRequiredError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {AuthenticationResponseJSON} from '@simplewebauthn/server';
import type {Context} from 'hono';
@@ -11,6 +10,7 @@ import * as AuthPassword from '../../auth/AuthPassword';
import {SUDO_MODE_HEADER} from '../../middleware/SudoModeMiddleware';
import type {User} from '../../models/User';
import type {HonoEnv} from '../../types/HonoEnv';
import {deriveSudoMethods, userHasMfa} from './SudoMethods';
import {getSudoModeService} from './SudoModeService';
export interface SudoVerificationBody {
@@ -23,13 +23,6 @@ export interface SudoVerificationBody {
type SudoVerificationMethod = 'password' | 'mfa' | 'sudo_token';
export function userHasMfa(user: {authenticatorTypes?: Set<number> | null}): boolean {
return (
(user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP) ?? false) ||
(user.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false)
);
}
export function hasNoVerifiableCredential(
user: {passwordHash: string | null; isBot: boolean},
hasMfa: boolean,
@@ -40,17 +33,6 @@ export function hasNoVerifiableCredential(
return user.passwordHash === null;
}
export function deriveSudoMethods(user: {
totpSecret?: string | null;
authenticatorTypes?: Set<number> | null;
}): SudoModeMethods {
const authenticatorTypes = user.authenticatorTypes ?? null;
return {
totp: (user.totpSecret ?? null) !== null && (authenticatorTypes?.has(UserAuthenticatorTypes.TOTP) ?? false),
webauthn: authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false,
};
}
export interface SudoVerificationResult {
verified: boolean;
method: SudoVerificationMethod;
@@ -2,8 +2,10 @@
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createUserID} from '../../BrandedTypes';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
import {UserRepository} from '../../user/repositories/UserRepository';
import {createTestAccount, createTotpSecret, generateTotpCode, type TestAccount} from './AuthTestUtils';
import {
createAuthenticationResponse,
@@ -27,6 +29,21 @@ interface LoginMfaResponse {
webauthn: boolean;
}
interface SudoMfaMethodsResponse {
totp: boolean;
webauthn: boolean;
has_mfa: boolean;
}
interface SudoModeRequiredResponse {
code: string;
has_mfa?: boolean;
methods?: {
totp?: boolean;
webauthn?: boolean;
};
}
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<TestAccount> {
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
.post('/auth/login')
@@ -291,6 +308,50 @@ describe('MFA Consistency Tests', () => {
.execute();
});
});
describe('Sudo MFA method availability', () => {
test('mfa-methods agrees with the SUDO_MODE_REQUIRED body for an orphaned TOTP secret', async () => {
const account = await createTestAccount(harness);
const userRepository = new UserRepository();
const userId = createUserID(BigInt(account.userId));
const existing = await userRepository.findUnique(userId);
await userRepository.patchUpsert(userId, {totp_secret: createTotpSecret()}, existing!.toRow());
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, account.token)
.get('/users/@me/sudo/mfa-methods')
.execute();
expect(methods).toEqual({totp: false, webauthn: false, has_mfa: false});
const errorResp = await createBuilder<SudoModeRequiredResponse>(harness, account.token)
.post('/users/@me/disable')
.body({})
.expect(403, 'SUDO_MODE_REQUIRED')
.execute();
expect(errorResp.has_mfa).toBe(methods.has_mfa);
expect(errorResp.methods).toEqual({totp: methods.totp, webauthn: methods.webauthn});
});
test('mfa-methods agrees with the SUDO_MODE_REQUIRED body for an enrolled TOTP user', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const loggedIn = await loginWithTotp(harness, account, secret);
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, loggedIn.token)
.get('/users/@me/sudo/mfa-methods')
.execute();
expect(methods).toEqual({totp: true, webauthn: false, has_mfa: true});
const errorResp = await createBuilder<SudoModeRequiredResponse>(harness, loggedIn.token)
.post('/users/@me/disable')
.body({})
.expect(403, 'SUDO_MODE_REQUIRED')
.execute();
expect(errorResp.has_mfa).toBe(methods.has_mfa);
expect(errorResp.methods).toEqual({totp: methods.totp, webauthn: methods.webauthn});
});
});
describe('MFA requirement propagates to sensitive operations', () => {
test('Account disable requires sudo for all users', async () => {
const account = await createTestAccount(harness);
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
@@ -12,6 +13,15 @@ import {
type UserMeResponse,
} from './AuthTestUtils';
interface ValidationErrorResponse {
code: string;
errors: Array<{
path: string;
code: string;
message: string;
}>;
}
describe('Auth MFA endpoints', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
@@ -83,4 +93,30 @@ describe('Auth MFA endpoints', () => {
.expect(204)
.execute();
});
it('expired MFA ticket reports SESSION_TIMEOUT on ticket for both completion routes', async () => {
const totpError = await createBuilderWithoutAuth<ValidationErrorResponse>(harness)
.post('/auth/login/mfa/totp')
.body({code: '000000', ticket: 'unknown'})
.expect(400, 'INVALID_FORM_BODY')
.execute();
expect(totpError.errors[0]?.path).toBe('ticket');
expect(totpError.errors[0]?.code).toBe(ValidationErrorCodes.SESSION_TIMEOUT);
const webauthnError = await createBuilderWithoutAuth<ValidationErrorResponse>(harness)
.post('/auth/login/mfa/webauthn')
.body({
response: {
id: 'unknown',
rawId: 'unknown',
type: 'public-key',
response: {clientDataJSON: '', authenticatorData: '', signature: ''},
clientExtensionResults: {},
},
challenge: 'unknown',
ticket: 'unknown',
})
.expect(400, 'INVALID_FORM_BODY')
.execute();
expect(webauthnError.errors[0]?.path).toBe('ticket');
expect(webauthnError.errors[0]?.code).toBe(ValidationErrorCodes.SESSION_TIMEOUT);
});
});
@@ -120,6 +120,56 @@ describe('Registration validation', () => {
.expect(400)
.execute();
});
it('rejects an impossible calendar date with INVALID_DATE_OF_BIRTH_FORMAT', async () => {
for (const dateOfBirth of ['2000-02-30', '2000-13-45']) {
const json = await createBuilderWithoutAuth<ValidationErrorResponse>(harness)
.post('/auth/register')
.body({
email: createUniqueEmail('impossible-dob'),
username: createUniqueUsername('impossible'),
global_name: 'Test User',
password: 'a-strong-password',
date_of_birth: dateOfBirth,
consent: true,
})
.expect(400, 'INVALID_FORM_BODY')
.execute();
const dateOfBirthError = json.errors?.find((e) => e.path === 'date_of_birth');
expect(dateOfBirthError?.code).toBe('INVALID_DATE_OF_BIRTH_FORMAT');
}
});
it('rejects a real calendar date below the minimum age with MUST_BE_MINIMUM_AGE', async () => {
const json = await createBuilderWithoutAuth<ValidationErrorResponse>(harness)
.post('/auth/register')
.body({
email: createUniqueEmail('underage'),
username: createUniqueUsername('underage'),
global_name: 'Test User',
password: 'a-strong-password',
date_of_birth: '2020-01-01',
consent: true,
})
.expect(400, 'INVALID_FORM_BODY')
.execute();
const dateOfBirthError = json.errors?.find((e) => e.path === 'date_of_birth');
expect(dateOfBirthError?.code).toBe('MUST_BE_MINIMUM_AGE');
});
it('accepts a real calendar date above the minimum age', async () => {
const reg = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/register')
.body({
email: createUniqueEmail('valid-dob'),
username: createUniqueUsername('validdob'),
global_name: 'Test User',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
})
.execute();
expect(reg.token).toBeTruthy();
});
it('allows emoji in global name', async () => {
const globalName = '🌻 Sunflower';
const reg = await createBuilderWithoutAuth<{
@@ -5,7 +5,8 @@ import {describe, expect, it} from 'vitest';
import {createUserID} from '../../BrandedTypes';
import {EMPTY_USER_ROW, type UserRow} from '../../database/types/UserTypes';
import {User} from '../../models/User';
import {hasNoVerifiableCredential, userHasMfa} from '../services/SudoVerificationService';
import {userHasMfa} from '../services/SudoMethods';
import {hasNoVerifiableCredential} from '../services/SudoVerificationService';
function createUser(overrides: Partial<UserRow> = {}): User {
return new User({
@@ -10,8 +10,8 @@ import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
import type {ApiContext} from '../../ApiContext';
import * as AuthPassword from '../../auth/AuthPassword';
import * as AuthSession from '../../auth/AuthSession';
import {deriveSudoMethods, userHasMfa} from '../../auth/services/SudoMethods';
import type {SudoVerificationResult} from '../../auth/services/SudoVerificationService';
import {deriveSudoMethods, userHasMfa} from '../../auth/services/SudoVerificationService';
import {Config} from '../../Config';
import type {UserRow} from '../../database/types/UserTypes';
import type {IDiscriminatorService} from '../../infrastructure/DiscriminatorService';
+1 -1
View File
@@ -9,8 +9,8 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
import type {ApiContext} from '../../ApiContext';
import * as AuthMfa from '../../auth/AuthMfa';
import * as AuthUtility from '../../auth/AuthUtility';
import {deriveSudoMethods, userHasMfa} from '../../auth/services/SudoMethods';
import type {SudoVerificationResult} from '../../auth/services/SudoVerificationService';
import {deriveSudoMethods, userHasMfa} from '../../auth/services/SudoVerificationService';
import type {MfaBackupCode} from '../../models/MfaBackupCode';
import type {User} from '../../models/User';
import {mapUserToPrivateResponse} from '../UserMappers';