Merge commit from fork

* fix(backend): Prevent the reuse of used TOTP tokens

* fix

* fix

* tighten totp window
This commit is contained in:
かっこかり 2026-06-22 16:34:15 +09:00 committed by GitHub
commit d323fe00d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -4,17 +4,18 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { QueryFailedError } from 'typeorm';
import * as Redis from 'ioredis';
import * as OTPAuth from 'otpauth';
import { DI } from '@/di-symbols.js';
import type { MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import type { MiLocalUser } from '@/models/User.js';
@Injectable()
export class UserAuthService {
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -30,16 +31,43 @@ export class UserAuthService {
twoFactorBackupSecret: profile.twoFactorBackupSecret.filter((secret) => secret !== token),
});
} else {
const delta = OTPAuth.TOTP.validate({
// 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,
token,
window: 5,
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) {
throw new Error('authentication failed');
}
}
}
}