forked from mirrors/misskey
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:
parent
2b87748537
commit
00c6210a59
3 changed files with 85 additions and 41 deletions
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue