@quark-fw/plugin-auth 0.0.0-stage → 0.1.27
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 +44 -2
- package/package.json +49 -5
- package/src/client.tsx +88 -0
- package/src/createRateLimiter.test.ts +60 -0
- package/src/createRateLimiter.ts +62 -0
- package/src/db.ts +30 -0
- package/src/enforceRateLimit.test.ts +36 -0
- package/src/enforceRateLimit.ts +32 -0
- package/src/global.d.ts +24 -0
- package/src/helpers.ts +42 -0
- package/src/index.ts +87 -0
- package/src/router.ts +206 -0
- package/src/sessionStore.ts +69 -0
- package/src/types.ts +16 -0
package/README.md
CHANGED
|
@@ -1,3 +1,45 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @quark-fw/plugin-auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Email + password authentication for Quark apps: passport-local behind a Redis-backed `express-session`, a tRPC router (`register`, `login`, `logout`, `me`, `updateProfile`, `changePassword`), a `useAuth()` hook for the React side and Redis rate limits on login and registration.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i @quark-fw/plugin-auth
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peers: `@quark-fw/core`, `@quark-fw/plugin-redis`, `express`, `react`. `@quark-fw/plugin-trpc` is a dependency (the router is built on its `publicProcedure`). At runtime the plugin declares `dependsOn: ["redis", "prisma"]`: sessions live in Redis, users in the database, so `@quark-fw/plugin-prisma` must be registered too, and the app must have a `User` model with `id`, `email`, `name`, `hashedPassword` reachable as `quark.db.orm.public.User`.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// src/index.ts — redis, prisma and trpc are registered before auth
|
|
17
|
+
import "@quark-fw/plugin-redis";
|
|
18
|
+
import "@quark-fw/plugin-prisma";
|
|
19
|
+
import "@quark-fw/plugin-trpc";
|
|
20
|
+
import "@quark-fw/plugin-auth"; // provides quark.auth, session + passport middleware
|
|
21
|
+
|
|
22
|
+
// src/router/index.ts — mount the router under `auth`
|
|
23
|
+
import { authRouter } from "@quark-fw/plugin-auth/src/router.js";
|
|
24
|
+
export const appRouter = router({ auth: authRouter, /* ... */ });
|
|
25
|
+
|
|
26
|
+
// a procedure that requires a logged-in user (ctx.user is typed)
|
|
27
|
+
import { userProcedure } from "@quark-fw/plugin-auth/src/router.js";
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
// src/frontend/Pages/Main/Page.tsx
|
|
32
|
+
import { useAuth } from "@quark-fw/plugin-auth/src/client.js";
|
|
33
|
+
|
|
34
|
+
const auth = useAuth(); // { user, isAuthenticated, login, register, logout, updateProfile, changePassword, refresh }
|
|
35
|
+
if (!auth.isAuthenticated) return <AuthForm />;
|
|
36
|
+
await auth.login(email, password);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
How it works: the plugin registers `express-session` (store `quark:sess:<sid>` in Redis, 30-day TTL) and passport as pre-middleware, so every later handler (`/api/trpc`, SSR) sees `req.user`. The session cookie is `quark.sid`: `httpOnly`, `sameSite: "lax"`, 30 days, `secure: "auto"` (secure only on https; behind a TLS proxy set `TRUST_PROXY` for `@quark-fw/plugin-react`). Only the user id is stored in the session; the profile is reloaded on each request and `hashedPassword` is stripped before it leaves the server. Passwords are hashed with async bcryptjs (`quark.auth.hashPassword` / `quark.auth.checkPassword`), and `login` compares against a dummy hash when the email is unknown so response time does not reveal whether an account exists. `me` is read through the store (`store.tiny.auth.me`), so on SSR the user arrives together with the HTML.
|
|
40
|
+
|
|
41
|
+
Rate limits (`src/router.ts`), fixed 15-minute windows counted in Redis and checked before bcrypt and the DB query: `login` — 30 attempts per IP and 10 per email (lowercased); `register` — 10 per IP. Exceeding one answers `TOO_MANY_REQUESTS` with the minutes until reset. If Redis fails, the check is logged and skipped — the limiter protects against brute force but is not a precondition for logging in.
|
|
42
|
+
|
|
43
|
+
Env: `SESSION_SECRET` — cookie signing secret; required when `NODE_ENV=production` (the plugin throws on start without it), a fixed dev secret is used otherwise. `BCRYPT_ROUNDS` (default 12).
|
|
44
|
+
|
|
45
|
+
Part of the [Quark framework](https://www.npmjs.com/package/@quark-fw/core).
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@quark-fw/plugin-auth",
|
|
3
|
+
"version": "0.1.27",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"types": "src/index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "echo raw-src package, no build",
|
|
9
|
+
"test": "vitest run"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"quark",
|
|
16
|
+
"auth",
|
|
17
|
+
"passport",
|
|
18
|
+
"session"
|
|
19
|
+
],
|
|
20
|
+
"author": "",
|
|
21
|
+
"license": "ISC",
|
|
22
|
+
"description": "Email/password auth for the Quark framework: passport-local, Redis sessions, tRPC auth router with rate limits (published as raw TS sources; consumers run tsx)",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@quark-fw/plugin": "0.1.27",
|
|
28
|
+
"@quark-fw/plugin-trpc": "0.1.27",
|
|
29
|
+
"@trpc/server": "^11.0.0",
|
|
30
|
+
"bcryptjs": "^3.0.2",
|
|
31
|
+
"express-session": "^1.18.1",
|
|
32
|
+
"ioredis": "^5.9.3",
|
|
33
|
+
"passport": "^0.7.0",
|
|
34
|
+
"passport-local": "^1.0.0",
|
|
35
|
+
"react": "^19.2.4",
|
|
36
|
+
"zod": "^4.0.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@quark-fw/core": "0.1.27",
|
|
40
|
+
"@quark-fw/plugin-redis": "0.1.27",
|
|
41
|
+
"express": "^5.0.0",
|
|
42
|
+
"react": "^19.2.4"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/express-session": "^1.18.1",
|
|
46
|
+
"@types/passport": "^1.0.17",
|
|
47
|
+
"@types/passport-local": "^1.0.38",
|
|
48
|
+
"vitest": "^5.0.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/client.tsx
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Клиентская часть auth: хук поверх @quark-fw/store.
|
|
2
|
+
// Не импортировать серверные модули плагина — только типы.
|
|
3
|
+
import { useStore, useTrpc } from "@quark-fw/store";
|
|
4
|
+
import type { AuthUser } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export type SessionUser = Omit<AuthUser, "hashedPassword">;
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* Ветка auth-роутера описана структурно (как TinyBranch в entity/client):
|
|
10
|
+
* AppRouter аугментируется приложением, и без этих типов standalone-тайпчек
|
|
11
|
+
* плагина не видит процедур.
|
|
12
|
+
*/
|
|
13
|
+
type AuthTinyBranch = {
|
|
14
|
+
auth: { me: { get(): unknown; clearCache(): void } };
|
|
15
|
+
};
|
|
16
|
+
type AuthTrpcBranch = {
|
|
17
|
+
auth: {
|
|
18
|
+
login: {
|
|
19
|
+
mutate(input: {
|
|
20
|
+
email: string;
|
|
21
|
+
password: string;
|
|
22
|
+
}): Promise<unknown>;
|
|
23
|
+
};
|
|
24
|
+
register: {
|
|
25
|
+
mutate(input: {
|
|
26
|
+
email: string;
|
|
27
|
+
password: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
}): Promise<unknown>;
|
|
30
|
+
};
|
|
31
|
+
logout: { mutate(): Promise<unknown> };
|
|
32
|
+
updateProfile: {
|
|
33
|
+
mutate(input: { name?: string; email?: string }): Promise<unknown>;
|
|
34
|
+
};
|
|
35
|
+
changePassword: {
|
|
36
|
+
mutate(input: {
|
|
37
|
+
currentPassword: string;
|
|
38
|
+
newPassword: string;
|
|
39
|
+
}): Promise<unknown>;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function useAuth() {
|
|
45
|
+
const store = useStore();
|
|
46
|
+
const trpc = useTrpc() as unknown as AuthTrpcBranch;
|
|
47
|
+
const tiny = (store.tiny as unknown as AuthTinyBranch).auth;
|
|
48
|
+
|
|
49
|
+
// Suspense-чтение: на SSR приходит вместе с HTML
|
|
50
|
+
const user = tiny.me.get() as SessionUser | null;
|
|
51
|
+
|
|
52
|
+
const refresh = () => {
|
|
53
|
+
tiny.me.clearCache();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
user,
|
|
58
|
+
isAuthenticated: !!user,
|
|
59
|
+
login: async (email: string, password: string) => {
|
|
60
|
+
const logged = await trpc.auth.login.mutate({ email, password });
|
|
61
|
+
refresh();
|
|
62
|
+
return logged as SessionUser;
|
|
63
|
+
},
|
|
64
|
+
register: async (input: {
|
|
65
|
+
email: string;
|
|
66
|
+
password: string;
|
|
67
|
+
name?: string;
|
|
68
|
+
}) => {
|
|
69
|
+
const created = await trpc.auth.register.mutate(input);
|
|
70
|
+
refresh();
|
|
71
|
+
return created as SessionUser;
|
|
72
|
+
},
|
|
73
|
+
logout: async () => {
|
|
74
|
+
await trpc.auth.logout.mutate();
|
|
75
|
+
refresh();
|
|
76
|
+
},
|
|
77
|
+
updateProfile: async (input: { name?: string; email?: string }) => {
|
|
78
|
+
const updated = await trpc.auth.updateProfile.mutate(input);
|
|
79
|
+
refresh();
|
|
80
|
+
return updated as SessionUser;
|
|
81
|
+
},
|
|
82
|
+
changePassword: (input: {
|
|
83
|
+
currentPassword: string;
|
|
84
|
+
newPassword: string;
|
|
85
|
+
}) => trpc.auth.changePassword.mutate(input),
|
|
86
|
+
refresh,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createRateLimiter,
|
|
4
|
+
type RateLimitClient,
|
|
5
|
+
} from "./createRateLimiter.js";
|
|
6
|
+
|
|
7
|
+
// Redis в памяти с семантикой INCR/EXPIRE/TTL скрипта лимитера и часами,
|
|
8
|
+
// которые тест двигает сам
|
|
9
|
+
const fakeRedis = () => {
|
|
10
|
+
let now = 0;
|
|
11
|
+
const keys = new Map<string, { n: number; expiresAt: number }>();
|
|
12
|
+
const client: RateLimitClient = {
|
|
13
|
+
async eval(_script, _numKeys, key, ttlSec) {
|
|
14
|
+
const live = keys.get(key);
|
|
15
|
+
const entry =
|
|
16
|
+
live && live.expiresAt > now
|
|
17
|
+
? live
|
|
18
|
+
: { n: 0, expiresAt: now + Number(ttlSec) * 1000 };
|
|
19
|
+
entry.n += 1;
|
|
20
|
+
keys.set(key, entry);
|
|
21
|
+
return [entry.n, Math.ceil((entry.expiresAt - now) / 1000)];
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
return { client, advance: (ms: number) => (now += ms) };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
test("до max включительно разрешено, дальше — нет, с времени до сброса", async () => {
|
|
28
|
+
const redis = fakeRedis();
|
|
29
|
+
const limiter = createRateLimiter({
|
|
30
|
+
client: () => redis.client,
|
|
31
|
+
prefix: "t:",
|
|
32
|
+
max: 3,
|
|
33
|
+
windowSec: 60,
|
|
34
|
+
});
|
|
35
|
+
for (let i = 1; i <= 3; i++) {
|
|
36
|
+
expect(await limiter.hit("a")).toMatchObject({
|
|
37
|
+
allowed: true,
|
|
38
|
+
count: i,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const blocked = await limiter.hit("a");
|
|
42
|
+
expect(blocked.allowed).toBe(false);
|
|
43
|
+
expect(blocked.count).toBe(4);
|
|
44
|
+
expect(blocked.retryAfterSec).toBe(60);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("ключи независимы, окно сбрасывается по TTL", async () => {
|
|
48
|
+
const redis = fakeRedis();
|
|
49
|
+
const limiter = createRateLimiter({
|
|
50
|
+
client: () => redis.client,
|
|
51
|
+
prefix: "t:",
|
|
52
|
+
max: 1,
|
|
53
|
+
windowSec: 10,
|
|
54
|
+
});
|
|
55
|
+
expect((await limiter.hit("a")).allowed).toBe(true);
|
|
56
|
+
expect((await limiter.hit("b")).allowed).toBe(true);
|
|
57
|
+
expect((await limiter.hit("a")).allowed).toBe(false);
|
|
58
|
+
redis.advance(10_001);
|
|
59
|
+
expect((await limiter.hit("a")).allowed).toBe(true);
|
|
60
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export type RateLimitClient = {
|
|
2
|
+
eval(
|
|
3
|
+
script: string,
|
|
4
|
+
numKeys: number,
|
|
5
|
+
key: string,
|
|
6
|
+
ttlSec: string,
|
|
7
|
+
): Promise<unknown>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type RateLimiterConfig = {
|
|
11
|
+
/** клиент берётся лениво: quark.redis.client — атом, его могут заменить */
|
|
12
|
+
client: () => RateLimitClient;
|
|
13
|
+
/** префикс ключей в Redis, например "quark:auth:login:ip:" */
|
|
14
|
+
prefix: string;
|
|
15
|
+
/** попыток за окно */
|
|
16
|
+
max: number;
|
|
17
|
+
windowSec: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type RateLimitResult = {
|
|
21
|
+
allowed: boolean;
|
|
22
|
+
count: number;
|
|
23
|
+
retryAfterSec: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// INCR и EXPIRE одним скриптом: раздельные команды при падении между ними
|
|
27
|
+
// оставляли ключ без TTL — вечную блокировку. TTL возвращаем сразу, чтобы
|
|
28
|
+
// не ходить за ним вторым запросом.
|
|
29
|
+
const SCRIPT = `
|
|
30
|
+
local n = redis.call('INCR', KEYS[1])
|
|
31
|
+
if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
|
|
32
|
+
return {n, redis.call('TTL', KEYS[1])}
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Счётчик попыток в фиксированном окне поверх Redis. Один ключ — одна
|
|
37
|
+
* сущность (IP, email); лимит превышен, когда счётчик за окно больше max.
|
|
38
|
+
*/
|
|
39
|
+
export function createRateLimiter({
|
|
40
|
+
client,
|
|
41
|
+
prefix,
|
|
42
|
+
max,
|
|
43
|
+
windowSec,
|
|
44
|
+
}: RateLimiterConfig) {
|
|
45
|
+
return {
|
|
46
|
+
async hit(key: string): Promise<RateLimitResult> {
|
|
47
|
+
const [count, ttl] = (await client().eval(
|
|
48
|
+
SCRIPT,
|
|
49
|
+
1,
|
|
50
|
+
prefix + key,
|
|
51
|
+
String(windowSec),
|
|
52
|
+
)) as [number, number];
|
|
53
|
+
return {
|
|
54
|
+
allowed: count <= max,
|
|
55
|
+
count,
|
|
56
|
+
retryAfterSec: ttl > 0 ? ttl : windowSec,
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type RateLimiter = ReturnType<typeof createRateLimiter>;
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AuthUser } from "./types.js";
|
|
2
|
+
|
|
3
|
+
// quark.db типизирует ПРИЛОЖЕНИЕ (плагин не знает его схему), поэтому здесь
|
|
4
|
+
// описан минимальный структурный контракт: модель User с полями AuthUser.
|
|
5
|
+
type UserModel = {
|
|
6
|
+
where(where: Record<string, unknown>): {
|
|
7
|
+
first(): Promise<AuthUser | null>;
|
|
8
|
+
update(data: Record<string, unknown>): Promise<AuthUser>;
|
|
9
|
+
};
|
|
10
|
+
create(data: Record<string, unknown>): Promise<AuthUser>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Модель пользователя из контракта приложения.
|
|
15
|
+
*
|
|
16
|
+
* Тип quark.db объявляет само приложение, поэтому плагин описывает только ту
|
|
17
|
+
* форму, на которую опирается.
|
|
18
|
+
*/
|
|
19
|
+
export const userModel = (): UserModel => {
|
|
20
|
+
const { db } = quark as Quark & {
|
|
21
|
+
db?: { orm?: { public?: { User?: UserModel } } };
|
|
22
|
+
};
|
|
23
|
+
const model = db?.orm?.public?.User;
|
|
24
|
+
if (!model) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"@quark-fw/plugin-auth: модель User недоступна — не подключён @quark-fw/plugin-prisma?",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return model;
|
|
30
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { TRPCError } from "@trpc/server";
|
|
3
|
+
import type { RateLimiter } from "./createRateLimiter.js";
|
|
4
|
+
import { enforceRateLimit } from "./enforceRateLimit.js";
|
|
5
|
+
|
|
6
|
+
const limiter = (allowed: boolean, retryAfterSec = 600): RateLimiter => ({
|
|
7
|
+
hit: async () => ({ allowed, count: 0, retryAfterSec }),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("все лимиты в норме — проходит", async () => {
|
|
11
|
+
await expect(
|
|
12
|
+
enforceRateLimit([
|
|
13
|
+
[limiter(true), "ip"],
|
|
14
|
+
[limiter(true), "email"],
|
|
15
|
+
]),
|
|
16
|
+
).resolves.toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("превышенный лимит — TOO_MANY_REQUESTS с минутами до сброса", async () => {
|
|
20
|
+
const error = await enforceRateLimit([
|
|
21
|
+
[limiter(true), "ip"],
|
|
22
|
+
[limiter(false, 601), "email"],
|
|
23
|
+
]).catch((e: unknown) => e);
|
|
24
|
+
expect(error).toBeInstanceOf(TRPCError);
|
|
25
|
+
expect((error as TRPCError).code).toBe("TOO_MANY_REQUESTS");
|
|
26
|
+
expect((error as TRPCError).message).toContain("11 мин");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("сбой Redis не блокирует вход", async () => {
|
|
30
|
+
const broken: RateLimiter = {
|
|
31
|
+
hit: async () => {
|
|
32
|
+
throw new Error("redis down");
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
await expect(enforceRateLimit([[broken, "ip"]])).resolves.toBeUndefined();
|
|
36
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { TRPCError } from "@trpc/server";
|
|
2
|
+
import { log as rootLog } from "@quark-fw/core/src/log.js";
|
|
3
|
+
import type { RateLimiter } from "./createRateLimiter.js";
|
|
4
|
+
|
|
5
|
+
const log = rootLog.child({ scope: "auth" });
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Проверяет лимиты до тяжёлой работы процедуры (bcrypt ~240 мс CPU,
|
|
9
|
+
* запрос в базу). Первый превышенный — TOO_MANY_REQUESTS.
|
|
10
|
+
*
|
|
11
|
+
* Сбой Redis вход не блокирует: лимитер — защита от перебора, а не условие
|
|
12
|
+
* работы; сам сбой пишется в лог.
|
|
13
|
+
*/
|
|
14
|
+
export async function enforceRateLimit(
|
|
15
|
+
checks: [limiter: RateLimiter, key: string][],
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
for (const [limiter, key] of checks) {
|
|
18
|
+
let result;
|
|
19
|
+
try {
|
|
20
|
+
result = await limiter.hit(key);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
log.error("rate limit check failed", { err: error });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (!result.allowed) {
|
|
26
|
+
throw new TRPCError({
|
|
27
|
+
code: "TOO_MANY_REQUESTS",
|
|
28
|
+
message: `Слишком много попыток, повторите через ${Math.ceil(result.retryAfterSec / 60)} мин`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import "@quark-fw/core/src/global.d.ts";
|
|
2
|
+
import type { AuthUser } from "@quark-fw/plugin-auth/src/types.js";
|
|
3
|
+
|
|
4
|
+
export {};
|
|
5
|
+
|
|
6
|
+
// заполняем шов пользователя в tRPC-контексте
|
|
7
|
+
declare module "@quark-fw/plugin-trpc/src/trpc.js" {
|
|
8
|
+
// шов: интерфейс существует ровно ради этой аугментации
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
10
|
+
interface TRPCUser extends Omit<AuthUser, "hashedPassword"> {}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
declare global {
|
|
14
|
+
interface Quark {
|
|
15
|
+
auth: {
|
|
16
|
+
/** хеширование и проверка пароля (bcrypt, не блокируя event loop) */
|
|
17
|
+
hashPassword(password: string): Promise<string>;
|
|
18
|
+
checkPassword(
|
|
19
|
+
hash: string | null | undefined,
|
|
20
|
+
password: string,
|
|
21
|
+
): Promise<boolean>;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Хелперы auth-плагина: пароли, поиск пользователей, адрес клиента.
|
|
2
|
+
import bcrypt from "bcryptjs";
|
|
3
|
+
import type { Request } from "express";
|
|
4
|
+
import { userModel } from "./db.js";
|
|
5
|
+
|
|
6
|
+
/** IP клиента с учётом trust proxy (см. TRUST_PROXY в plugin-react) */
|
|
7
|
+
export const clientIp = (req: Request): string =>
|
|
8
|
+
req.ip ?? req.socket?.remoteAddress ?? "unknown";
|
|
9
|
+
|
|
10
|
+
const SALT_ROUNDS = Number(process.env.BCRYPT_ROUNDS) || 12;
|
|
11
|
+
|
|
12
|
+
// Асинхронные версии: bcryptjs дробит 2^rounds итераций на порции через
|
|
13
|
+
// setImmediate. Синхронный хеш занимал event loop целиком (~сотни мс при 12
|
|
14
|
+
// раундах), и каждый логин задерживал все остальные запросы процесса.
|
|
15
|
+
export const hashPassword = (password: string): Promise<string> =>
|
|
16
|
+
bcrypt.hash(String(password), SALT_ROUNDS);
|
|
17
|
+
|
|
18
|
+
export const checkPassword = (
|
|
19
|
+
hash: string | null | undefined,
|
|
20
|
+
password: string,
|
|
21
|
+
): Promise<boolean> => bcrypt.compare(String(password), hash || "");
|
|
22
|
+
|
|
23
|
+
export const findUserByEmail = (email: string) =>
|
|
24
|
+
userModel().where({ email }).first();
|
|
25
|
+
|
|
26
|
+
export const findUserById = (id: string) => userModel().where({ id }).first();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Секрет подписи сессионной cookie.
|
|
30
|
+
*
|
|
31
|
+
* Дефолт допустим только вне прода: с общеизвестным секретом любой
|
|
32
|
+
* подделывает подпись cookie, поэтому в проде без SESSION_SECRET
|
|
33
|
+
* плагин падает на старте, а не работает с дырой.
|
|
34
|
+
*/
|
|
35
|
+
export const sessionSecret = (): string => {
|
|
36
|
+
const secret = process.env.SESSION_SECRET;
|
|
37
|
+
if (secret) return secret;
|
|
38
|
+
if (process.env.NODE_ENV === "production") {
|
|
39
|
+
throw new Error("[auth] SESSION_SECRET обязателен в production");
|
|
40
|
+
}
|
|
41
|
+
return "quark-dev-secret";
|
|
42
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { QuarkPlugin } from "@quark-fw/plugin";
|
|
2
|
+
import session from "express-session";
|
|
3
|
+
import { RedisSessionStore } from "./sessionStore.js";
|
|
4
|
+
import passport from "passport";
|
|
5
|
+
import { Strategy as LocalStrategy } from "passport-local";
|
|
6
|
+
import { registerHttpPreMiddleware } from "@quark-fw/plugin/http.js";
|
|
7
|
+
import { sanitizeUser } from "./types.js";
|
|
8
|
+
import {
|
|
9
|
+
checkPassword,
|
|
10
|
+
findUserByEmail,
|
|
11
|
+
findUserById,
|
|
12
|
+
hashPassword,
|
|
13
|
+
sessionSecret,
|
|
14
|
+
} from "./helpers.js";
|
|
15
|
+
import {} from "@quark-fw/plugin-auth/src/global.js";
|
|
16
|
+
import {} from "@quark-fw/plugin-redis/src/global.js";
|
|
17
|
+
|
|
18
|
+
await quark.registerPlugin({
|
|
19
|
+
name: "auth",
|
|
20
|
+
// сессии живут в redis, пользователи — в базе
|
|
21
|
+
dependsOn: ["redis", "prisma"],
|
|
22
|
+
extend(quark) {
|
|
23
|
+
quark.auth = { hashPassword, checkPassword };
|
|
24
|
+
},
|
|
25
|
+
onInit(quark) {
|
|
26
|
+
passport.use(
|
|
27
|
+
new LocalStrategy(
|
|
28
|
+
{ usernameField: "email", passwordField: "password" },
|
|
29
|
+
async (email, password, done) => {
|
|
30
|
+
try {
|
|
31
|
+
const user = await findUserByEmail(email);
|
|
32
|
+
if (
|
|
33
|
+
!user ||
|
|
34
|
+
!(await checkPassword(
|
|
35
|
+
user.hashedPassword,
|
|
36
|
+
password,
|
|
37
|
+
))
|
|
38
|
+
) {
|
|
39
|
+
done(null, false);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
done(null, sanitizeUser(user));
|
|
43
|
+
} catch (e) {
|
|
44
|
+
done(e as Error);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// в сессии храним только id — профиль подтягиваем на каждый запрос
|
|
51
|
+
passport.serializeUser((user, done) => {
|
|
52
|
+
done(null, (user as { id: string }).id);
|
|
53
|
+
});
|
|
54
|
+
passport.deserializeUser(async (id: string, done) => {
|
|
55
|
+
try {
|
|
56
|
+
const user = await findUserById(id);
|
|
57
|
+
done(null, user ? sanitizeUser(user) : false);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
done(e as Error);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const sessionMiddleware = session({
|
|
64
|
+
store: new RedisSessionStore({
|
|
65
|
+
client: quark.redis.client.value,
|
|
66
|
+
}),
|
|
67
|
+
secret: sessionSecret(),
|
|
68
|
+
resave: false,
|
|
69
|
+
saveUninitialized: false,
|
|
70
|
+
name: "quark.sid",
|
|
71
|
+
cookie: {
|
|
72
|
+
httpOnly: true,
|
|
73
|
+
sameSite: "lax",
|
|
74
|
+
maxAge: 30 * 24 * 60 * 60 * 1000,
|
|
75
|
+
// "auto" = secure только на https-соединении. Жёсткое true
|
|
76
|
+
// ломает прод по http (браузер не вернёт такую cookie);
|
|
77
|
+
// за TLS-прокси нужен ещё TRUST_PROXY (см. react-плагин).
|
|
78
|
+
secure: "auto",
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// порядок важен: сессия → passport → всё остальное (/api/trpc)
|
|
83
|
+
registerHttpPreMiddleware(sessionMiddleware);
|
|
84
|
+
registerHttpPreMiddleware(passport.initialize());
|
|
85
|
+
registerHttpPreMiddleware(passport.session());
|
|
86
|
+
},
|
|
87
|
+
} satisfies QuarkPlugin);
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { TRPCError } from "@trpc/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
publicProcedure,
|
|
5
|
+
router,
|
|
6
|
+
type TRPCUser,
|
|
7
|
+
} from "@quark-fw/plugin-trpc/src/trpc.js";
|
|
8
|
+
import bcrypt from "bcryptjs";
|
|
9
|
+
import { sanitizeUser } from "./types.js";
|
|
10
|
+
import { userModel } from "./db.js";
|
|
11
|
+
import {} from "@quark-fw/plugin-auth/src/global.js";
|
|
12
|
+
import {} from "@quark-fw/plugin-redis/src/global.js";
|
|
13
|
+
import { createRateLimiter } from "./createRateLimiter.js";
|
|
14
|
+
import { enforceRateLimit } from "./enforceRateLimit.js";
|
|
15
|
+
import { clientIp } from "./helpers.js";
|
|
16
|
+
|
|
17
|
+
// Лимиты перебора: по IP — пароли и email с одного адреса, по email —
|
|
18
|
+
// распределённый перебор одного аккаунта. Окно 15 минут. Проверяются до
|
|
19
|
+
// bcrypt (~240 мс CPU на попытку) и запроса в базу.
|
|
20
|
+
const WINDOW_SEC = 15 * 60;
|
|
21
|
+
const redis = () => quark.redis.client.value;
|
|
22
|
+
const loginByIp = createRateLimiter({
|
|
23
|
+
client: redis,
|
|
24
|
+
prefix: "quark:auth:login:ip:",
|
|
25
|
+
max: 30,
|
|
26
|
+
windowSec: WINDOW_SEC,
|
|
27
|
+
});
|
|
28
|
+
const loginByEmail = createRateLimiter({
|
|
29
|
+
client: redis,
|
|
30
|
+
prefix: "quark:auth:login:email:",
|
|
31
|
+
max: 10,
|
|
32
|
+
windowSec: WINDOW_SEC,
|
|
33
|
+
});
|
|
34
|
+
const registerByIp = createRateLimiter({
|
|
35
|
+
client: redis,
|
|
36
|
+
prefix: "quark:auth:register:ip:",
|
|
37
|
+
max: 10,
|
|
38
|
+
windowSec: WINDOW_SEC,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Процедура, требующая залогиненного пользователя. */
|
|
42
|
+
export const userProcedure = publicProcedure.use(async ({ ctx, next }) => {
|
|
43
|
+
if (!ctx.user) {
|
|
44
|
+
throw new TRPCError({
|
|
45
|
+
code: "UNAUTHORIZED",
|
|
46
|
+
message: "Требуется вход",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return next({ ctx: { ...ctx, user: ctx.user as TRPCUser } });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const credentials = z.object({
|
|
53
|
+
email: z.email(),
|
|
54
|
+
password: z.string().min(6, "Пароль минимум 6 символов"),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** методы, которые passport дописывает в express-запрос */
|
|
58
|
+
type PassportRequest = {
|
|
59
|
+
login(user: unknown, done: (err: unknown) => void): void;
|
|
60
|
+
logout(done: (err: unknown) => void): void;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// passport кладёт пользователя в сессию через req.login
|
|
64
|
+
const login = (req: unknown, user: unknown) =>
|
|
65
|
+
new Promise<void>((resolve, reject) => {
|
|
66
|
+
(req as PassportRequest).login(user, (err: unknown) =>
|
|
67
|
+
err ? reject(err) : resolve(),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const authRouter = router({
|
|
72
|
+
me: publicProcedure.query(({ ctx }) => ctx.user ?? null),
|
|
73
|
+
|
|
74
|
+
register: publicProcedure
|
|
75
|
+
.input(credentials.extend({ name: z.string().optional() }))
|
|
76
|
+
.mutation(async ({ ctx, input }) => {
|
|
77
|
+
await enforceRateLimit([[registerByIp, clientIp(ctx.req)]]);
|
|
78
|
+
const exists = await userModel()
|
|
79
|
+
.where({ email: input.email })
|
|
80
|
+
.first();
|
|
81
|
+
if (exists) {
|
|
82
|
+
throw new TRPCError({
|
|
83
|
+
code: "BAD_REQUEST",
|
|
84
|
+
message: "Email уже зарегистрирован",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const created = await userModel().create({
|
|
89
|
+
email: input.email,
|
|
90
|
+
name: input.name ?? null,
|
|
91
|
+
hashedPassword: await quark.auth.hashPassword(input.password),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const user = sanitizeUser(created);
|
|
95
|
+
await login(ctx.req, user);
|
|
96
|
+
return user;
|
|
97
|
+
}),
|
|
98
|
+
|
|
99
|
+
login: publicProcedure
|
|
100
|
+
.input(credentials)
|
|
101
|
+
.mutation(async ({ ctx, input }) => {
|
|
102
|
+
await enforceRateLimit([
|
|
103
|
+
[loginByIp, clientIp(ctx.req)],
|
|
104
|
+
[loginByEmail, input.email.toLowerCase()],
|
|
105
|
+
]);
|
|
106
|
+
const found = await userModel()
|
|
107
|
+
.where({ email: input.email })
|
|
108
|
+
.first();
|
|
109
|
+
|
|
110
|
+
// сравниваем всегда, чтобы время ответа не выдавало наличие email
|
|
111
|
+
const ok = await bcrypt.compare(
|
|
112
|
+
input.password,
|
|
113
|
+
found?.hashedPassword ||
|
|
114
|
+
"$2a$12$0000000000000000000000000000000000000000000000000000",
|
|
115
|
+
);
|
|
116
|
+
if (!found || !ok) {
|
|
117
|
+
throw new TRPCError({
|
|
118
|
+
code: "UNAUTHORIZED",
|
|
119
|
+
message: "Неправильный email или пароль",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const user = sanitizeUser(found);
|
|
124
|
+
await login(ctx.req, user);
|
|
125
|
+
return user;
|
|
126
|
+
}),
|
|
127
|
+
|
|
128
|
+
updateProfile: userProcedure
|
|
129
|
+
.input(
|
|
130
|
+
z.object({
|
|
131
|
+
name: z.string().optional(),
|
|
132
|
+
email: z.email().optional(),
|
|
133
|
+
}),
|
|
134
|
+
)
|
|
135
|
+
.mutation(async ({ ctx, input }) => {
|
|
136
|
+
const me = ctx.user as { id: string };
|
|
137
|
+
|
|
138
|
+
if (input.email) {
|
|
139
|
+
const taken = await userModel()
|
|
140
|
+
.where({ email: input.email })
|
|
141
|
+
.first();
|
|
142
|
+
if (taken && taken.id !== me.id) {
|
|
143
|
+
throw new TRPCError({
|
|
144
|
+
code: "BAD_REQUEST",
|
|
145
|
+
message: "Email уже занят",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const updated = await userModel()
|
|
151
|
+
.where({ id: me.id })
|
|
152
|
+
.update({
|
|
153
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
154
|
+
...(input.email ? { email: input.email } : {}),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const user = sanitizeUser(updated);
|
|
158
|
+
// обновляем данные в сессии
|
|
159
|
+
await login(ctx.req, user);
|
|
160
|
+
return user;
|
|
161
|
+
}),
|
|
162
|
+
|
|
163
|
+
changePassword: userProcedure
|
|
164
|
+
.input(
|
|
165
|
+
z.object({
|
|
166
|
+
currentPassword: z.string(),
|
|
167
|
+
newPassword: z.string().min(6, "Пароль минимум 6 символов"),
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
.mutation(async ({ ctx, input }) => {
|
|
171
|
+
const me = ctx.user as { id: string };
|
|
172
|
+
const current = await userModel().where({ id: me.id }).first();
|
|
173
|
+
|
|
174
|
+
if (
|
|
175
|
+
!current ||
|
|
176
|
+
!(await quark.auth.checkPassword(
|
|
177
|
+
current.hashedPassword,
|
|
178
|
+
input.currentPassword,
|
|
179
|
+
))
|
|
180
|
+
) {
|
|
181
|
+
throw new TRPCError({
|
|
182
|
+
code: "BAD_REQUEST",
|
|
183
|
+
message: "Текущий пароль неверен",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await userModel()
|
|
188
|
+
.where({ id: me.id })
|
|
189
|
+
.update({
|
|
190
|
+
hashedPassword: await quark.auth.hashPassword(
|
|
191
|
+
input.newPassword,
|
|
192
|
+
),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return true;
|
|
196
|
+
}),
|
|
197
|
+
|
|
198
|
+
logout: publicProcedure.mutation(async ({ ctx }) => {
|
|
199
|
+
await new Promise<void>((resolve, reject) => {
|
|
200
|
+
(ctx.req as unknown as PassportRequest).logout((err: unknown) =>
|
|
201
|
+
err ? reject(err) : resolve(),
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
return true;
|
|
205
|
+
}),
|
|
206
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import session from "express-session";
|
|
2
|
+
import type { Redis } from "ioredis";
|
|
3
|
+
|
|
4
|
+
// Своя реализация store вместо connect-redis: тот говорит на диалекте
|
|
5
|
+
// node-redis (`set(key, val, {EX})`), а quark.redis — ioredis, который
|
|
6
|
+
// принимает `set(key, val, "EX", ttl)` и отвечает "ERR syntax error".
|
|
7
|
+
export class RedisSessionStore extends session.Store {
|
|
8
|
+
private client: Redis;
|
|
9
|
+
private prefix: string;
|
|
10
|
+
private ttl: number;
|
|
11
|
+
|
|
12
|
+
constructor({
|
|
13
|
+
client,
|
|
14
|
+
prefix = "quark:sess:",
|
|
15
|
+
ttlSeconds = 30 * 24 * 60 * 60,
|
|
16
|
+
}: {
|
|
17
|
+
client: Redis;
|
|
18
|
+
prefix?: string;
|
|
19
|
+
ttlSeconds?: number;
|
|
20
|
+
}) {
|
|
21
|
+
super();
|
|
22
|
+
this.client = client;
|
|
23
|
+
this.prefix = prefix;
|
|
24
|
+
this.ttl = ttlSeconds;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private key(sid: string) {
|
|
28
|
+
return `${this.prefix}${sid}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get(
|
|
32
|
+
sid: string,
|
|
33
|
+
callback: (err?: unknown, session?: session.SessionData | null) => void,
|
|
34
|
+
) {
|
|
35
|
+
this.client
|
|
36
|
+
.get(this.key(sid))
|
|
37
|
+
.then((data) => callback(null, data ? JSON.parse(data) : null))
|
|
38
|
+
.catch((e) => callback(e));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
set(
|
|
42
|
+
sid: string,
|
|
43
|
+
sess: session.SessionData,
|
|
44
|
+
callback?: (err?: unknown) => void,
|
|
45
|
+
) {
|
|
46
|
+
this.client
|
|
47
|
+
.set(this.key(sid), JSON.stringify(sess), "EX", this.ttl)
|
|
48
|
+
.then(() => callback?.(null))
|
|
49
|
+
.catch((e) => callback?.(e));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
destroy(sid: string, callback?: (err?: unknown) => void) {
|
|
53
|
+
this.client
|
|
54
|
+
.del(this.key(sid))
|
|
55
|
+
.then(() => callback?.(null))
|
|
56
|
+
.catch((e) => callback?.(e));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
touch(
|
|
60
|
+
sid: string,
|
|
61
|
+
_sess: session.SessionData,
|
|
62
|
+
callback?: (err?: unknown) => void,
|
|
63
|
+
) {
|
|
64
|
+
this.client
|
|
65
|
+
.expire(this.key(sid), this.ttl)
|
|
66
|
+
.then(() => callback?.(null))
|
|
67
|
+
.catch((e) => callback?.(e));
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Пользователь, каким его видит auth-плагин. Конвенция: приложение имеет
|
|
2
|
+
// модель User с полями id/email/name/hashedPassword.
|
|
3
|
+
export type AuthUser = {
|
|
4
|
+
id: string;
|
|
5
|
+
email: string;
|
|
6
|
+
name?: string | null;
|
|
7
|
+
hashedPassword: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Убирает хеш пароля перед отдачей пользователя наружу. */
|
|
11
|
+
export function sanitizeUser<T extends { hashedPassword?: string | null }>(
|
|
12
|
+
user: T,
|
|
13
|
+
): Omit<T, "hashedPassword"> {
|
|
14
|
+
const { hashedPassword: _drop, ...safe } = user;
|
|
15
|
+
return safe;
|
|
16
|
+
}
|