@lacspace/lock 1.0.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/LICENSE +21 -0
- package/README.md +74 -0
- package/dist/index.cjs +77 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +66 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +73 -0
- package/dist/index.js.map +1 -0
- package/package.json +20 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lacspace
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# @lacspace/lock
|
|
4
|
+
|
|
5
|
+
**Account lockout & brute-force protection โ "server lock".**
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@lacspace/lock)
|
|
8
|
+
[](https://packagephobia.com/result?p=@lacspace/lock)
|
|
9
|
+
[](https://bundlephobia.com/package/@lacspace/lock)
|
|
10
|
+
[](https://www.npmjs.com/package/@lacspace/lock)
|
|
11
|
+
[](https://github.com/lacspace/npm-packages/blob/main/LICENSE)
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
> Stop credential-stuffing and brute force at the door. Track failed attempts per key (user, email, IP), lock after N strikes with **exponential backoff**, auto-expire the window, and reset on success. In-memory store built in; implement `LockStore` for Redis/Mongo.
|
|
16
|
+
|
|
17
|
+
- ๐ `record` / `check` / `reset` with a clear `LockStatus`
|
|
18
|
+
- ๐ Exponential backoff up to a cap ยท self-resetting window
|
|
19
|
+
- ๐ Pluggable `LockStore` (memory included)
|
|
20
|
+
- โก Zero dependencies ยท ๐ isomorphic ยท fully typed
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install @lacspace/lock
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { lockout } from "@lacspace/lock";
|
|
32
|
+
|
|
33
|
+
const guard = lockout({ maxAttempts: 5, baseDelayMs: 60_000, maxDelayMs: 3_600_000 });
|
|
34
|
+
|
|
35
|
+
// before checking the password
|
|
36
|
+
const status = await guard.check(email);
|
|
37
|
+
if (status.locked) throw new Error(`Too many attempts. Try again in ${Math.ceil(status.retryAfterMs / 1000)}s`);
|
|
38
|
+
|
|
39
|
+
if (await verifyPassword(input, stored)) {
|
|
40
|
+
await guard.reset(email); // success โ clear strikes
|
|
41
|
+
} else {
|
|
42
|
+
const s = await guard.record(email); // failure โ may lock
|
|
43
|
+
throw new Error(s.locked ? "Account temporarily locked." : `${s.remaining} attempts left`);
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## API
|
|
48
|
+
|
|
49
|
+
| Export | Description |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `lockout(opts?)` | `maxAttempts`, `baseDelayMs`, `maxDelayMs`, `windowMs`, `store` |
|
|
52
|
+
| `.check(key)` | status without recording |
|
|
53
|
+
| `.record(key)` | record a failure โ new status |
|
|
54
|
+
| `.reset(key)` | clear on success |
|
|
55
|
+
| `MemoryLockStore` / `LockStore` | storage (bring your own for Redis) |
|
|
56
|
+
|
|
57
|
+
`LockStatus` โ `{ locked, attempts, remaining, retryAfterMs }`.
|
|
58
|
+
|
|
59
|
+
## The Lacspace Security Kit
|
|
60
|
+
|
|
61
|
+
| Package | For |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| [`@lacspace/crypto`](https://www.npmjs.com/package/@lacspace/crypto) | AES encryption & hashing |
|
|
64
|
+
| [`@lacspace/password`](https://www.npmjs.com/package/@lacspace/password) | Password hashing |
|
|
65
|
+
| [`@lacspace/jwt`](https://www.npmjs.com/package/@lacspace/jwt) | JWTs & tokens |
|
|
66
|
+
| [`@lacspace/apikey`](https://www.npmjs.com/package/@lacspace/apikey) | API keys |
|
|
67
|
+
| [`@lacspace/otp`](https://www.npmjs.com/package/@lacspace/otp) | TOTP/HOTP 2FA |
|
|
68
|
+
| [`@lacspace/webauthn`](https://www.npmjs.com/package/@lacspace/webauthn) | Passkeys / biometric |
|
|
69
|
+
| [`@lacspace/mfa`](https://www.npmjs.com/package/@lacspace/mfa) | 2FA/3FA orchestration |
|
|
70
|
+
| **`@lacspace/lock`** | Account lockout (this package) |
|
|
71
|
+
| [`@lacspace/headers`](https://www.npmjs.com/package/@lacspace/headers) | Secure headers / CSP |
|
|
72
|
+
| [`@lacspace/redact`](https://www.npmjs.com/package/@lacspace/redact) | Log redaction |
|
|
73
|
+
|
|
74
|
+
<div align="center"><sub>Built with care by <a href="https://lacspace.com">Lacspace</a> ยท MIT licensed ยท <a href="https://github.com/lacspace/npm-packages">source</a></sub></div>
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
var MemoryLockStore = class {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.map = /* @__PURE__ */ new Map();
|
|
7
|
+
}
|
|
8
|
+
async get(key) {
|
|
9
|
+
return this.map.get(key);
|
|
10
|
+
}
|
|
11
|
+
async set(key, state) {
|
|
12
|
+
this.map.set(key, state);
|
|
13
|
+
}
|
|
14
|
+
async delete(key) {
|
|
15
|
+
this.map.delete(key);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
var Lockout = class {
|
|
19
|
+
constructor(opts = {}) {
|
|
20
|
+
this.store = opts.store ?? new MemoryLockStore();
|
|
21
|
+
this.maxAttempts = opts.maxAttempts ?? 5;
|
|
22
|
+
this.baseDelayMs = opts.baseDelayMs ?? 6e4;
|
|
23
|
+
this.maxDelayMs = opts.maxDelayMs ?? 36e5;
|
|
24
|
+
this.windowMs = opts.windowMs ?? 9e5;
|
|
25
|
+
}
|
|
26
|
+
lockDuration(attempts) {
|
|
27
|
+
const over = attempts - this.maxAttempts;
|
|
28
|
+
if (over < 1) return 0;
|
|
29
|
+
return Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** (over - 1));
|
|
30
|
+
}
|
|
31
|
+
status(state, now) {
|
|
32
|
+
if (!state) return { locked: false, attempts: 0, remaining: this.maxAttempts, retryAfterMs: 0 };
|
|
33
|
+
const locked = state.lockedUntil > now;
|
|
34
|
+
return {
|
|
35
|
+
locked,
|
|
36
|
+
attempts: state.attempts,
|
|
37
|
+
remaining: locked ? 0 : Math.max(0, this.maxAttempts - state.attempts),
|
|
38
|
+
retryAfterMs: locked ? state.lockedUntil - now : 0
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Current lock status without recording an attempt. */
|
|
42
|
+
async check(key) {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
let state = await this.store.get(key);
|
|
45
|
+
if (state && now - state.firstAttempt > this.windowMs && state.lockedUntil <= now) {
|
|
46
|
+
await this.store.delete(key);
|
|
47
|
+
state = void 0;
|
|
48
|
+
}
|
|
49
|
+
return this.status(state, now);
|
|
50
|
+
}
|
|
51
|
+
/** Record a failed attempt and return the new status. */
|
|
52
|
+
async record(key) {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
let state = await this.store.get(key);
|
|
55
|
+
if (!state || now - state.firstAttempt > this.windowMs && state.lockedUntil <= now) {
|
|
56
|
+
state = { attempts: 0, lockedUntil: 0, firstAttempt: now };
|
|
57
|
+
}
|
|
58
|
+
state.attempts += 1;
|
|
59
|
+
const dur = this.lockDuration(state.attempts);
|
|
60
|
+
if (dur > 0) state.lockedUntil = now + dur;
|
|
61
|
+
await this.store.set(key, state);
|
|
62
|
+
return this.status(state, now);
|
|
63
|
+
}
|
|
64
|
+
/** Clear all state for a key (call on successful auth). */
|
|
65
|
+
async reset(key) {
|
|
66
|
+
await this.store.delete(key);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
function lockout(opts) {
|
|
70
|
+
return new Lockout(opts);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
exports.Lockout = Lockout;
|
|
74
|
+
exports.MemoryLockStore = MemoryLockStore;
|
|
75
|
+
exports.lockout = lockout;
|
|
76
|
+
//# sourceMappingURL=index.cjs.map
|
|
77
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @lacspace/lock\n * Account lockout & brute-force protection (\"server lock\").\n *\n * Track failed attempts per key (user, IP, email), lock after N strikes with\n * exponential backoff, and reset on success. Pluggable store (in-memory built\n * in; implement `LockStore` for Redis/Mongo).\n *\n * Zero dependencies ยท isomorphic ยท fully typed.\n */\n\nexport interface AttemptState {\n attempts: number;\n /** Epoch ms the key is locked until (0 = not locked). */\n lockedUntil: number;\n /** Epoch ms of the first attempt in the current window. */\n firstAttempt: number;\n}\n\nexport interface LockStore {\n get(key: string): Promise<AttemptState | undefined>;\n set(key: string, state: AttemptState): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\nexport class MemoryLockStore implements LockStore {\n private map = new Map<string, AttemptState>();\n async get(key: string) {\n return this.map.get(key);\n }\n async set(key: string, state: AttemptState) {\n this.map.set(key, state);\n }\n async delete(key: string) {\n this.map.delete(key);\n }\n}\n\nexport interface LockoutOptions {\n /** Failed attempts allowed before locking. Default 5. */\n maxAttempts?: number;\n /** Base lock duration in ms once the threshold is crossed. Default 60000 (1 min). */\n baseDelayMs?: number;\n /** Maximum lock duration in ms. Default 3600000 (1 hour). */\n maxDelayMs?: number;\n /** Window in ms after which the attempt counter resets on its own. Default 900000 (15 min). */\n windowMs?: number;\n store?: LockStore;\n}\n\nexport interface LockStatus {\n locked: boolean;\n attempts: number;\n /** Attempts left before the next lock (0 when locked). */\n remaining: number;\n /** ms until unlock (0 when not locked). */\n retryAfterMs: number;\n}\n\nexport class Lockout {\n private store: LockStore;\n private maxAttempts: number;\n private baseDelayMs: number;\n private maxDelayMs: number;\n private windowMs: number;\n\n constructor(opts: LockoutOptions = {}) {\n this.store = opts.store ?? new MemoryLockStore();\n this.maxAttempts = opts.maxAttempts ?? 5;\n this.baseDelayMs = opts.baseDelayMs ?? 60000;\n this.maxDelayMs = opts.maxDelayMs ?? 3600000;\n this.windowMs = opts.windowMs ?? 900000;\n }\n\n private lockDuration(attempts: number): number {\n // maxAttempts failures are allowed; locking begins once they're exceeded.\n const over = attempts - this.maxAttempts;\n if (over < 1) return 0;\n return Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** (over - 1));\n }\n\n private status(state: AttemptState | undefined, now: number): LockStatus {\n if (!state) return { locked: false, attempts: 0, remaining: this.maxAttempts, retryAfterMs: 0 };\n const locked = state.lockedUntil > now;\n return {\n locked,\n attempts: state.attempts,\n remaining: locked ? 0 : Math.max(0, this.maxAttempts - state.attempts),\n retryAfterMs: locked ? state.lockedUntil - now : 0,\n };\n }\n\n /** Current lock status without recording an attempt. */\n async check(key: string): Promise<LockStatus> {\n const now = Date.now();\n let state = await this.store.get(key);\n if (state && now - state.firstAttempt > this.windowMs && state.lockedUntil <= now) {\n await this.store.delete(key);\n state = undefined;\n }\n return this.status(state, now);\n }\n\n /** Record a failed attempt and return the new status. */\n async record(key: string): Promise<LockStatus> {\n const now = Date.now();\n let state = await this.store.get(key);\n if (!state || (now - state.firstAttempt > this.windowMs && state.lockedUntil <= now)) {\n state = { attempts: 0, lockedUntil: 0, firstAttempt: now };\n }\n state.attempts += 1;\n const dur = this.lockDuration(state.attempts);\n if (dur > 0) state.lockedUntil = now + dur;\n await this.store.set(key, state);\n return this.status(state, now);\n }\n\n /** Clear all state for a key (call on successful auth). */\n async reset(key: string): Promise<void> {\n await this.store.delete(key);\n }\n}\n\nexport function lockout(opts?: LockoutOptions): Lockout {\n return new Lockout(opts);\n}\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lacspace/lock
|
|
3
|
+
* Account lockout & brute-force protection ("server lock").
|
|
4
|
+
*
|
|
5
|
+
* Track failed attempts per key (user, IP, email), lock after N strikes with
|
|
6
|
+
* exponential backoff, and reset on success. Pluggable store (in-memory built
|
|
7
|
+
* in; implement `LockStore` for Redis/Mongo).
|
|
8
|
+
*
|
|
9
|
+
* Zero dependencies ยท isomorphic ยท fully typed.
|
|
10
|
+
*/
|
|
11
|
+
interface AttemptState {
|
|
12
|
+
attempts: number;
|
|
13
|
+
/** Epoch ms the key is locked until (0 = not locked). */
|
|
14
|
+
lockedUntil: number;
|
|
15
|
+
/** Epoch ms of the first attempt in the current window. */
|
|
16
|
+
firstAttempt: number;
|
|
17
|
+
}
|
|
18
|
+
interface LockStore {
|
|
19
|
+
get(key: string): Promise<AttemptState | undefined>;
|
|
20
|
+
set(key: string, state: AttemptState): Promise<void>;
|
|
21
|
+
delete(key: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
declare class MemoryLockStore implements LockStore {
|
|
24
|
+
private map;
|
|
25
|
+
get(key: string): Promise<AttemptState | undefined>;
|
|
26
|
+
set(key: string, state: AttemptState): Promise<void>;
|
|
27
|
+
delete(key: string): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
interface LockoutOptions {
|
|
30
|
+
/** Failed attempts allowed before locking. Default 5. */
|
|
31
|
+
maxAttempts?: number;
|
|
32
|
+
/** Base lock duration in ms once the threshold is crossed. Default 60000 (1 min). */
|
|
33
|
+
baseDelayMs?: number;
|
|
34
|
+
/** Maximum lock duration in ms. Default 3600000 (1 hour). */
|
|
35
|
+
maxDelayMs?: number;
|
|
36
|
+
/** Window in ms after which the attempt counter resets on its own. Default 900000 (15 min). */
|
|
37
|
+
windowMs?: number;
|
|
38
|
+
store?: LockStore;
|
|
39
|
+
}
|
|
40
|
+
interface LockStatus {
|
|
41
|
+
locked: boolean;
|
|
42
|
+
attempts: number;
|
|
43
|
+
/** Attempts left before the next lock (0 when locked). */
|
|
44
|
+
remaining: number;
|
|
45
|
+
/** ms until unlock (0 when not locked). */
|
|
46
|
+
retryAfterMs: number;
|
|
47
|
+
}
|
|
48
|
+
declare class Lockout {
|
|
49
|
+
private store;
|
|
50
|
+
private maxAttempts;
|
|
51
|
+
private baseDelayMs;
|
|
52
|
+
private maxDelayMs;
|
|
53
|
+
private windowMs;
|
|
54
|
+
constructor(opts?: LockoutOptions);
|
|
55
|
+
private lockDuration;
|
|
56
|
+
private status;
|
|
57
|
+
/** Current lock status without recording an attempt. */
|
|
58
|
+
check(key: string): Promise<LockStatus>;
|
|
59
|
+
/** Record a failed attempt and return the new status. */
|
|
60
|
+
record(key: string): Promise<LockStatus>;
|
|
61
|
+
/** Clear all state for a key (call on successful auth). */
|
|
62
|
+
reset(key: string): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
declare function lockout(opts?: LockoutOptions): Lockout;
|
|
65
|
+
|
|
66
|
+
export { type AttemptState, type LockStatus, type LockStore, Lockout, type LockoutOptions, MemoryLockStore, lockout };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lacspace/lock
|
|
3
|
+
* Account lockout & brute-force protection ("server lock").
|
|
4
|
+
*
|
|
5
|
+
* Track failed attempts per key (user, IP, email), lock after N strikes with
|
|
6
|
+
* exponential backoff, and reset on success. Pluggable store (in-memory built
|
|
7
|
+
* in; implement `LockStore` for Redis/Mongo).
|
|
8
|
+
*
|
|
9
|
+
* Zero dependencies ยท isomorphic ยท fully typed.
|
|
10
|
+
*/
|
|
11
|
+
interface AttemptState {
|
|
12
|
+
attempts: number;
|
|
13
|
+
/** Epoch ms the key is locked until (0 = not locked). */
|
|
14
|
+
lockedUntil: number;
|
|
15
|
+
/** Epoch ms of the first attempt in the current window. */
|
|
16
|
+
firstAttempt: number;
|
|
17
|
+
}
|
|
18
|
+
interface LockStore {
|
|
19
|
+
get(key: string): Promise<AttemptState | undefined>;
|
|
20
|
+
set(key: string, state: AttemptState): Promise<void>;
|
|
21
|
+
delete(key: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
declare class MemoryLockStore implements LockStore {
|
|
24
|
+
private map;
|
|
25
|
+
get(key: string): Promise<AttemptState | undefined>;
|
|
26
|
+
set(key: string, state: AttemptState): Promise<void>;
|
|
27
|
+
delete(key: string): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
interface LockoutOptions {
|
|
30
|
+
/** Failed attempts allowed before locking. Default 5. */
|
|
31
|
+
maxAttempts?: number;
|
|
32
|
+
/** Base lock duration in ms once the threshold is crossed. Default 60000 (1 min). */
|
|
33
|
+
baseDelayMs?: number;
|
|
34
|
+
/** Maximum lock duration in ms. Default 3600000 (1 hour). */
|
|
35
|
+
maxDelayMs?: number;
|
|
36
|
+
/** Window in ms after which the attempt counter resets on its own. Default 900000 (15 min). */
|
|
37
|
+
windowMs?: number;
|
|
38
|
+
store?: LockStore;
|
|
39
|
+
}
|
|
40
|
+
interface LockStatus {
|
|
41
|
+
locked: boolean;
|
|
42
|
+
attempts: number;
|
|
43
|
+
/** Attempts left before the next lock (0 when locked). */
|
|
44
|
+
remaining: number;
|
|
45
|
+
/** ms until unlock (0 when not locked). */
|
|
46
|
+
retryAfterMs: number;
|
|
47
|
+
}
|
|
48
|
+
declare class Lockout {
|
|
49
|
+
private store;
|
|
50
|
+
private maxAttempts;
|
|
51
|
+
private baseDelayMs;
|
|
52
|
+
private maxDelayMs;
|
|
53
|
+
private windowMs;
|
|
54
|
+
constructor(opts?: LockoutOptions);
|
|
55
|
+
private lockDuration;
|
|
56
|
+
private status;
|
|
57
|
+
/** Current lock status without recording an attempt. */
|
|
58
|
+
check(key: string): Promise<LockStatus>;
|
|
59
|
+
/** Record a failed attempt and return the new status. */
|
|
60
|
+
record(key: string): Promise<LockStatus>;
|
|
61
|
+
/** Clear all state for a key (call on successful auth). */
|
|
62
|
+
reset(key: string): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
declare function lockout(opts?: LockoutOptions): Lockout;
|
|
65
|
+
|
|
66
|
+
export { type AttemptState, type LockStatus, type LockStore, Lockout, type LockoutOptions, MemoryLockStore, lockout };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var MemoryLockStore = class {
|
|
3
|
+
constructor() {
|
|
4
|
+
this.map = /* @__PURE__ */ new Map();
|
|
5
|
+
}
|
|
6
|
+
async get(key) {
|
|
7
|
+
return this.map.get(key);
|
|
8
|
+
}
|
|
9
|
+
async set(key, state) {
|
|
10
|
+
this.map.set(key, state);
|
|
11
|
+
}
|
|
12
|
+
async delete(key) {
|
|
13
|
+
this.map.delete(key);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
var Lockout = class {
|
|
17
|
+
constructor(opts = {}) {
|
|
18
|
+
this.store = opts.store ?? new MemoryLockStore();
|
|
19
|
+
this.maxAttempts = opts.maxAttempts ?? 5;
|
|
20
|
+
this.baseDelayMs = opts.baseDelayMs ?? 6e4;
|
|
21
|
+
this.maxDelayMs = opts.maxDelayMs ?? 36e5;
|
|
22
|
+
this.windowMs = opts.windowMs ?? 9e5;
|
|
23
|
+
}
|
|
24
|
+
lockDuration(attempts) {
|
|
25
|
+
const over = attempts - this.maxAttempts;
|
|
26
|
+
if (over < 1) return 0;
|
|
27
|
+
return Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** (over - 1));
|
|
28
|
+
}
|
|
29
|
+
status(state, now) {
|
|
30
|
+
if (!state) return { locked: false, attempts: 0, remaining: this.maxAttempts, retryAfterMs: 0 };
|
|
31
|
+
const locked = state.lockedUntil > now;
|
|
32
|
+
return {
|
|
33
|
+
locked,
|
|
34
|
+
attempts: state.attempts,
|
|
35
|
+
remaining: locked ? 0 : Math.max(0, this.maxAttempts - state.attempts),
|
|
36
|
+
retryAfterMs: locked ? state.lockedUntil - now : 0
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Current lock status without recording an attempt. */
|
|
40
|
+
async check(key) {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
let state = await this.store.get(key);
|
|
43
|
+
if (state && now - state.firstAttempt > this.windowMs && state.lockedUntil <= now) {
|
|
44
|
+
await this.store.delete(key);
|
|
45
|
+
state = void 0;
|
|
46
|
+
}
|
|
47
|
+
return this.status(state, now);
|
|
48
|
+
}
|
|
49
|
+
/** Record a failed attempt and return the new status. */
|
|
50
|
+
async record(key) {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
let state = await this.store.get(key);
|
|
53
|
+
if (!state || now - state.firstAttempt > this.windowMs && state.lockedUntil <= now) {
|
|
54
|
+
state = { attempts: 0, lockedUntil: 0, firstAttempt: now };
|
|
55
|
+
}
|
|
56
|
+
state.attempts += 1;
|
|
57
|
+
const dur = this.lockDuration(state.attempts);
|
|
58
|
+
if (dur > 0) state.lockedUntil = now + dur;
|
|
59
|
+
await this.store.set(key, state);
|
|
60
|
+
return this.status(state, now);
|
|
61
|
+
}
|
|
62
|
+
/** Clear all state for a key (call on successful auth). */
|
|
63
|
+
async reset(key) {
|
|
64
|
+
await this.store.delete(key);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
function lockout(opts) {
|
|
68
|
+
return new Lockout(opts);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export { Lockout, MemoryLockStore, lockout };
|
|
72
|
+
//# sourceMappingURL=index.js.map
|
|
73
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @lacspace/lock\n * Account lockout & brute-force protection (\"server lock\").\n *\n * Track failed attempts per key (user, IP, email), lock after N strikes with\n * exponential backoff, and reset on success. Pluggable store (in-memory built\n * in; implement `LockStore` for Redis/Mongo).\n *\n * Zero dependencies ยท isomorphic ยท fully typed.\n */\n\nexport interface AttemptState {\n attempts: number;\n /** Epoch ms the key is locked until (0 = not locked). */\n lockedUntil: number;\n /** Epoch ms of the first attempt in the current window. */\n firstAttempt: number;\n}\n\nexport interface LockStore {\n get(key: string): Promise<AttemptState | undefined>;\n set(key: string, state: AttemptState): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\nexport class MemoryLockStore implements LockStore {\n private map = new Map<string, AttemptState>();\n async get(key: string) {\n return this.map.get(key);\n }\n async set(key: string, state: AttemptState) {\n this.map.set(key, state);\n }\n async delete(key: string) {\n this.map.delete(key);\n }\n}\n\nexport interface LockoutOptions {\n /** Failed attempts allowed before locking. Default 5. */\n maxAttempts?: number;\n /** Base lock duration in ms once the threshold is crossed. Default 60000 (1 min). */\n baseDelayMs?: number;\n /** Maximum lock duration in ms. Default 3600000 (1 hour). */\n maxDelayMs?: number;\n /** Window in ms after which the attempt counter resets on its own. Default 900000 (15 min). */\n windowMs?: number;\n store?: LockStore;\n}\n\nexport interface LockStatus {\n locked: boolean;\n attempts: number;\n /** Attempts left before the next lock (0 when locked). */\n remaining: number;\n /** ms until unlock (0 when not locked). */\n retryAfterMs: number;\n}\n\nexport class Lockout {\n private store: LockStore;\n private maxAttempts: number;\n private baseDelayMs: number;\n private maxDelayMs: number;\n private windowMs: number;\n\n constructor(opts: LockoutOptions = {}) {\n this.store = opts.store ?? new MemoryLockStore();\n this.maxAttempts = opts.maxAttempts ?? 5;\n this.baseDelayMs = opts.baseDelayMs ?? 60000;\n this.maxDelayMs = opts.maxDelayMs ?? 3600000;\n this.windowMs = opts.windowMs ?? 900000;\n }\n\n private lockDuration(attempts: number): number {\n // maxAttempts failures are allowed; locking begins once they're exceeded.\n const over = attempts - this.maxAttempts;\n if (over < 1) return 0;\n return Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** (over - 1));\n }\n\n private status(state: AttemptState | undefined, now: number): LockStatus {\n if (!state) return { locked: false, attempts: 0, remaining: this.maxAttempts, retryAfterMs: 0 };\n const locked = state.lockedUntil > now;\n return {\n locked,\n attempts: state.attempts,\n remaining: locked ? 0 : Math.max(0, this.maxAttempts - state.attempts),\n retryAfterMs: locked ? state.lockedUntil - now : 0,\n };\n }\n\n /** Current lock status without recording an attempt. */\n async check(key: string): Promise<LockStatus> {\n const now = Date.now();\n let state = await this.store.get(key);\n if (state && now - state.firstAttempt > this.windowMs && state.lockedUntil <= now) {\n await this.store.delete(key);\n state = undefined;\n }\n return this.status(state, now);\n }\n\n /** Record a failed attempt and return the new status. */\n async record(key: string): Promise<LockStatus> {\n const now = Date.now();\n let state = await this.store.get(key);\n if (!state || (now - state.firstAttempt > this.windowMs && state.lockedUntil <= now)) {\n state = { attempts: 0, lockedUntil: 0, firstAttempt: now };\n }\n state.attempts += 1;\n const dur = this.lockDuration(state.attempts);\n if (dur > 0) state.lockedUntil = now + dur;\n await this.store.set(key, state);\n return this.status(state, now);\n }\n\n /** Clear all state for a key (call on successful auth). */\n async reset(key: string): Promise<void> {\n await this.store.delete(key);\n }\n}\n\nexport function lockout(opts?: LockoutOptions): Lockout {\n return new Lockout(opts);\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lacspace/lock",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Account lockout & brute-force protection (server lock) โ N-strikes, exponential backoff, self-resetting window, pluggable store. Zero-dependency, isomorphic.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": { ".": { "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } } },
|
|
10
|
+
"files": ["dist"],
|
|
11
|
+
"sideEffects": false,
|
|
12
|
+
"scripts": { "build": "tsup", "prepublishOnly": "npm run build" },
|
|
13
|
+
"keywords": ["account-lockout", "brute-force", "login-security", "lockout", "backoff", "auth", "security", "typescript"],
|
|
14
|
+
"author": "Lacspace <contact@lacspace.com>",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"homepage": "https://lacspace.com/packages",
|
|
17
|
+
"repository": { "type": "git", "url": "git+https://github.com/lacspace/npm-packages.git", "directory": "lock" },
|
|
18
|
+
"bugs": { "url": "https://github.com/lacspace/npm-packages/issues" },
|
|
19
|
+
"engines": { "node": ">=18" }
|
|
20
|
+
}
|