From d323fe00d04ac46ab0b4e66fce9169effaa8dfb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:34:15 +0900 Subject: [PATCH] Merge commit from fork * fix(backend): Prevent the reuse of used TOTP tokens * fix * fix * tighten totp window --- packages/backend/src/core/UserAuthService.ts | 40 +++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/core/UserAuthService.ts b/packages/backend/src/core/UserAuthService.ts index bdc27cbe8e..dc9104e8a2 100644 --- a/packages/backend/src/core/UserAuthService.ts +++ b/packages/backend/src/core/UserAuthService.ts @@ -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'); + } } } }