diff --git a/packages/backend/test-server/entry.ts b/packages/backend/test-server/entry.ts index 5abe8dd296..340acef7c3 100644 --- a/packages/backend/test-server/entry.ts +++ b/packages/backend/test-server/entry.ts @@ -1,6 +1,7 @@ import { portToPid } from 'pid-port'; import fkill from 'fkill'; import Fastify from 'fastify'; +import * as lolex from '@sinonjs/fake-timers'; import { NestFactory } from '@nestjs/core'; import { MainModule } from '@/MainModule.js'; import { ServerService } from '@/server/ServerService.js'; @@ -10,11 +11,22 @@ import { INestApplicationContext } from '@nestjs/common'; const config = loadConfig(); const originEnv = JSON.stringify(process.env); +const originalDateNow = Date.now.bind(Date); process.env.NODE_ENV = 'test'; let app: INestApplicationContext; let serverService: ServerService; +let baseNowMs = originalDateNow(); +const clock = lolex.install({ + toFake: Object.keys(lolex.timers).filter((key) => !['nextTick', 'queueMicrotask'].includes(key)) as lolex.FakeMethod[], + now: baseNowMs, +}); + +function resetFakeTime() { + baseNowMs = originalDateNow(); + clock.setSystemTime(baseNowMs); +} /** * テスト用のサーバインスタンスを起動する @@ -84,6 +96,7 @@ async function startControllerEndpoints(port = config.port + 1000) { fastify.post<{ Body: { key?: string, value?: string } }>('/env-reset', async (req, res) => { process.env = JSON.parse(originEnv); + resetFakeTime(); await serverService.dispose(); await app.close(); @@ -101,5 +114,30 @@ async function startControllerEndpoints(port = config.port + 1000) { res.code(200).send({ success: true }); }); + fastify.post<{ Body: { ms?: number } }>('/time/advance', async (req, res) => { + if (typeof req.body.ms !== 'number' || !Number.isFinite(req.body.ms)) { + res.code(400).send({ success: false }); + return; + } + + clock.setSystemTime(Date.now() + req.body.ms); + + res.code(200).send({ + success: true, + now: Date.now(), + offsetMs: Date.now() - baseNowMs, + }); + }); + + fastify.post('/time/reset', async (_req, res) => { + resetFakeTime(); + + res.code(200).send({ + success: true, + now: Date.now(), + offsetMs: Date.now() - baseNowMs, + }); + }); + await fastify.listen({ port: port, host: 'localhost' }); } diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts index bb98c88f19..c083e24dcf 100644 --- a/packages/backend/test/e2e/2fa.ts +++ b/packages/backend/test/e2e/2fa.ts @@ -10,7 +10,7 @@ import * as crypto from 'node:crypto'; import cbor from 'cbor'; import * as OTPAuth from 'otpauth'; import { loadConfig } from '@/config.js'; -import { api, signup } from '../utils.js'; +import { api, sendTimeAdvanceRequest, sendTimeResetRequest, signup } from '../utils.js'; import type { AuthenticationResponseJSON, AuthenticatorAssertionResponseJSON, @@ -20,7 +20,7 @@ import type { RegistrationResponseJSON, } from '@simplewebauthn/server'; import type * as misskey from 'misskey-js'; -import { describe, beforeAll, test } from 'vitest'; +import { describe, beforeAll, beforeEach, test } from 'vitest'; describe('2要素認証', () => { let alice: misskey.entities.SignupResponse; @@ -45,13 +45,19 @@ describe('2要素認証', () => { 'M0c+PVy4WGvCyMQ6SUWklvzo2+2osjqwsQ==\n' + '-----END EC PRIVATE KEY-----\n'; - const otpToken = (secret: string): string => { + const otpToken = (secret: string, timestamp?: number): string => { return OTPAuth.TOTP.generate({ secret: OTPAuth.Secret.fromBase32(secret), digits: 6, + timestamp, }); }; + const nextOtpToken = async (secret: string): Promise => { + const timestamp = await sendTimeAdvanceRequest({ ms: 30 * 1000 }); + return otpToken(secret, timestamp); + }; + const rpIdHash = (): Buffer => { return crypto.createHash('sha256') .update(Buffer.from(config.host, 'utf-8')) @@ -181,6 +187,11 @@ describe('2要素認証', () => { alice = await signup({ username, password }); }, 1000 * 60 * 2); + beforeEach(async () => { + await sendTimeResetRequest(); + }); + + test('が設定でき、OTPでログインできる。', async () => { const registerResponse = await api('i/2fa/register', { password, @@ -193,7 +204,7 @@ describe('2要素認証', () => { assert.strictEqual(registerResponse.body.issuer, config.host); const doneResponse = await api('i/2fa/done', { - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), }, alice); assert.strictEqual(doneResponse.status, 200); @@ -208,7 +219,7 @@ describe('2要素認証', () => { const signinResponse = await api('signin-flow', { ...signinParam(), - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), }); assert.strictEqual(signinResponse.status, 200); assert.strictEqual(signinResponse.body.finished, true); @@ -217,7 +228,7 @@ describe('2要素認証', () => { // 後片付け await api('i/2fa/unregister', { password, - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), }, alice); }); @@ -228,13 +239,13 @@ describe('2要素認証', () => { assert.strictEqual(registerResponse.status, 200); const doneResponse = await api('i/2fa/done', { - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), }, alice); assert.strictEqual(doneResponse.status, 200); const registerKeyResponse = await api('i/2fa/register-key', { password, - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), }, alice); assert.strictEqual(registerKeyResponse.status, 200); assert.notEqual(registerKeyResponse.body.rp, undefined); @@ -243,7 +254,7 @@ describe('2要素認証', () => { const keyName = 'example-key'; const credentialId = crypto.randomBytes(0x41); const keyDoneResponse = await api('i/2fa/key-done', keyDoneParam({ - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), keyName, credentialId, creationOptions: registerKeyResponse.body, @@ -274,7 +285,7 @@ describe('2要素認証', () => { // 後片付け await api('i/2fa/unregister', { password, - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), }, alice); }); @@ -285,12 +296,12 @@ describe('2要素認証', () => { assert.strictEqual(registerResponse.status, 200); const doneResponse = await api('i/2fa/done', { - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), }, alice); assert.strictEqual(doneResponse.status, 200); const registerKeyResponse = await api('i/2fa/register-key', { - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), password, }, alice); assert.strictEqual(registerKeyResponse.status, 200); @@ -298,7 +309,7 @@ describe('2要素認証', () => { const keyName = 'example-key'; const credentialId = crypto.randomBytes(0x41); const keyDoneResponse = await api('i/2fa/key-done', keyDoneParam({ - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), keyName, credentialId, creationOptions: registerKeyResponse.body, @@ -339,7 +350,7 @@ describe('2要素認証', () => { // 後片付け await api('i/2fa/unregister', { password, - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), }, alice); }); @@ -350,12 +361,12 @@ describe('2要素認証', () => { assert.strictEqual(registerResponse.status, 200); const doneResponse = await api('i/2fa/done', { - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), }, alice); assert.strictEqual(doneResponse.status, 200); const registerKeyResponse = await api('i/2fa/register-key', { - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), password, }, alice); assert.strictEqual(registerKeyResponse.status, 200); @@ -363,7 +374,7 @@ describe('2要素認証', () => { const keyName = 'example-key'; const credentialId = crypto.randomBytes(0x41); const keyDoneResponse = await api('i/2fa/key-done', keyDoneParam({ - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), keyName, credentialId, creationOptions: registerKeyResponse.body, @@ -389,7 +400,7 @@ describe('2要素認証', () => { // 後片付け await api('i/2fa/unregister', { password, - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), }, alice); }); @@ -400,12 +411,12 @@ describe('2要素認証', () => { assert.strictEqual(registerResponse.status, 200); const doneResponse = await api('i/2fa/done', { - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), }, alice); assert.strictEqual(doneResponse.status, 200); const registerKeyResponse = await api('i/2fa/register-key', { - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), password, }, alice); assert.strictEqual(registerKeyResponse.status, 200); @@ -413,7 +424,7 @@ describe('2要素認証', () => { const keyName = 'example-key'; const credentialId = crypto.randomBytes(0x41); const keyDoneResponse = await api('i/2fa/key-done', keyDoneParam({ - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), keyName, credentialId, creationOptions: registerKeyResponse.body, @@ -427,7 +438,7 @@ describe('2要素認証', () => { assert.ok(beforeIResponse.body.securityKeysList); for (const key of beforeIResponse.body.securityKeysList) { const removeKeyResponse = await api('i/2fa/remove-key', { - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), password, credentialId: key.id, }, alice); @@ -440,7 +451,7 @@ describe('2要素認証', () => { const signinResponse = await api('signin-flow', { ...signinParam(), - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), }); assert.strictEqual(signinResponse.status, 200); assert.strictEqual(signinResponse.body.finished, true); @@ -449,7 +460,7 @@ describe('2要素認証', () => { // 後片付け await api('i/2fa/unregister', { password, - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), }, alice); }); @@ -460,7 +471,7 @@ describe('2要素認証', () => { assert.strictEqual(registerResponse.status, 200); const doneResponse = await api('i/2fa/done', { - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), }, alice); assert.strictEqual(doneResponse.status, 200); @@ -469,7 +480,7 @@ describe('2要素認証', () => { assert.strictEqual(iResponse.body.twoFactorEnabled, true); const unregisterResponse = await api('i/2fa/unregister', { - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), password, }, alice); assert.strictEqual(unregisterResponse.status, 204); @@ -484,7 +495,7 @@ describe('2要素認証', () => { // 後片付け await api('i/2fa/unregister', { password, - token: otpToken(registerResponse.body.secret), + token: await nextOtpToken(registerResponse.body.secret), }, alice); }); }); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index e9938c5fa8..cbd8870d22 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -644,6 +644,46 @@ export async function sendEnvResetRequest() { } } +export async function sendTimeAdvanceRequest(params: { ms: number }): Promise { + const res = await fetch( + `http://localhost:${port + 1000}/time/advance`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + }, + ); + + if (res.status !== 200) { + throw new Error('server time advance failed.'); + } + + const body = await res.json() as { now: number }; + return body.now; +} + +export async function sendTimeResetRequest(): Promise { + const res = await fetch( + `http://localhost:${port + 1000}/time/reset`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }, + ); + + if (res.status !== 200) { + throw new Error('server time reset failed.'); + } + + const body = await res.json() as { now: number }; + return body.now; +} + // 与えられた値を強制的にエラーとみなす。この関数は型安全性を破壊するため、異常系のアサーション以外で用いられるべきではない。 // FIXME(misskey-js): misskey-jsがエラー情報を公開するようになったらこの関数を廃止する export function castAsError(obj: Record): { error: ApiError } {