mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
fix(auth): correct mfa errors, sudo methods and birth dates (#2498)
This commit is contained in:
1 parent
150115cc0c
commit
f392636857
12 files changed
+193
-32
No files matched your search
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user