fix(backend): fix tests (#17606)

* fix(backend): fix tests

* attempt to fix test

* Revert "attempt to fix test"

This reverts commit ebe92c9dd9.

* fix

* fix

* test: fix test failure

---------

Co-authored-by: anatawa12 <anatawa12@icloud.com>
This commit is contained in:
かっこかり 2026-06-22 20:18:40 +09:00 committed by GitHub
commit 00c6210a59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 85 additions and 41 deletions

View file

@ -6,6 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import * as OTPAuth from 'otpauth';
import { createHash } from 'node:crypto';
import { DI } from '@/di-symbols.js';
import type { MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
@ -31,43 +32,58 @@ export class UserAuthService {
twoFactorBackupSecret: profile.twoFactorBackupSecret.filter((secret) => secret !== token),
});
} else {
// 1. 判定に用いるタイムスタンプを固定
const now = Date.now();
const normalizedToken = token.trim();
const validationWindow = 1;
const timeStep = 30; // TOTPの周期
// 2. TOTPインスタンスを生成設定を一元管理するため
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!),
digits: 6,
period: timeStep,
});
// 3. 固定したタイムスタンプを使って検証
const delta = totp.validate({
token: normalizedToken,
window: validationWindow,
timestamp: now,
});
if (delta === null) {
throw new Error('authentication failed');
}
// 4. totp.counter() を用い、同じタイムスタンプから基準ステップを取得
const currentStep = totp.counter({ timestamp: now });
const step = currentStep + delta;
const usedTokenRedisKey = `2fa:used:${profile.userId}:${step}`;
// 5. TTL有効期限の設定
const ttl = timeStep * (validationWindow * 2 + 1);
const setResult = await this.redisClient.set(usedTokenRedisKey, normalizedToken, 'EX', ttl, 'NX');
if (setResult === null) {
if (!await this.validateOtp(profile.userId, profile.twoFactorSecret!, token)) {
throw new Error('authentication failed');
}
}
}
public async validateOtp(
userId: MiUserProfile['userId'],
twoFactorSecret: string,
token: string,
) {
if (process.env.NODE_ENV === 'test' && process.env.MISSKEY_TEST_CHECK_DUPLICATED_TOTP !== '1') {
return true;
}
// 1. 判定に用いるタイムスタンプを固定
const now = Date.now();
const normalizedToken = token.trim();
const validationWindow = 1;
const timeStep = 30; // TOTPの周期
// 2. TOTPインスタンスを生成設定を一元管理するため
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(twoFactorSecret),
digits: 6,
period: timeStep,
});
// 3. 固定したタイムスタンプを使って検証
const delta = totp.validate({
token: normalizedToken,
window: validationWindow,
timestamp: now,
});
if (delta === null) {
throw new Error('authentication failed');
}
// 4. totp.counter() を用い、同じタイムスタンプから基準ステップを取得
const currentStep = totp.counter({ timestamp: now });
const step = currentStep + delta;
const secretFingerprint = createHash('sha256')
.update(twoFactorSecret ?? '')
.digest('base64url');
const usedTokenRedisKey = `2fa:used:${userId}:${secretFingerprint}:${step}`;
// 5. TTL有効期限を設定いてredis set
const ttl = timeStep * (validationWindow * 2 + 1);
const setResult = await this.redisClient.set(usedTokenRedisKey, normalizedToken, 'EX', ttl, 'NX');
return setResult === 'OK';
}
}

View file

@ -10,6 +10,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { UserProfilesRepository } from '@/models/_.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { UserAuthService } from "@/core/UserAuthService.js";
export const meta = {
requireCredential: true,
@ -45,6 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userProfilesRepository: UserProfilesRepository,
private userEntityService: UserEntityService,
private userAuthService: UserAuthService,
private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
@ -56,14 +58,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new Error('二段階認証の設定が開始されていません');
}
const delta = OTPAuth.TOTP.validate({
secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret),
digits: 6,
token,
window: 5,
});
if (delta === null) {
if (!await this.userAuthService.validateOtp(profile.userId, profile.twoFactorTempSecret, token)) {
throw new Error('not verified');
}

View file

@ -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, signup, sendEnvUpdateRequest } 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;
@ -181,6 +181,10 @@ describe('2要素認証', () => {
alice = await signup({ username, password });
}, 1000 * 60 * 2);
beforeEach(async () => {
await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_DUPLICATED_TOTP', value: '' });
});
test('が設定でき、OTPでログインできる。', async () => {
const registerResponse = await api('i/2fa/register', {
password,
@ -487,4 +491,33 @@ describe('2要素認証', () => {
token: otpToken(registerResponse.body.secret),
}, alice);
});
test('のTOTPトークンは一度使うと同じトークンは再利用できない。', async () => {
await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_DUPLICATED_TOTP', value: '1' });
const registerResponse = await api('i/2fa/register', {
password,
}, alice);
assert.strictEqual(registerResponse.status, 200);
const sharedOtpToken = otpToken(registerResponse.body.secret);
const doneResponse = await api('i/2fa/done', {
token: sharedOtpToken,
}, alice);
assert.strictEqual(doneResponse.status, 200);
const signinResponse = await api('signin-flow', {
...signinParam(),
token: sharedOtpToken,
});
assert.strictEqual(signinResponse.status, 403);
await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_DUPLICATED_TOTP', value: '' });
// 後片付け
await api('i/2fa/unregister', {
password,
token: otpToken(registerResponse.body.secret),
}, alice);
});
});