@arcote.tech/arc-auth 0.7.23 → 0.7.25
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/package.json +3 -3
- package/src/aggregates/account.ts +85 -5
- package/src/auth-builder.ts +4 -1
- package/src/index.ts +4 -0
- package/src/react/auth-provider.tsx +14 -2
- package/src/routes/logout-route.ts +28 -0
- package/src/utils/password-policy.test.ts +40 -0
- package/src/utils/password-policy.ts +33 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/arc-auth",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.25",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "Reusable authentication module for Arc framework — aggregate-based auth with factory pattern",
|
|
7
7
|
"main": "./src/index.ts",
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
"type-check": "tsc --noEmit"
|
|
11
11
|
},
|
|
12
12
|
"peerDependencies": {
|
|
13
|
-
"@arcote.tech/arc": "^0.7.
|
|
14
|
-
"@arcote.tech/platform": "^0.7.
|
|
13
|
+
"@arcote.tech/arc": "^0.7.25",
|
|
14
|
+
"@arcote.tech/platform": "^0.7.25",
|
|
15
15
|
"react": "^18.0.0 || ^19.0.0",
|
|
16
16
|
"typescript": "^5.0.0"
|
|
17
17
|
},
|
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
/// <reference path="../arc.d.ts" />
|
|
2
2
|
import {
|
|
3
3
|
aggregate,
|
|
4
|
+
array,
|
|
4
5
|
boolean,
|
|
5
6
|
date,
|
|
6
7
|
mergeUnsafe,
|
|
8
|
+
number,
|
|
7
9
|
string,
|
|
8
10
|
stringEnum,
|
|
9
11
|
type ArcRawShape,
|
|
10
12
|
} from "@arcote.tech/arc";
|
|
11
13
|
import type { AccountId } from "../ids/account";
|
|
12
14
|
import type { Token } from "../tokens/token";
|
|
15
|
+
import { validatePassword } from "../utils/password-policy";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Blokada konta przy brute-force — standard bezpieczeństwa frameworku. Po
|
|
19
|
+
* `MAX_FAILED_ATTEMPTS` nieudanych logowaniach konto jest czasowo zablokowane
|
|
20
|
+
* na `LOCK_MS`; udane logowanie resetuje licznik.
|
|
21
|
+
*/
|
|
22
|
+
const MAX_FAILED_ATTEMPTS = 5;
|
|
23
|
+
const LOCK_MS = 15 * 60_000; // 15 minut
|
|
13
24
|
|
|
14
25
|
/**
|
|
15
26
|
* Password helpers — server-only, tree-shaken on client.
|
|
@@ -71,6 +82,9 @@ export const createAccountAggregate = <
|
|
|
71
82
|
authMethod: string(),
|
|
72
83
|
registeredAt: date(),
|
|
73
84
|
lastSignedInAt: date().optional(),
|
|
85
|
+
// Brute-force lockout — licznik nieudanych logowań + czasowa blokada.
|
|
86
|
+
failedLoginCount: number(),
|
|
87
|
+
lockedUntil: date().optional(),
|
|
74
88
|
},
|
|
75
89
|
customFields,
|
|
76
90
|
),
|
|
@@ -97,6 +111,7 @@ export const createAccountAggregate = <
|
|
|
97
111
|
authMethod: "email",
|
|
98
112
|
registeredAt: event.createdAt,
|
|
99
113
|
lastSignedInAt: undefined,
|
|
114
|
+
failedLoginCount: 0,
|
|
100
115
|
...event.payload,
|
|
101
116
|
});
|
|
102
117
|
},
|
|
@@ -120,6 +135,7 @@ export const createAccountAggregate = <
|
|
|
120
135
|
authMethod: "oauth",
|
|
121
136
|
registeredAt: event.createdAt,
|
|
122
137
|
lastSignedInAt: event.createdAt,
|
|
138
|
+
failedLoginCount: 0,
|
|
123
139
|
...event.payload,
|
|
124
140
|
});
|
|
125
141
|
},
|
|
@@ -129,8 +145,26 @@ export const createAccountAggregate = <
|
|
|
129
145
|
"signedIn",
|
|
130
146
|
{ accountId, email: string().email() },
|
|
131
147
|
async (ctx, event) => {
|
|
148
|
+
// Udane logowanie resetuje licznik blokady.
|
|
132
149
|
await ctx.modify(event.payload.accountId, {
|
|
133
150
|
lastSignedInAt: event.createdAt,
|
|
151
|
+
failedLoginCount: 0,
|
|
152
|
+
lockedUntil: undefined,
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
.publicEvent(
|
|
158
|
+
"loginAttemptFailed",
|
|
159
|
+
{
|
|
160
|
+
accountId,
|
|
161
|
+
failedLoginCount: number(),
|
|
162
|
+
lockedUntil: date().optional(),
|
|
163
|
+
},
|
|
164
|
+
async (ctx, event) => {
|
|
165
|
+
await ctx.modify(event.payload.accountId, {
|
|
166
|
+
failedLoginCount: event.payload.failedLoginCount,
|
|
167
|
+
lockedUntil: event.payload.lockedUntil,
|
|
134
168
|
});
|
|
135
169
|
},
|
|
136
170
|
)
|
|
@@ -149,7 +183,10 @@ export const createAccountAggregate = <
|
|
|
149
183
|
mergeUnsafe(
|
|
150
184
|
{
|
|
151
185
|
email: string().email(),
|
|
152
|
-
|
|
186
|
+
// Twardy limit bcrypt 72 bajty; właściwa polityka (≥8 + złożoność)
|
|
187
|
+
// egzekwowana przez `validatePassword` w handlerze (jedno źródło
|
|
188
|
+
// prawdy z checklistą klienta).
|
|
189
|
+
password: string().minLength(1).maxLength(72),
|
|
153
190
|
},
|
|
154
191
|
customFields,
|
|
155
192
|
),
|
|
@@ -157,14 +194,19 @@ export const createAccountAggregate = <
|
|
|
157
194
|
.withResult(
|
|
158
195
|
{ accountId, token: string() },
|
|
159
196
|
{
|
|
160
|
-
error: stringEnum("EMAIL_ALREADY_TAKEN"),
|
|
161
|
-
accountId,
|
|
162
|
-
token: string(),
|
|
197
|
+
error: stringEnum("EMAIL_ALREADY_TAKEN", "WEAK_PASSWORD"),
|
|
198
|
+
accountId: accountId.optional(),
|
|
199
|
+
token: string().optional(),
|
|
163
200
|
},
|
|
164
201
|
)
|
|
165
202
|
.handle(
|
|
166
203
|
ONLY_SERVER &&
|
|
167
204
|
(async (ctx, params) => {
|
|
205
|
+
const policy = validatePassword(params.password);
|
|
206
|
+
if (!policy.ok) {
|
|
207
|
+
return { error: "WEAK_PASSWORD" as const };
|
|
208
|
+
}
|
|
209
|
+
|
|
168
210
|
const existing = await ctx.$query.findOne({
|
|
169
211
|
email: params.email,
|
|
170
212
|
});
|
|
@@ -199,8 +241,16 @@ export const createAccountAggregate = <
|
|
|
199
241
|
fn
|
|
200
242
|
.withParams({
|
|
201
243
|
email: string().email(),
|
|
202
|
-
password: string().minLength(
|
|
244
|
+
password: string().minLength(1).maxLength(72),
|
|
203
245
|
})
|
|
246
|
+
.withResult(
|
|
247
|
+
{ token: string() },
|
|
248
|
+
{
|
|
249
|
+
error: stringEnum("INVALID_EMAIL_OR_PASSWORD", "ACCOUNT_LOCKED"),
|
|
250
|
+
/** ISO timestamp do kiedy konto zablokowane (dla ACCOUNT_LOCKED). */
|
|
251
|
+
lockedUntil: string().optional(),
|
|
252
|
+
},
|
|
253
|
+
)
|
|
204
254
|
.handle(
|
|
205
255
|
ONLY_SERVER &&
|
|
206
256
|
(async (ctx, params) => {
|
|
@@ -212,11 +262,41 @@ export const createAccountAggregate = <
|
|
|
212
262
|
return { error: "INVALID_EMAIL_OR_PASSWORD" as const };
|
|
213
263
|
}
|
|
214
264
|
|
|
265
|
+
// Blokada czasowa — odrzuć przed weryfikacją hasła.
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
if (
|
|
268
|
+
account.lockedUntil &&
|
|
269
|
+
new Date(account.lockedUntil).getTime() > now
|
|
270
|
+
) {
|
|
271
|
+
return {
|
|
272
|
+
error: "ACCOUNT_LOCKED" as const,
|
|
273
|
+
lockedUntil: new Date(account.lockedUntil).toISOString(),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
215
277
|
const isValid = await verifyPassword(
|
|
216
278
|
params.password,
|
|
217
279
|
account.passwordHash!,
|
|
218
280
|
);
|
|
219
281
|
if (!isValid) {
|
|
282
|
+
const attempts = (account.failedLoginCount ?? 0) + 1;
|
|
283
|
+
const shouldLock = attempts >= MAX_FAILED_ATTEMPTS;
|
|
284
|
+
const lockedUntil = shouldLock
|
|
285
|
+
? new Date(now + LOCK_MS)
|
|
286
|
+
: undefined;
|
|
287
|
+
await ctx.loginAttemptFailed.emit({
|
|
288
|
+
accountId: account._id,
|
|
289
|
+
// Po zablokowaniu zerujemy licznik — po wygaśnięciu blokady
|
|
290
|
+
// użytkownik dostaje świeżą pulę prób.
|
|
291
|
+
failedLoginCount: shouldLock ? 0 : attempts,
|
|
292
|
+
lockedUntil,
|
|
293
|
+
});
|
|
294
|
+
if (shouldLock) {
|
|
295
|
+
return {
|
|
296
|
+
error: "ACCOUNT_LOCKED" as const,
|
|
297
|
+
lockedUntil: lockedUntil!.toISOString(),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
220
300
|
return { error: "INVALID_EMAIL_OR_PASSWORD" as const };
|
|
221
301
|
}
|
|
222
302
|
|
package/src/auth-builder.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { createAccountId } from "./ids/account";
|
|
|
12
12
|
import { createOAuthIdentityId } from "./ids/oauth-identity";
|
|
13
13
|
import { createToken } from "./tokens/token";
|
|
14
14
|
import { createOAuthRoutes } from "./routes/oauth-routes";
|
|
15
|
+
import { createLogoutRoute } from "./routes/logout-route";
|
|
15
16
|
import type { AccountId } from "./ids/account";
|
|
16
17
|
import type { OAuthProvidersConfig } from "./providers/types";
|
|
17
18
|
import type { Token } from "./tokens/token";
|
|
@@ -86,8 +87,10 @@ export class AuthBuilder<
|
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
build() {
|
|
90
|
+
// Logout route zawsze obecny (niezależnie od OAuth) — czyści cookie sesji.
|
|
91
|
+
const logout = createLogoutRoute();
|
|
89
92
|
return {
|
|
90
|
-
context: context(this.elements),
|
|
93
|
+
context: context([...this.elements, logout]),
|
|
91
94
|
accountId: this.accountId,
|
|
92
95
|
token: this.token,
|
|
93
96
|
Account: this.Account,
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,10 @@ export type { OAuthIdentityId } from "./ids/oauth-identity";
|
|
|
13
13
|
export { createToken } from "./tokens/token";
|
|
14
14
|
export type { Token } from "./tokens/token";
|
|
15
15
|
|
|
16
|
+
// Polityka haseł — jedno źródło prawdy dla checklisty klienta i walidacji serwerowej
|
|
17
|
+
export { validatePassword, passwordRequirements } from "./utils/password-policy";
|
|
18
|
+
export type { PasswordRule, PasswordValidation } from "./utils/password-policy";
|
|
19
|
+
|
|
16
20
|
// Provider types
|
|
17
21
|
export type {
|
|
18
22
|
OAuthProviderConfig,
|
|
@@ -13,7 +13,7 @@ export interface AuthContextType {
|
|
|
13
13
|
/** Resolves when the module loader has synced chunks for the new token —
|
|
14
14
|
* await before navigating to a token-gated route. */
|
|
15
15
|
setAccessToken: (token: string) => Promise<void>;
|
|
16
|
-
logout: () => void
|
|
16
|
+
logout: () => Promise<void>;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const AuthContext = createContext<AuthContextType | null>(null);
|
|
@@ -60,7 +60,19 @@ export function AuthProvider({ children, onTokenChange, initialToken }: AuthProv
|
|
|
60
60
|
await onTokenChange?.(newToken);
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
-
const logout = () => {
|
|
63
|
+
const logout = async (): Promise<void> => {
|
|
64
|
+
// Najpierw serwerowo wygaś cookie `arc_token` (HttpOnly — JS go nie ruszy);
|
|
65
|
+
// bez tego serwer używałby go jako fallback (m.in. upgrade /ws) i sesja
|
|
66
|
+
// trwałaby po wylogowaniu. Best-effort: błąd sieci nie blokuje czyszczenia
|
|
67
|
+
// stanu klienta, żeby UI był wylogowany.
|
|
68
|
+
try {
|
|
69
|
+
await fetch("/route/auth/logout", {
|
|
70
|
+
method: "POST",
|
|
71
|
+
credentials: "include",
|
|
72
|
+
});
|
|
73
|
+
} catch {
|
|
74
|
+
/* offline / serwer niedostępny — i tak czyścimy stan lokalny */
|
|
75
|
+
}
|
|
64
76
|
setToken(null);
|
|
65
77
|
onTokenChange?.(null); // scope.setToken(null) → adapter clears this scope
|
|
66
78
|
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { route } from "@arcote.tech/arc";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Publiczny route wylogowania — wygasza serwerowe cookie sesji `arc_token`
|
|
5
|
+
* (ustawiane przez OAuth callback, Max-Age 7 dni). Symetryczny do logowania:
|
|
6
|
+
* skoro OAuth ustawia cookie, logout musi je czyścić serwerowo — JS nie ruszy
|
|
7
|
+
* HttpOnly cookie, a serwer używa go jako fallback dla każdego requestu (w tym
|
|
8
|
+
* upgrade'u `/ws`), więc bez tego po wylogowaniu sesja trwała dalej.
|
|
9
|
+
*
|
|
10
|
+
* Publiczny (logout nie może wymagać auth). Czyszczenie nieistniejącego cookie
|
|
11
|
+
* (login hasłem) jest no-opem. Atrybuty muszą pokrywać się z ustawieniem
|
|
12
|
+
* (Path=/) żeby przeglądarka dopasowała cookie do usunięcia.
|
|
13
|
+
*/
|
|
14
|
+
export function createLogoutRoute() {
|
|
15
|
+
return route("authLogout")
|
|
16
|
+
.path("/auth/logout")
|
|
17
|
+
.public()
|
|
18
|
+
.handle({
|
|
19
|
+
POST: async () => {
|
|
20
|
+
const headers = new Headers();
|
|
21
|
+
headers.append(
|
|
22
|
+
"Set-Cookie",
|
|
23
|
+
"arc_token=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
|
|
24
|
+
);
|
|
25
|
+
return new Response(null, { status: 204, headers });
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { validatePassword } from "./password-policy";
|
|
3
|
+
|
|
4
|
+
describe("validatePassword", () => {
|
|
5
|
+
it("akceptuje hasło spełniające wszystkie reguły (cyfra)", () => {
|
|
6
|
+
expect(validatePassword("Haslo123")).toEqual({ ok: true, failed: [] });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("akceptuje wariant ze znakiem specjalnym zamiast cyfry", () => {
|
|
10
|
+
expect(validatePassword("Haslo!ab")).toEqual({ ok: true, failed: [] });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("za krótkie → failed length", () => {
|
|
14
|
+
expect(validatePassword("Ab1")).toEqual({ ok: false, failed: ["length"] });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("brak wielkiej litery", () => {
|
|
18
|
+
const r = validatePassword("haslo123");
|
|
19
|
+
expect(r.ok).toBe(false);
|
|
20
|
+
expect(r.failed).toContain("uppercase");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("brak małej litery", () => {
|
|
24
|
+
const r = validatePassword("HASLO123");
|
|
25
|
+
expect(r.ok).toBe(false);
|
|
26
|
+
expect(r.failed).toContain("lowercase");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("brak cyfry i znaku specjalnego", () => {
|
|
30
|
+
const r = validatePassword("HasloAbcd");
|
|
31
|
+
expect(r.ok).toBe(false);
|
|
32
|
+
expect(r.failed).toContain("digitOrSymbol");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("puste hasło → wszystkie reguły poza znakiem", () => {
|
|
36
|
+
const r = validatePassword("");
|
|
37
|
+
expect(r.ok).toBe(false);
|
|
38
|
+
expect(r.failed).toEqual(["length", "lowercase", "uppercase", "digitOrSymbol"]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polityka haseł — jedno źródło prawdy dla walidacji serwerowej (`register`) i
|
|
3
|
+
* checklisty po stronie klienta. Reguły są language-neutralne (`key` + `test`);
|
|
4
|
+
* etykiety (i18n) dostarcza konsument, mapując po `key`.
|
|
5
|
+
*
|
|
6
|
+
* Standard: ≥8 znaków, mała litera, wielka litera, oraz cyfra LUB znak specjalny.
|
|
7
|
+
*/
|
|
8
|
+
export interface PasswordRule {
|
|
9
|
+
/** Stabilny klucz reguły — konsument mapuje go na etykietę (i18n). */
|
|
10
|
+
key: "length" | "lowercase" | "uppercase" | "digitOrSymbol";
|
|
11
|
+
test: (password: string) => boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const passwordRequirements: readonly PasswordRule[] = [
|
|
15
|
+
{ key: "length", test: (p) => p.length >= 8 },
|
|
16
|
+
{ key: "lowercase", test: (p) => /[a-z]/.test(p) },
|
|
17
|
+
{ key: "uppercase", test: (p) => /[A-Z]/.test(p) },
|
|
18
|
+
{ key: "digitOrSymbol", test: (p) => /[0-9]/.test(p) || /[^A-Za-z0-9]/.test(p) },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export interface PasswordValidation {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
/** Klucze reguł, które NIE są spełnione. */
|
|
24
|
+
failed: PasswordRule["key"][];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Waliduje hasło względem polityki. `ok` gdy wszystkie reguły spełnione. */
|
|
28
|
+
export function validatePassword(password: string): PasswordValidation {
|
|
29
|
+
const failed = passwordRequirements
|
|
30
|
+
.filter((r) => !r.test(password))
|
|
31
|
+
.map((r) => r.key);
|
|
32
|
+
return { ok: failed.length === 0, failed };
|
|
33
|
+
}
|