mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
refactor(api): collapse single-impl interfaces and facades (#3382)
This commit is contained in:
1 parent
e7b13fd19f
commit
c16d414d93
426 files changed
+2085
-6445
No files matched your search
@@ -1,10 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import {createAuthHarness, createTestAccount, loginAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createTotpSecret,
|
||||
loginAccount,
|
||||
loginWithTotp,
|
||||
type TestAccount,
|
||||
totpCodeNow,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {TotpGenerator} from '@app/api/utils/TotpGenerator';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -25,65 +31,6 @@ interface OAuth2ApplicationResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function generateTotpSecret(): string {
|
||||
const buffer = randomBytes(20);
|
||||
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
let result = '';
|
||||
for (let i = 0; i < buffer.length; i += 5) {
|
||||
const bytes = [buffer[i]!, buffer[i + 1]!, buffer[i + 2]!, buffer[i + 3]!, buffer[i + 4]!];
|
||||
const n = (bytes[0]! << 24) | (bytes[1]! << 16) | (bytes[2]! << 8) | bytes[3]!;
|
||||
const indices = [
|
||||
(n >> 3) & 0x1f,
|
||||
((n >> 11) | ((bytes[4]! << 4) & 0xf)) & 0x1f,
|
||||
((n >> 19) | ((bytes[4]! << 2) & 0x3c)) & 0x1f,
|
||||
(bytes[4]! >> 1) & 0x1f,
|
||||
];
|
||||
result += base32Chars[indices[0]!];
|
||||
result += base32Chars[indices[1]!];
|
||||
result += base32Chars[indices[2]!];
|
||||
result += base32Chars[indices[3]!];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function generateTotpCode(secret: string): Promise<string> {
|
||||
const totp = new TotpGenerator(secret);
|
||||
const codes = await totp.generateTotp();
|
||||
return codes[0]!;
|
||||
}
|
||||
|
||||
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<TestAccount> {
|
||||
const loginResp = await createBuilderWithoutAuth<
|
||||
| {
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
}
|
||||
| {
|
||||
mfa: false;
|
||||
token: string;
|
||||
}
|
||||
>(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
if (!loginResp.mfa) {
|
||||
throw new Error('Expected MFA login');
|
||||
}
|
||||
const mfaLoginResp = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({
|
||||
ticket: loginResp.ticket,
|
||||
code: await generateTotpCode(secret),
|
||||
})
|
||||
.execute();
|
||||
return {...account, token: mfaLoginResp.token};
|
||||
}
|
||||
|
||||
async function createOAuth2BotApplication(
|
||||
harness: ApiTestHarness,
|
||||
account: TestAccount,
|
||||
@@ -177,8 +124,8 @@ describe('Auth sudo required operations', () => {
|
||||
});
|
||||
it('requires sudo for disable TOTP MFA', async () => {
|
||||
let account = await createTestAccount(harness);
|
||||
const secret = generateTotpSecret();
|
||||
const code = await generateTotpCode(secret);
|
||||
const secret = createTotpSecret();
|
||||
const code = totpCodeNow(secret);
|
||||
const enableResp = await createBuilder<BackupCodesResponse>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({
|
||||
@@ -202,15 +149,15 @@ describe('Auth sudo required operations', () => {
|
||||
.body({
|
||||
code: backupCode,
|
||||
mfa_method: 'totp',
|
||||
mfa_code: await generateTotpCode(secret),
|
||||
mfa_code: totpCodeNow(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
it('requires sudo for enable TOTP MFA', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = generateTotpSecret();
|
||||
const code = await generateTotpCode(secret);
|
||||
const secret = createTotpSecret();
|
||||
const code = totpCodeNow(secret);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({
|
||||
|
||||
@@ -4,11 +4,11 @@ import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createTotpSecret,
|
||||
type TestAccount,
|
||||
loginWithTotp,
|
||||
totpCodeNow,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -21,38 +21,6 @@ interface BackupCodesResponse {
|
||||
|
||||
const SUDO_MODE_HEADER = 'X-Fluxer-Sudo-Mode-JWT';
|
||||
|
||||
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<TestAccount> {
|
||||
const loginResp = await createBuilderWithoutAuth<
|
||||
| {
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
}
|
||||
| {
|
||||
mfa: false;
|
||||
token: string;
|
||||
}
|
||||
>(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
if (!loginResp.mfa) {
|
||||
throw new Error('Expected MFA login');
|
||||
}
|
||||
const mfaLoginResp = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({
|
||||
ticket: loginResp.ticket,
|
||||
code: totpCodeNow(secret),
|
||||
})
|
||||
.execute();
|
||||
return {...account, token: mfaLoginResp.token};
|
||||
}
|
||||
|
||||
describe('Auth sudo TOTP verification', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
|
||||
@@ -260,6 +260,22 @@ export function totpCodeNow(secret: string): string {
|
||||
return generateTotpCode(secret, Date.now());
|
||||
}
|
||||
|
||||
export async function loginWithTotp(
|
||||
harness: ApiTestHarness,
|
||||
account: TestAccount,
|
||||
secret: string,
|
||||
): Promise<TestAccount> {
|
||||
const login = await loginUser(harness, {email: account.email, password: account.password});
|
||||
if (!('mfa' in login)) {
|
||||
throw new Error('Expected MFA login');
|
||||
}
|
||||
const mfaLogin = await createBuilder<{token: string}>(harness, '')
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({ticket: login.ticket, code: totpCodeNow(secret)})
|
||||
.execute();
|
||||
return {...account, token: mfaLogin.token};
|
||||
}
|
||||
|
||||
export async function seedMfaTicket(
|
||||
harness: ApiTestHarness,
|
||||
ticket: string,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createTestAccount,
|
||||
createTotpSecret,
|
||||
generateTotpCode,
|
||||
loginWithTotp,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
@@ -51,27 +52,6 @@ interface SudoModeRequiredResponse {
|
||||
};
|
||||
}
|
||||
|
||||
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<TestAccount> {
|
||||
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
expect(login.mfa).toBe(true);
|
||||
const mfaLogin = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({
|
||||
code: generateTotpCode(secret),
|
||||
ticket: login.ticket,
|
||||
})
|
||||
.execute();
|
||||
return {...account, token: mfaLogin.token};
|
||||
}
|
||||
|
||||
async function setupWebAuthnOnlyUser(
|
||||
harness: ApiTestHarness,
|
||||
account: TestAccount,
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import {createAuthHarness, createTestAccount, loginUser, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createTotpSecret,
|
||||
loginUser,
|
||||
loginWithTotp,
|
||||
type TestAccount,
|
||||
totpCodeNow,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {TotpGenerator} from '@app/api/utils/TotpGenerator';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const SUDO_MODE_HEADER = 'X-Fluxer-Sudo-Mode-JWT';
|
||||
@@ -22,35 +28,8 @@ interface BackupCodesResponse {
|
||||
}>;
|
||||
}
|
||||
|
||||
function generateTotpSecret(): string {
|
||||
const buffer = randomBytes(20);
|
||||
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
let result = '';
|
||||
for (let i = 0; i < buffer.length; i += 5) {
|
||||
const bytes = [buffer[i]!, buffer[i + 1]!, buffer[i + 2]!, buffer[i + 3]!, buffer[i + 4]!];
|
||||
const n = (bytes[0]! << 24) | (bytes[1]! << 16) | (bytes[2]! << 8) | bytes[3]!;
|
||||
const indices = [
|
||||
(n >> 3) & 0x1f,
|
||||
((n >> 11) | ((bytes[4]! << 4) & 0xf)) & 0x1f,
|
||||
((n >> 19) | ((bytes[4]! << 2) & 0x3c)) & 0x1f,
|
||||
(bytes[4]! >> 1) & 0x1f,
|
||||
];
|
||||
result += base32Chars[indices[0]!];
|
||||
result += base32Chars[indices[1]!];
|
||||
result += base32Chars[indices[2]!];
|
||||
result += base32Chars[indices[3]!];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function generateTotpCode(secret: string): Promise<string> {
|
||||
const totp = new TotpGenerator(secret);
|
||||
const codes = await totp.generateTotp();
|
||||
return codes[0]!;
|
||||
}
|
||||
|
||||
async function enableTotpForAccount(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<void> {
|
||||
const code = await generateTotpCode(secret);
|
||||
const code = totpCodeNow(secret);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({
|
||||
@@ -61,29 +40,12 @@ async function enableTotpForAccount(harness: ApiTestHarness, account: TestAccoun
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<TestAccount> {
|
||||
const login = await loginUser(harness, {email: account.email, password: account.password});
|
||||
if (!('mfa' in login)) {
|
||||
throw new Error('Expected MFA login');
|
||||
}
|
||||
const mfaLoginResp = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({
|
||||
ticket: login.ticket,
|
||||
code: await generateTotpCode(secret),
|
||||
})
|
||||
.execute();
|
||||
return {...account, token: mfaLoginResp.token};
|
||||
}
|
||||
|
||||
async function getSudoTokenViaMfa(harness: ApiTestHarness, token: string, secret: string): Promise<string> {
|
||||
const {response} = await createBuilder<BackupCodesResponse>(harness, token)
|
||||
.post('/users/@me/mfa/backup-codes')
|
||||
.body({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: await generateTotpCode(secret),
|
||||
mfa_code: totpCodeNow(secret),
|
||||
regenerate: false,
|
||||
})
|
||||
.executeWithResponse();
|
||||
@@ -108,7 +70,7 @@ describe('Sudo mode negative cases', () => {
|
||||
describe('invalid sudo token rejected', () => {
|
||||
it('rejects malformed sudo token', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = generateTotpSecret();
|
||||
const secret = createTotpSecret();
|
||||
await enableTotpForAccount(harness, account, secret);
|
||||
const loggedIn = await loginWithTotp(harness, account, secret);
|
||||
const invalidTokens = [
|
||||
@@ -128,7 +90,7 @@ describe('Sudo mode negative cases', () => {
|
||||
});
|
||||
it('rejects empty sudo token header', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = generateTotpSecret();
|
||||
const secret = createTotpSecret();
|
||||
await enableTotpForAccount(harness, account, secret);
|
||||
const loggedIn = await loginWithTotp(harness, account, secret);
|
||||
const {json: errResp} = await createBuilder<ErrorResponse>(harness, loggedIn.token)
|
||||
@@ -166,7 +128,7 @@ describe('Sudo mode negative cases', () => {
|
||||
describe('wrong MFA code rejected', () => {
|
||||
it('rejects incorrect TOTP codes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = generateTotpSecret();
|
||||
const secret = createTotpSecret();
|
||||
await enableTotpForAccount(harness, account, secret);
|
||||
const loggedIn = await loginWithTotp(harness, account, secret);
|
||||
const wrongCodes = ['000000', '123456', '999999', '12345', '1234567', 'abcdef'];
|
||||
@@ -185,12 +147,12 @@ describe('Sudo mode negative cases', () => {
|
||||
describe('sudo token for wrong user rejected', () => {
|
||||
it('rejects sudo token from different user', async () => {
|
||||
const account1 = await createTestAccount(harness);
|
||||
const secret1 = generateTotpSecret();
|
||||
const secret1 = createTotpSecret();
|
||||
await enableTotpForAccount(harness, account1, secret1);
|
||||
const loggedIn1 = await loginWithTotp(harness, account1, secret1);
|
||||
const user1SudoToken = await getSudoTokenViaMfa(harness, loggedIn1.token, secret1);
|
||||
const account2 = await createTestAccount(harness);
|
||||
const secret2 = generateTotpSecret();
|
||||
const secret2 = createTotpSecret();
|
||||
await enableTotpForAccount(harness, account2, secret2);
|
||||
const loggedIn2 = await loginWithTotp(harness, account2, secret2);
|
||||
const {json: errResp} = await createBuilder<ErrorResponse>(harness, loggedIn2.token)
|
||||
@@ -243,7 +205,7 @@ describe('Sudo mode negative cases', () => {
|
||||
describe('MFA registration without token fails', () => {
|
||||
it('WebAuthn registration options do not issue sudo token', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = generateTotpSecret();
|
||||
const secret = createTotpSecret();
|
||||
await enableTotpForAccount(harness, account, secret);
|
||||
const loggedIn = await loginWithTotp(harness, account, secret);
|
||||
const sudoToken = await getSudoTokenViaMfa(harness, loggedIn.token, secret);
|
||||
@@ -259,7 +221,7 @@ describe('Sudo mode negative cases', () => {
|
||||
describe('existing MFA token allows skipping MFA', () => {
|
||||
it('sudo token from MFA allows skipping MFA verification on sensitive endpoint', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = generateTotpSecret();
|
||||
const secret = createTotpSecret();
|
||||
await enableTotpForAccount(harness, account, secret);
|
||||
const loggedIn = await loginWithTotp(harness, account, secret);
|
||||
const sudoToken = await getSudoTokenViaMfa(harness, loggedIn.token, secret);
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createHash,
|
||||
createHmac,
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
createSign,
|
||||
generateKeyPairSync,
|
||||
randomBytes,
|
||||
} from 'node:crypto';
|
||||
import {createHash, createPrivateKey, createPublicKey, createSign, generateKeyPairSync, randomBytes} from 'node:crypto';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {decode as base32Decode, encode as base32Encode} from 'hi-base32';
|
||||
|
||||
export {createTotpSecret, generateTotpCode} from '@app/api/auth/tests/AuthTestUtils';
|
||||
|
||||
export interface WebAuthnDevice {
|
||||
privateKey: string;
|
||||
@@ -103,30 +96,6 @@ interface WebAuthnAuthenticationResponse {
|
||||
response: AuthenticatorAssertionResponse;
|
||||
}
|
||||
|
||||
export function createTotpSecret(): string {
|
||||
const buf = randomBytes(20);
|
||||
return base32Encode(buf).replace(/=/g, '');
|
||||
}
|
||||
|
||||
export function generateTotpCode(secret: string, time = Date.now()): string {
|
||||
const key = Buffer.from(base32Decode.asBytes(secret.toUpperCase()));
|
||||
const epoch = Math.floor(time / 1000);
|
||||
const counter = Math.floor(epoch / 30);
|
||||
const counterBuf = Buffer.alloc(8);
|
||||
counterBuf.writeBigUInt64BE(BigInt(counter));
|
||||
const hmac = createHmac('sha1', key);
|
||||
hmac.update(counterBuf);
|
||||
const hash = hmac.digest();
|
||||
const offset = hash[hash.length - 1] & 0x0f;
|
||||
const binary =
|
||||
((hash[offset] & 0x7f) << 24) |
|
||||
((hash[offset + 1] & 0xff) << 16) |
|
||||
((hash[offset + 2] & 0xff) << 8) |
|
||||
(hash[offset + 3] & 0xff);
|
||||
const otp = binary % 1000000;
|
||||
return otp.toString().padStart(6, '0');
|
||||
}
|
||||
|
||||
function resolveWebAuthnOrigin(): {
|
||||
rpId: string;
|
||||
origin: string;
|
||||
|
||||
Reference in new issue
Block a user