redis-otp-manager 0.2.3 → 0.3.0
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.
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ This package currently includes:
|
|
|
15
15
|
- Rate limiting
|
|
16
16
|
- Optional resend cooldown
|
|
17
17
|
- Max-attempt protection
|
|
18
|
+
- Atomic Redis generate and verify paths using Lua scripts
|
|
18
19
|
- NestJS module integration via `redis-otp-manager/nest`
|
|
19
20
|
- Dual package support for ESM and CommonJS consumers
|
|
20
21
|
|
|
@@ -32,7 +33,7 @@ npm install @nestjs/common @nestjs/core reflect-metadata rxjs
|
|
|
32
33
|
|
|
33
34
|
## Module Support
|
|
34
35
|
|
|
35
|
-
This package
|
|
36
|
+
This package supports both:
|
|
36
37
|
- ESM imports
|
|
37
38
|
- CommonJS/Nest `ts-node/register` style resolution
|
|
38
39
|
|
|
@@ -89,6 +90,16 @@ await otp.verify({
|
|
|
89
90
|
});
|
|
90
91
|
```
|
|
91
92
|
|
|
93
|
+
## Production Security Notes
|
|
94
|
+
|
|
95
|
+
When you use `RedisAdapter`, the package now takes the Redis-specific atomic path for:
|
|
96
|
+
- OTP generation plus rate-limit/cooldown checks
|
|
97
|
+
- OTP verification plus attempt tracking and OTP deletion
|
|
98
|
+
|
|
99
|
+
That prevents the most important race condition in earlier versions where two parallel correct verification requests could both succeed.
|
|
100
|
+
|
|
101
|
+
Simpler adapters like `MemoryAdapter` intentionally stay on the non-atomic fallback path to keep tests and local development lightweight.
|
|
102
|
+
|
|
92
103
|
## NestJS
|
|
93
104
|
|
|
94
105
|
Import the Nest integration from the dedicated subpath so non-Nest users do not pull Nest dependencies unless they need them.
|
|
@@ -223,6 +234,7 @@ Returns `true` or throws a typed error.
|
|
|
223
234
|
- `maxAttempts: 3`
|
|
224
235
|
- `resendCooldown: 30` or higher to reduce abuse
|
|
225
236
|
- keep Redis private and behind authenticated network access
|
|
237
|
+
- prefer `RedisAdapter` in production to get the atomic security path
|
|
226
238
|
|
|
227
239
|
## Key Design
|
|
228
240
|
|
|
@@ -237,7 +249,7 @@ cooldown:{intent}:{type}:{identifier}
|
|
|
237
249
|
|
|
238
250
|
Publishing on every `main` merge is not recommended for npm packages because npm versions are immutable. The safer setup is:
|
|
239
251
|
- merge to `main` runs CI only
|
|
240
|
-
- publish happens when you push a version tag like `v0.
|
|
252
|
+
- publish happens when you push a version tag like `v0.3.0`
|
|
241
253
|
|
|
242
254
|
Required GitHub secrets:
|
|
243
255
|
- `NPM_TOKEN`
|
|
@@ -245,12 +257,12 @@ Required GitHub secrets:
|
|
|
245
257
|
Tag-based publish:
|
|
246
258
|
|
|
247
259
|
```bash
|
|
248
|
-
git tag v0.
|
|
249
|
-
git push origin v0.
|
|
260
|
+
git tag v0.3.0
|
|
261
|
+
git push origin v0.3.0
|
|
250
262
|
```
|
|
251
263
|
|
|
252
264
|
## Next Roadmap
|
|
253
265
|
|
|
254
|
-
-
|
|
266
|
+
- keyed HMAC for OTP hashing
|
|
255
267
|
- hooks/events
|
|
256
268
|
- analytics and observability
|
|
@@ -7,7 +7,31 @@ export interface RedisLikeClient {
|
|
|
7
7
|
del(key: string): Promise<unknown>;
|
|
8
8
|
incr(key: string): Promise<number>;
|
|
9
9
|
expire(key: string, seconds: number): Promise<unknown>;
|
|
10
|
+
eval?(script: string, options: {
|
|
11
|
+
keys: string[];
|
|
12
|
+
arguments: string[];
|
|
13
|
+
}): Promise<string | number | null>;
|
|
10
14
|
}
|
|
15
|
+
export interface AtomicGenerateParams {
|
|
16
|
+
otpKey: string;
|
|
17
|
+
attemptsKey: string;
|
|
18
|
+
rateLimitKey: string;
|
|
19
|
+
cooldownKey: string;
|
|
20
|
+
hashedOtp: string;
|
|
21
|
+
ttl: number;
|
|
22
|
+
rateWindow?: number;
|
|
23
|
+
rateMax?: number;
|
|
24
|
+
resendCooldown?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface AtomicVerifyParams {
|
|
27
|
+
otpKey: string;
|
|
28
|
+
attemptsKey: string;
|
|
29
|
+
providedHash: string;
|
|
30
|
+
ttl: number;
|
|
31
|
+
maxAttempts: number;
|
|
32
|
+
}
|
|
33
|
+
export type AtomicGenerateResult = "ok" | "rate_limit" | "cooldown";
|
|
34
|
+
export type AtomicVerifyResult = "verified" | "expired" | "invalid" | "max_attempts";
|
|
11
35
|
export declare class RedisAdapter implements StoreAdapter {
|
|
12
36
|
private readonly client;
|
|
13
37
|
constructor(client: RedisLikeClient);
|
|
@@ -15,4 +39,6 @@ export declare class RedisAdapter implements StoreAdapter {
|
|
|
15
39
|
set(key: string, value: string, ttlSeconds: number): Promise<void>;
|
|
16
40
|
del(key: string): Promise<void>;
|
|
17
41
|
increment(key: string, ttlSeconds: number): Promise<number>;
|
|
42
|
+
generateOtpAtomically(params: AtomicGenerateParams): Promise<AtomicGenerateResult | null>;
|
|
43
|
+
verifyOtpAtomically(params: AtomicVerifyParams): Promise<AtomicVerifyResult | null>;
|
|
18
44
|
}
|
|
@@ -1,3 +1,55 @@
|
|
|
1
|
+
const ATOMIC_GENERATE_SCRIPT = `
|
|
2
|
+
if ARGV[5] ~= '' then
|
|
3
|
+
local cooldownExists = redis.call('GET', KEYS[4])
|
|
4
|
+
if cooldownExists then
|
|
5
|
+
return 'cooldown'
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
if ARGV[3] ~= '' and ARGV[4] ~= '' then
|
|
10
|
+
local nextCount = redis.call('INCR', KEYS[3])
|
|
11
|
+
if nextCount == 1 then
|
|
12
|
+
redis.call('EXPIRE', KEYS[3], tonumber(ARGV[3]))
|
|
13
|
+
end
|
|
14
|
+
if nextCount > tonumber(ARGV[4]) then
|
|
15
|
+
return 'rate_limit'
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
redis.call('SET', KEYS[1], ARGV[1], 'EX', tonumber(ARGV[2]))
|
|
20
|
+
redis.call('DEL', KEYS[2])
|
|
21
|
+
|
|
22
|
+
if ARGV[5] ~= '' then
|
|
23
|
+
redis.call('SET', KEYS[4], '1', 'EX', tonumber(ARGV[5]))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
return 'ok'
|
|
27
|
+
`;
|
|
28
|
+
const ATOMIC_VERIFY_SCRIPT = `
|
|
29
|
+
local storedHash = redis.call('GET', KEYS[1])
|
|
30
|
+
if not storedHash then
|
|
31
|
+
return 'expired'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if storedHash == ARGV[1] then
|
|
35
|
+
redis.call('DEL', KEYS[1])
|
|
36
|
+
redis.call('DEL', KEYS[2])
|
|
37
|
+
return 'verified'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
local attempts = redis.call('INCR', KEYS[2])
|
|
41
|
+
if attempts == 1 then
|
|
42
|
+
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[2]))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if attempts >= tonumber(ARGV[3]) then
|
|
46
|
+
redis.call('DEL', KEYS[1])
|
|
47
|
+
redis.call('DEL', KEYS[2])
|
|
48
|
+
return 'max_attempts'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
return 'invalid'
|
|
52
|
+
`;
|
|
1
53
|
export class RedisAdapter {
|
|
2
54
|
client;
|
|
3
55
|
constructor(client) {
|
|
@@ -19,4 +71,39 @@ export class RedisAdapter {
|
|
|
19
71
|
}
|
|
20
72
|
return nextValue;
|
|
21
73
|
}
|
|
74
|
+
async generateOtpAtomically(params) {
|
|
75
|
+
if (!this.client.eval) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const result = await this.client.eval(ATOMIC_GENERATE_SCRIPT, {
|
|
79
|
+
keys: [params.otpKey, params.attemptsKey, params.rateLimitKey, params.cooldownKey],
|
|
80
|
+
arguments: [
|
|
81
|
+
params.hashedOtp,
|
|
82
|
+
String(params.ttl),
|
|
83
|
+
params.rateWindow ? String(params.rateWindow) : "",
|
|
84
|
+
params.rateMax ? String(params.rateMax) : "",
|
|
85
|
+
params.resendCooldown ? String(params.resendCooldown) : "",
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
if (result === "ok" || result === "rate_limit" || result === "cooldown") {
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
async verifyOtpAtomically(params) {
|
|
94
|
+
if (!this.client.eval) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const result = await this.client.eval(ATOMIC_VERIFY_SCRIPT, {
|
|
98
|
+
keys: [params.otpKey, params.attemptsKey],
|
|
99
|
+
arguments: [params.providedHash, String(params.ttl), String(params.maxAttempts)],
|
|
100
|
+
});
|
|
101
|
+
if (result === "verified" ||
|
|
102
|
+
result === "expired" ||
|
|
103
|
+
result === "invalid" ||
|
|
104
|
+
result === "max_attempts") {
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
22
109
|
}
|
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.RedisAdapter = void 0;
|
|
4
|
+
const ATOMIC_GENERATE_SCRIPT = `
|
|
5
|
+
if ARGV[5] ~= '' then
|
|
6
|
+
local cooldownExists = redis.call('GET', KEYS[4])
|
|
7
|
+
if cooldownExists then
|
|
8
|
+
return 'cooldown'
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
if ARGV[3] ~= '' and ARGV[4] ~= '' then
|
|
13
|
+
local nextCount = redis.call('INCR', KEYS[3])
|
|
14
|
+
if nextCount == 1 then
|
|
15
|
+
redis.call('EXPIRE', KEYS[3], tonumber(ARGV[3]))
|
|
16
|
+
end
|
|
17
|
+
if nextCount > tonumber(ARGV[4]) then
|
|
18
|
+
return 'rate_limit'
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
redis.call('SET', KEYS[1], ARGV[1], 'EX', tonumber(ARGV[2]))
|
|
23
|
+
redis.call('DEL', KEYS[2])
|
|
24
|
+
|
|
25
|
+
if ARGV[5] ~= '' then
|
|
26
|
+
redis.call('SET', KEYS[4], '1', 'EX', tonumber(ARGV[5]))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
return 'ok'
|
|
30
|
+
`;
|
|
31
|
+
const ATOMIC_VERIFY_SCRIPT = `
|
|
32
|
+
local storedHash = redis.call('GET', KEYS[1])
|
|
33
|
+
if not storedHash then
|
|
34
|
+
return 'expired'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if storedHash == ARGV[1] then
|
|
38
|
+
redis.call('DEL', KEYS[1])
|
|
39
|
+
redis.call('DEL', KEYS[2])
|
|
40
|
+
return 'verified'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
local attempts = redis.call('INCR', KEYS[2])
|
|
44
|
+
if attempts == 1 then
|
|
45
|
+
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[2]))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if attempts >= tonumber(ARGV[3]) then
|
|
49
|
+
redis.call('DEL', KEYS[1])
|
|
50
|
+
redis.call('DEL', KEYS[2])
|
|
51
|
+
return 'max_attempts'
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
return 'invalid'
|
|
55
|
+
`;
|
|
4
56
|
class RedisAdapter {
|
|
5
57
|
client;
|
|
6
58
|
constructor(client) {
|
|
@@ -22,5 +74,40 @@ class RedisAdapter {
|
|
|
22
74
|
}
|
|
23
75
|
return nextValue;
|
|
24
76
|
}
|
|
77
|
+
async generateOtpAtomically(params) {
|
|
78
|
+
if (!this.client.eval) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const result = await this.client.eval(ATOMIC_GENERATE_SCRIPT, {
|
|
82
|
+
keys: [params.otpKey, params.attemptsKey, params.rateLimitKey, params.cooldownKey],
|
|
83
|
+
arguments: [
|
|
84
|
+
params.hashedOtp,
|
|
85
|
+
String(params.ttl),
|
|
86
|
+
params.rateWindow ? String(params.rateWindow) : "",
|
|
87
|
+
params.rateMax ? String(params.rateMax) : "",
|
|
88
|
+
params.resendCooldown ? String(params.resendCooldown) : "",
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
if (result === "ok" || result === "rate_limit" || result === "cooldown") {
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
async verifyOtpAtomically(params) {
|
|
97
|
+
if (!this.client.eval) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const result = await this.client.eval(ATOMIC_VERIFY_SCRIPT, {
|
|
101
|
+
keys: [params.otpKey, params.attemptsKey],
|
|
102
|
+
arguments: [params.providedHash, String(params.ttl), String(params.maxAttempts)],
|
|
103
|
+
});
|
|
104
|
+
if (result === "verified" ||
|
|
105
|
+
result === "expired" ||
|
|
106
|
+
result === "invalid" ||
|
|
107
|
+
result === "max_attempts") {
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
25
112
|
}
|
|
26
113
|
exports.RedisAdapter = RedisAdapter;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.OTPManager = void 0;
|
|
4
|
+
const redis_adapter_js_1 = require("../adapters/redis.adapter.js");
|
|
4
5
|
const otp_generator_js_1 = require("./otp.generator.js");
|
|
5
6
|
const otp_hash_js_1 = require("./otp.hash.js");
|
|
6
7
|
const otp_errors_js_1 = require("../errors/otp.errors.js");
|
|
@@ -24,6 +25,33 @@ class OTPManager {
|
|
|
24
25
|
const attemptsKey = (0, key_builder_js_1.buildAttemptsKey)(normalizedInput);
|
|
25
26
|
const rateLimitKey = (0, key_builder_js_1.buildRateLimitKey)(normalizedInput);
|
|
26
27
|
const cooldownKey = (0, key_builder_js_1.buildCooldownKey)(normalizedInput);
|
|
28
|
+
const otp = (0, otp_generator_js_1.generateNumericOtp)(this.options.otpLength);
|
|
29
|
+
const hashedOtp = (0, otp_hash_js_1.hashOtp)(otp);
|
|
30
|
+
if (this.options.store instanceof redis_adapter_js_1.RedisAdapter) {
|
|
31
|
+
const atomicResult = await this.options.store.generateOtpAtomically({
|
|
32
|
+
otpKey,
|
|
33
|
+
attemptsKey,
|
|
34
|
+
rateLimitKey,
|
|
35
|
+
cooldownKey,
|
|
36
|
+
hashedOtp,
|
|
37
|
+
ttl: this.options.ttl,
|
|
38
|
+
rateWindow: this.options.rateLimit?.window,
|
|
39
|
+
rateMax: this.options.rateLimit?.max,
|
|
40
|
+
resendCooldown: this.options.resendCooldown,
|
|
41
|
+
});
|
|
42
|
+
if (atomicResult === "cooldown") {
|
|
43
|
+
throw new otp_errors_js_1.OTPResendCooldownError();
|
|
44
|
+
}
|
|
45
|
+
if (atomicResult === "rate_limit") {
|
|
46
|
+
throw new otp_errors_js_1.OTPRateLimitExceededError();
|
|
47
|
+
}
|
|
48
|
+
if (atomicResult === "ok") {
|
|
49
|
+
return {
|
|
50
|
+
expiresIn: this.options.ttl,
|
|
51
|
+
otp: this.options.devMode ? otp : undefined,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
27
55
|
if (this.options.resendCooldown) {
|
|
28
56
|
const cooldownActive = await this.options.store.get(cooldownKey);
|
|
29
57
|
if (cooldownActive) {
|
|
@@ -31,8 +59,6 @@ class OTPManager {
|
|
|
31
59
|
}
|
|
32
60
|
}
|
|
33
61
|
await (0, rate_limiter_js_1.assertWithinRateLimit)(this.options.store, rateLimitKey, this.options.rateLimit);
|
|
34
|
-
const otp = (0, otp_generator_js_1.generateNumericOtp)(this.options.otpLength);
|
|
35
|
-
const hashedOtp = (0, otp_hash_js_1.hashOtp)(otp);
|
|
36
62
|
await this.options.store.set(otpKey, hashedOtp, this.options.ttl);
|
|
37
63
|
await this.options.store.del(attemptsKey);
|
|
38
64
|
if (this.options.resendCooldown) {
|
|
@@ -51,6 +77,27 @@ class OTPManager {
|
|
|
51
77
|
}
|
|
52
78
|
const otpKey = (0, key_builder_js_1.buildOtpKey)(normalizedInput);
|
|
53
79
|
const attemptsKey = (0, key_builder_js_1.buildAttemptsKey)(normalizedInput);
|
|
80
|
+
if (this.options.store instanceof redis_adapter_js_1.RedisAdapter) {
|
|
81
|
+
const atomicResult = await this.options.store.verifyOtpAtomically({
|
|
82
|
+
otpKey,
|
|
83
|
+
attemptsKey,
|
|
84
|
+
providedHash: (0, otp_hash_js_1.hashOtp)(input.otp),
|
|
85
|
+
ttl: this.options.ttl,
|
|
86
|
+
maxAttempts: this.options.maxAttempts,
|
|
87
|
+
});
|
|
88
|
+
if (atomicResult === "verified") {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
if (atomicResult === "expired") {
|
|
92
|
+
throw new otp_errors_js_1.OTPExpiredError();
|
|
93
|
+
}
|
|
94
|
+
if (atomicResult === "max_attempts") {
|
|
95
|
+
throw new otp_errors_js_1.OTPMaxAttemptsExceededError();
|
|
96
|
+
}
|
|
97
|
+
if (atomicResult === "invalid") {
|
|
98
|
+
throw new otp_errors_js_1.OTPInvalidError();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
54
101
|
const storedHash = await this.options.store.get(otpKey);
|
|
55
102
|
if (!storedHash) {
|
|
56
103
|
throw new otp_errors_js_1.OTPExpiredError();
|
package/dist/core/otp.service.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { RedisAdapter } from "../adapters/redis.adapter.js";
|
|
1
2
|
import { generateNumericOtp } from "./otp.generator.js";
|
|
2
3
|
import { hashOtp, verifyOtpHash } from "./otp.hash.js";
|
|
3
|
-
import { OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPResendCooldownError, } from "../errors/otp.errors.js";
|
|
4
|
+
import { OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPRateLimitExceededError, OTPResendCooldownError, } from "../errors/otp.errors.js";
|
|
4
5
|
import { buildAttemptsKey, buildCooldownKey, buildOtpKey, buildRateLimitKey, } from "../utils/key-builder.js";
|
|
5
6
|
import { normalizePayloadIdentifier } from "../utils/identifier-normalizer.js";
|
|
6
7
|
import { assertWithinRateLimit } from "../utils/rate-limiter.js";
|
|
@@ -21,6 +22,33 @@ export class OTPManager {
|
|
|
21
22
|
const attemptsKey = buildAttemptsKey(normalizedInput);
|
|
22
23
|
const rateLimitKey = buildRateLimitKey(normalizedInput);
|
|
23
24
|
const cooldownKey = buildCooldownKey(normalizedInput);
|
|
25
|
+
const otp = generateNumericOtp(this.options.otpLength);
|
|
26
|
+
const hashedOtp = hashOtp(otp);
|
|
27
|
+
if (this.options.store instanceof RedisAdapter) {
|
|
28
|
+
const atomicResult = await this.options.store.generateOtpAtomically({
|
|
29
|
+
otpKey,
|
|
30
|
+
attemptsKey,
|
|
31
|
+
rateLimitKey,
|
|
32
|
+
cooldownKey,
|
|
33
|
+
hashedOtp,
|
|
34
|
+
ttl: this.options.ttl,
|
|
35
|
+
rateWindow: this.options.rateLimit?.window,
|
|
36
|
+
rateMax: this.options.rateLimit?.max,
|
|
37
|
+
resendCooldown: this.options.resendCooldown,
|
|
38
|
+
});
|
|
39
|
+
if (atomicResult === "cooldown") {
|
|
40
|
+
throw new OTPResendCooldownError();
|
|
41
|
+
}
|
|
42
|
+
if (atomicResult === "rate_limit") {
|
|
43
|
+
throw new OTPRateLimitExceededError();
|
|
44
|
+
}
|
|
45
|
+
if (atomicResult === "ok") {
|
|
46
|
+
return {
|
|
47
|
+
expiresIn: this.options.ttl,
|
|
48
|
+
otp: this.options.devMode ? otp : undefined,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
24
52
|
if (this.options.resendCooldown) {
|
|
25
53
|
const cooldownActive = await this.options.store.get(cooldownKey);
|
|
26
54
|
if (cooldownActive) {
|
|
@@ -28,8 +56,6 @@ export class OTPManager {
|
|
|
28
56
|
}
|
|
29
57
|
}
|
|
30
58
|
await assertWithinRateLimit(this.options.store, rateLimitKey, this.options.rateLimit);
|
|
31
|
-
const otp = generateNumericOtp(this.options.otpLength);
|
|
32
|
-
const hashedOtp = hashOtp(otp);
|
|
33
59
|
await this.options.store.set(otpKey, hashedOtp, this.options.ttl);
|
|
34
60
|
await this.options.store.del(attemptsKey);
|
|
35
61
|
if (this.options.resendCooldown) {
|
|
@@ -48,6 +74,27 @@ export class OTPManager {
|
|
|
48
74
|
}
|
|
49
75
|
const otpKey = buildOtpKey(normalizedInput);
|
|
50
76
|
const attemptsKey = buildAttemptsKey(normalizedInput);
|
|
77
|
+
if (this.options.store instanceof RedisAdapter) {
|
|
78
|
+
const atomicResult = await this.options.store.verifyOtpAtomically({
|
|
79
|
+
otpKey,
|
|
80
|
+
attemptsKey,
|
|
81
|
+
providedHash: hashOtp(input.otp),
|
|
82
|
+
ttl: this.options.ttl,
|
|
83
|
+
maxAttempts: this.options.maxAttempts,
|
|
84
|
+
});
|
|
85
|
+
if (atomicResult === "verified") {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (atomicResult === "expired") {
|
|
89
|
+
throw new OTPExpiredError();
|
|
90
|
+
}
|
|
91
|
+
if (atomicResult === "max_attempts") {
|
|
92
|
+
throw new OTPMaxAttemptsExceededError();
|
|
93
|
+
}
|
|
94
|
+
if (atomicResult === "invalid") {
|
|
95
|
+
throw new OTPInvalidError();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
51
98
|
const storedHash = await this.options.store.get(otpKey);
|
|
52
99
|
if (!storedHash) {
|
|
53
100
|
throw new OTPExpiredError();
|