@adminforth/two-factors-auth 4.7.0 → 4.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,9 +7,11 @@ export declare class TotpService {
7
7
  private readonly userRepository;
8
8
  private readonly adminforth;
9
9
  private readonly codeAttemptLimiters;
10
+ private readonly lastUsedCodeSteps;
10
11
  constructor(options: PluginOptions, userRepository: UserRepository, adminforth: IAdminForth);
11
12
  generateSecret(issuerName: string, userName: string): string;
12
13
  verifySecret(secret: string, code: string): boolean;
14
+ private findCodeStep;
13
15
  verifyUserCode(userPk: string, code: string): Promise<VerificationResult>;
14
16
  verifyAdminUserCode(adminUser: AdminUser, code: string): Promise<{
15
17
  ok: true;
@@ -13,21 +13,28 @@ import { TwoFactorErrorCode } from "../utils/errors.js";
13
13
  const CODE_ATTEMPT_RATE_LIMITS = ['30/1m', '300/1h', '1000/1d'];
14
14
  const VERIFIED_CODE_JWT_TYPE = 'verifiedTotpCode';
15
15
  const VERIFIED_CODE_TOKEN_TTL = '1m';
16
+ const TOTP_STEP_SECONDS = 30;
16
17
  export class TotpService {
17
18
  constructor(options, userRepository, adminforth) {
18
19
  this.options = options;
19
20
  this.userRepository = userRepository;
20
21
  this.adminforth = adminforth;
21
22
  this.codeAttemptLimiters = CODE_ATTEMPT_RATE_LIMITS.map((rate) => new RateLimiter(rate));
23
+ this.lastUsedCodeSteps = new Map();
22
24
  }
23
25
  generateSecret(issuerName, userName) {
24
26
  return twofactor.generateSecret({ name: issuerName, account: userName }).secret;
25
27
  }
26
28
  verifySecret(secret, code) {
27
- return Boolean(twofactor.verifyToken(secret, code, this.options.timeStepWindow));
29
+ return this.findCodeStep(secret, code) !== null;
30
+ }
31
+ findCodeStep(secret, code) {
32
+ const match = twofactor.verifyToken(secret, code, this.options.timeStepWindow);
33
+ return match && Math.floor(Date.now() / 1000 / TOTP_STEP_SECONDS) + match.delta;
28
34
  }
29
35
  verifyUserCode(userPk, code) {
30
36
  return __awaiter(this, void 0, void 0, function* () {
37
+ var _a;
31
38
  const attemptsAllowed = yield Promise.all(this.codeAttemptLimiters.map((limiter) => limiter.consume(userPk)));
32
39
  if (!attemptsAllowed.every(Boolean)) {
33
40
  return { error: "Too many attempts to enter the code. Try again later.", code: TwoFactorErrorCode.RATE_LIMITED };
@@ -38,9 +45,14 @@ export class TotpService {
38
45
  const secret = this.userRepository.getSecret(user);
39
46
  if (!secret)
40
47
  return { error: "2FA is not set up for this user", code: TwoFactorErrorCode.NOT_SET_UP };
41
- return this.verifySecret(secret, code)
42
- ? { ok: true }
43
- : { error: "Wrong or expired OTP code", code: TwoFactorErrorCode.INVALID };
48
+ const step = this.findCodeStep(secret, code);
49
+ if (step === null)
50
+ return { error: "Wrong or expired OTP code", code: TwoFactorErrorCode.INVALID };
51
+ if (step <= ((_a = this.lastUsedCodeSteps.get(userPk)) !== null && _a !== void 0 ? _a : -Infinity)) {
52
+ return { error: "This code has already been used. Wait for the next one.", code: TwoFactorErrorCode.INVALID };
53
+ }
54
+ this.lastUsedCodeSteps.set(userPk, step);
55
+ return { ok: true };
44
56
  });
45
57
  }
46
58
  verifyAdminUserCode(adminUser, code) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/two-factors-auth",
3
- "version": "4.7.0",
3
+ "version": "4.7.1",
4
4
  "description": "AdminForth plugin for two factors authentication",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -8,9 +8,11 @@ import type { VerificationFailure, VerificationResult } from "../utils/types.js"
8
8
  const CODE_ATTEMPT_RATE_LIMITS = ['30/1m', '300/1h', '1000/1d'];
9
9
  const VERIFIED_CODE_JWT_TYPE = 'verifiedTotpCode';
10
10
  const VERIFIED_CODE_TOKEN_TTL = '1m';
11
+ const TOTP_STEP_SECONDS = 30;
11
12
 
12
13
  export class TotpService {
13
14
  private readonly codeAttemptLimiters = CODE_ATTEMPT_RATE_LIMITS.map((rate) => new RateLimiter(rate));
15
+ private readonly lastUsedCodeSteps = new Map<string, number>();
14
16
 
15
17
  constructor(
16
18
  private readonly options: PluginOptions,
@@ -23,7 +25,12 @@ export class TotpService {
23
25
  }
24
26
 
25
27
  public verifySecret(secret: string, code: string): boolean {
26
- return Boolean(twofactor.verifyToken(secret, code, this.options.timeStepWindow));
28
+ return this.findCodeStep(secret, code) !== null;
29
+ }
30
+
31
+ private findCodeStep(secret: string, code: string): number | null {
32
+ const match = twofactor.verifyToken(secret, code, this.options.timeStepWindow);
33
+ return match && Math.floor(Date.now() / 1000 / TOTP_STEP_SECONDS) + match.delta;
27
34
  }
28
35
 
29
36
  public async verifyUserCode(userPk: string, code: string): Promise<VerificationResult> {
@@ -38,9 +45,14 @@ export class TotpService {
38
45
  const secret = this.userRepository.getSecret(user);
39
46
  if (!secret) return { error: "2FA is not set up for this user", code: TwoFactorErrorCode.NOT_SET_UP };
40
47
 
41
- return this.verifySecret(secret, code)
42
- ? { ok: true }
43
- : { error: "Wrong or expired OTP code", code: TwoFactorErrorCode.INVALID };
48
+ const step = this.findCodeStep(secret, code);
49
+ if (step === null) return { error: "Wrong or expired OTP code", code: TwoFactorErrorCode.INVALID };
50
+
51
+ if (step <= (this.lastUsedCodeSteps.get(userPk) ?? -Infinity)) {
52
+ return { error: "This code has already been used. Wait for the next one.", code: TwoFactorErrorCode.INVALID };
53
+ }
54
+ this.lastUsedCodeSteps.set(userPk, step);
55
+ return { ok: true };
44
56
  }
45
57
 
46
58
  public async verifyAdminUserCode(adminUser: AdminUser, code: string): Promise<{ ok: true; verifiedCodeToken: string } | VerificationFailure> {
@@ -36,3 +36,30 @@ test('a correct code gives a token that confirms the code only for its user', as
36
36
  assert.equal(await service.isVerifiedCodeToken(verifiedCodeToken, 'user-2'), false);
37
37
  assert.equal(await service.isVerifiedCodeToken('forged', 'user-1'), false);
38
38
  });
39
+
40
+ const STEP_MS = 30_000;
41
+ const codeAt = (t, time) => {
42
+ t.mock.timers.setTime(time);
43
+ return twofactor.generateToken(SECRET).token;
44
+ };
45
+
46
+ test('a code confirms only one request, so a phished code can not be replayed', async (t) => {
47
+ t.mock.timers.enable({ apis: ['Date'], now: 1_000 * STEP_MS });
48
+ const service = createService();
49
+ const code = codeAt(t, 1_000 * STEP_MS);
50
+ const results = await Promise.all([service.verifyUserCode('user-1', code), service.verifyUserCode('user-1', code)]);
51
+ assert.equal(results.filter((result) => result.ok).length, 1);
52
+ assert.equal((await service.verifyUserCode('user-1', code)).code, TwoFactorErrorCode.INVALID);
53
+ assert.equal((await service.verifyAdminUserCode({ pk: 'user-1' }, code)).code, TwoFactorErrorCode.INVALID);
54
+ assert.deepEqual(await service.verifyUserCode('user-2', code), { ok: true });
55
+ });
56
+
57
+ test('after a code is used, older codes from the window are rejected and the next code is accepted', async (t) => {
58
+ t.mock.timers.enable({ apis: ['Date'], now: 1_000 * STEP_MS });
59
+ const service = createService();
60
+ const olderCode = codeAt(t, 1_000 * STEP_MS);
61
+ const code = codeAt(t, 1_001 * STEP_MS);
62
+ assert.deepEqual(await service.verifyUserCode('user-1', code), { ok: true });
63
+ assert.equal((await service.verifyUserCode('user-1', olderCode)).code, TwoFactorErrorCode.INVALID);
64
+ assert.deepEqual(await service.verifyUserCode('user-1', codeAt(t, 1_002 * STEP_MS)), { ok: true });
65
+ });