letopis 0.5.0 → 0.13.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/CHANGELOG.md +171 -0
- package/README.md +2280 -211
- package/dist/acl.d.ts +48 -0
- package/dist/acl.js +208 -0
- package/dist/auth.d.ts +116 -0
- package/dist/auth.js +263 -0
- package/dist/chain.d.ts +51 -26
- package/dist/chain.js +143 -74
- package/dist/index.d.ts +9 -3
- package/dist/index.js +24 -3
- package/dist/schema.js +11 -0
- package/dist/sessions.d.ts +32 -0
- package/dist/sessions.js +48 -0
- package/dist/sql.d.ts +23 -1
- package/dist/sql.js +167 -55
- package/dist/tx.d.ts +1 -1
- package/dist/tx.js +16 -1
- package/dist/types.d.ts +44 -1
- package/dist/up.d.ts +48 -0
- package/dist/up.js +209 -0
- package/dist/write.d.ts +18 -12
- package/dist/write.js +247 -36
- package/docker/Dockerfile +17 -0
- package/docker/start.sh +4 -0
- package/package.json +16 -2
- package/sql/ddl.sql +362 -0
- package/sql/seed.auth.sql +46 -0
- package/sql/seed.booking.sql +64 -0
package/dist/acl.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACL по таблицам Resource/Rule. Pattern каждой категории — шаблон «своей» сущности:
|
|
3
|
+
*
|
|
4
|
+
* category='ACCOUNT' — шаблон Account: {"categories": "{A,B}"} — есть любая;
|
|
5
|
+
* "!{A,B}" — нет ни одной; NULL — все аккаунты. Субъекты правил.
|
|
6
|
+
* category='API' — шаблон адреса эндпоинта: {"endpoint": "v2.auth.apikey.*"};
|
|
7
|
+
* маска: '.'-сегменты, '{a,b}' — альтернативы (вложенные ок), '*' — хвост 1+ сегментов.
|
|
8
|
+
* category='READ' | 'WRITE' | 'DELETE' — ШАБЛОН СТРОКИ Entity (операция = категория):
|
|
9
|
+
* ключи — реальные колонки (class, owner, account, tags, data, links, id, partition),
|
|
10
|
+
* значения — литералы или "$account" (id текущего аккаунта, подставляется в запрос).
|
|
11
|
+
* "class" — маска с lineage (класс или любой его предок); нет ключа/NULL — все строки.
|
|
12
|
+
* Остальные ключи победившего allow-правила вливаются в SQL ДО сортировки и лимита —
|
|
13
|
+
* пагинация/count/keyset работают по уже суженному множеству.
|
|
14
|
+
*
|
|
15
|
+
* Rule(субъект-alias → объект-alias, permission allow|deny, weight): правила никогда не
|
|
16
|
+
* указывают на конкретный аккаунт — принадлежность группе вычисляется из categories.
|
|
17
|
+
* Побеждает РОВНО ОДНО правило: max weight; при равном весе deny бьёт allow (равные
|
|
18
|
+
* allow — детерминированно по alias ресурса); НЕТ правил — deny (deny-by-default).
|
|
19
|
+
*/
|
|
20
|
+
import type { AclOp, AclDecision, ClassDef, Resource, Rule } from './types.js';
|
|
21
|
+
import type { Registry } from './schema.js';
|
|
22
|
+
import type { Tables } from './tables.js';
|
|
23
|
+
export declare const ACL_OPS: readonly AclOp[];
|
|
24
|
+
/** pattern.categories: "{A,B}" | "!{A,B}" | отсутствие/NULL (матч всех). */
|
|
25
|
+
export declare function matchCategories(pattern: Record<string, unknown> | null, cats: string[]): boolean;
|
|
26
|
+
export declare function matchMask(mask: string, value: string): boolean;
|
|
27
|
+
export interface AclApi {
|
|
28
|
+
/** Может ли аккаунт дёрнуть эндпоинт (Resource category='API'). */
|
|
29
|
+
check(account: string | {
|
|
30
|
+
id: string;
|
|
31
|
+
}, endpoint: string): Promise<AclDecision>;
|
|
32
|
+
/** Решение операции над классом: allow + остаточный шаблон строк (filter) | deny. */
|
|
33
|
+
checkData(account: string | {
|
|
34
|
+
id: string;
|
|
35
|
+
}, className: string, op: AclOp): Promise<AclDecision>;
|
|
36
|
+
/** Сбросить кэш Resource/Rule фасада (enforceAcl-цепочки перечитывают только новым connect). */
|
|
37
|
+
reload(): void;
|
|
38
|
+
}
|
|
39
|
+
interface AclSource {
|
|
40
|
+
resources: Resource[];
|
|
41
|
+
rules: Rule[];
|
|
42
|
+
}
|
|
43
|
+
export declare function makeAcl(tables: Tables, registry: Registry): AclApi;
|
|
44
|
+
/** Синхронный резолвер для цепочек: категории субъекта и правила зафиксированы на connect. */
|
|
45
|
+
export declare function compileEnforcer(src: AclSource, account: string, cats: string[], registry: Registry): (cls: ClassDef, op: AclOp) => AclDecision;
|
|
46
|
+
/** Читаемая ошибка отказа для цепочек. */
|
|
47
|
+
export declare function aclDenied(op: AclOp, clsId: string, d: AclDecision): Error;
|
|
48
|
+
export {};
|
package/dist/acl.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
export const ACL_OPS = ['READ', 'WRITE', 'DELETE'];
|
|
2
|
+
// --- матчеры -----------------------------------------------------------------
|
|
3
|
+
/** pattern.categories: "{A,B}" | "!{A,B}" | отсутствие/NULL (матч всех). */
|
|
4
|
+
export function matchCategories(pattern, cats) {
|
|
5
|
+
const spec = pattern?.categories;
|
|
6
|
+
if (typeof spec !== 'string')
|
|
7
|
+
return true;
|
|
8
|
+
const neg = spec.startsWith('!');
|
|
9
|
+
const list = spec
|
|
10
|
+
.replace(/^!/, '')
|
|
11
|
+
.replace(/^\{/, '')
|
|
12
|
+
.replace(/\}$/, '')
|
|
13
|
+
.split(',')
|
|
14
|
+
.map((s) => s.trim())
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
const hit = list.some((c) => cats.includes(c));
|
|
17
|
+
return neg ? !hit : hit;
|
|
18
|
+
}
|
|
19
|
+
/** Маска → regex: '{a,b}' — альтернативы (рекурсивно), '*' — хвост из 1+ сегментов. */
|
|
20
|
+
function maskBody(mask) {
|
|
21
|
+
let out = '';
|
|
22
|
+
for (let i = 0; i < mask.length; i++) {
|
|
23
|
+
const ch = mask[i];
|
|
24
|
+
if (ch === '{') {
|
|
25
|
+
let depth = 1;
|
|
26
|
+
let j = i + 1;
|
|
27
|
+
for (; j < mask.length && depth; j++) {
|
|
28
|
+
if (mask[j] === '{')
|
|
29
|
+
depth++;
|
|
30
|
+
else if (mask[j] === '}')
|
|
31
|
+
depth--;
|
|
32
|
+
}
|
|
33
|
+
const inner = mask.slice(i + 1, j - 1);
|
|
34
|
+
const parts = [];
|
|
35
|
+
let d = 0;
|
|
36
|
+
let start = 0;
|
|
37
|
+
for (let k = 0; k <= inner.length; k++) {
|
|
38
|
+
const c = inner[k];
|
|
39
|
+
if (k === inner.length || (c === ',' && d === 0)) {
|
|
40
|
+
parts.push(inner.slice(start, k));
|
|
41
|
+
start = k + 1;
|
|
42
|
+
}
|
|
43
|
+
else if (c === '{')
|
|
44
|
+
d++;
|
|
45
|
+
else if (c === '}')
|
|
46
|
+
d--;
|
|
47
|
+
}
|
|
48
|
+
out += '(?:' + parts.map(maskBody).join('|') + ')';
|
|
49
|
+
i = j - 1;
|
|
50
|
+
}
|
|
51
|
+
else if (ch === '*') {
|
|
52
|
+
out += '[^.]+(?:\\.[^.]+)*';
|
|
53
|
+
}
|
|
54
|
+
else if ('.\\+?()[]^$|'.includes(ch)) {
|
|
55
|
+
out += '\\' + ch;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
out += ch;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
const regexCache = new Map();
|
|
64
|
+
export function matchMask(mask, value) {
|
|
65
|
+
let re = regexCache.get(mask);
|
|
66
|
+
if (!re) {
|
|
67
|
+
re = new RegExp(`^${maskBody(mask)}$`);
|
|
68
|
+
regexCache.set(mask, re);
|
|
69
|
+
}
|
|
70
|
+
return re.test(value);
|
|
71
|
+
}
|
|
72
|
+
// --- резолюция ----------------------------------------------------------------
|
|
73
|
+
/** Все имена, под которыми класс виден правилам: id+алиас его самого и каждого предка. */
|
|
74
|
+
function classNames(cls, registry) {
|
|
75
|
+
const names = [];
|
|
76
|
+
for (const id of cls.ancestors) {
|
|
77
|
+
names.push(id);
|
|
78
|
+
try {
|
|
79
|
+
const a = registry.resolve(id).alias;
|
|
80
|
+
if (a && a !== id)
|
|
81
|
+
names.push(a);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* предок вне реестра — пропуск */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return names;
|
|
88
|
+
}
|
|
89
|
+
const matchesApi = (r, endpoint) => {
|
|
90
|
+
if (r.category !== 'API')
|
|
91
|
+
return false;
|
|
92
|
+
if (r.pattern == null)
|
|
93
|
+
return true;
|
|
94
|
+
const m = r.pattern.endpoint;
|
|
95
|
+
return typeof m === 'string' && matchMask(m, endpoint);
|
|
96
|
+
};
|
|
97
|
+
/** Ресурс операции op применим к классу? (class-ключ — маска с lineage; нет ключа — все). */
|
|
98
|
+
const matchesRow = (r, op, names) => {
|
|
99
|
+
if (r.category !== op)
|
|
100
|
+
return false;
|
|
101
|
+
if (r.pattern == null)
|
|
102
|
+
return true;
|
|
103
|
+
const clsMask = r.pattern.class;
|
|
104
|
+
return typeof clsMask !== 'string' || names.some((n) => matchMask(clsMask, n));
|
|
105
|
+
};
|
|
106
|
+
/** "$account" → id субъекта, рекурсивно по значениям шаблона. */
|
|
107
|
+
function substitute(v, account) {
|
|
108
|
+
if (v === '$account')
|
|
109
|
+
return account;
|
|
110
|
+
if (Array.isArray(v))
|
|
111
|
+
return v.map((x) => substitute(x, account));
|
|
112
|
+
if (v && typeof v === 'object') {
|
|
113
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, substitute(x, account)]));
|
|
114
|
+
}
|
|
115
|
+
return v;
|
|
116
|
+
}
|
|
117
|
+
/** Остаточный шаблон строки победившего allow: все ключи, кроме class, с подстановкой. */
|
|
118
|
+
function rowFilter(r, account) {
|
|
119
|
+
if (r.pattern == null)
|
|
120
|
+
return undefined;
|
|
121
|
+
const rest = Object.entries(r.pattern).filter(([k]) => k !== 'class');
|
|
122
|
+
if (!rest.length)
|
|
123
|
+
return undefined;
|
|
124
|
+
return Object.fromEntries(rest.map(([k, v]) => [k, substitute(v, account)]));
|
|
125
|
+
}
|
|
126
|
+
/** Победитель: max weight; равный вес — deny бьёт allow, равные allow — по alias объекта. */
|
|
127
|
+
function decide(rules, subjects, objects) {
|
|
128
|
+
let winner;
|
|
129
|
+
for (const rule of rules) {
|
|
130
|
+
if (!rule.enabled || !subjects.has(rule.account) || !objects.has(rule.resource))
|
|
131
|
+
continue;
|
|
132
|
+
if (!winner) {
|
|
133
|
+
winner = rule;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const w = rule.weight ?? 0;
|
|
137
|
+
const ww = winner.weight ?? 0;
|
|
138
|
+
if (w > ww ||
|
|
139
|
+
(w === ww && winner.permission === 'allow' && rule.permission !== 'allow') ||
|
|
140
|
+
(w === ww && winner.permission === rule.permission && rule.resource < winner.resource)) {
|
|
141
|
+
winner = rule;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { winner };
|
|
145
|
+
}
|
|
146
|
+
function toDecision(winner, objects, account) {
|
|
147
|
+
if (!winner)
|
|
148
|
+
return { allow: false, message: 'no matching rule (deny by default)' };
|
|
149
|
+
if (winner.permission !== 'allow') {
|
|
150
|
+
const meta = (winner.meta ?? {});
|
|
151
|
+
return { allow: false, rule: winner, code: meta.code, message: meta.message };
|
|
152
|
+
}
|
|
153
|
+
const res = objects.get(winner.resource);
|
|
154
|
+
// остаточный шаблон строк — только у ресурсов-операций (у API pattern — не строки Entity)
|
|
155
|
+
const filter = ACL_OPS.includes(res.category) ? rowFilter(res, account) : undefined;
|
|
156
|
+
return filter ? { allow: true, rule: winner, filter } : { allow: true, rule: winner };
|
|
157
|
+
}
|
|
158
|
+
const subjectAliases = (resources, cats) => new Set(resources.filter((r) => r.category === 'ACCOUNT' && matchCategories(r.pattern, cats)).map((r) => r.alias));
|
|
159
|
+
const toMap = (rs) => new Map(rs.map((r) => [r.alias, r]));
|
|
160
|
+
const accId = (a) => (typeof a === 'object' ? a.id : a);
|
|
161
|
+
export function makeAcl(tables, registry) {
|
|
162
|
+
let cache = null;
|
|
163
|
+
const load = () => (cache ??= (async () => ({
|
|
164
|
+
resources: await tables.resources.find(),
|
|
165
|
+
rules: await tables.rules.find({ enabled: true }),
|
|
166
|
+
}))());
|
|
167
|
+
const catsOf = async (account) => (await tables.accounts.get(accId(account)))?.categories ?? [];
|
|
168
|
+
return {
|
|
169
|
+
async check(account, endpoint) {
|
|
170
|
+
const [{ resources, rules }, cats] = await Promise.all([load(), catsOf(account)]);
|
|
171
|
+
const objects = toMap(resources.filter((r) => matchesApi(r, endpoint)));
|
|
172
|
+
const { winner } = decide(rules, subjectAliases(resources, cats), objects);
|
|
173
|
+
return toDecision(winner, objects, accId(account));
|
|
174
|
+
},
|
|
175
|
+
async checkData(account, className, op) {
|
|
176
|
+
const [{ resources, rules }, cats] = await Promise.all([load(), catsOf(account)]);
|
|
177
|
+
const names = classNames(registry.resolve(className), registry);
|
|
178
|
+
const objects = toMap(resources.filter((r) => matchesRow(r, op, names)));
|
|
179
|
+
const { winner } = decide(rules, subjectAliases(resources, cats), objects);
|
|
180
|
+
return toDecision(winner, objects, accId(account));
|
|
181
|
+
},
|
|
182
|
+
reload() {
|
|
183
|
+
cache = null;
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
// --- enforce-компилятор (connect({enforceAcl: true})) ----------------------------
|
|
188
|
+
/** Синхронный резолвер для цепочек: категории субъекта и правила зафиксированы на connect. */
|
|
189
|
+
export function compileEnforcer(src, account, cats, registry) {
|
|
190
|
+
const subjects = subjectAliases(src.resources, cats);
|
|
191
|
+
const memo = new Map();
|
|
192
|
+
return (cls, op) => {
|
|
193
|
+
const key = `${cls.id} ${op}`;
|
|
194
|
+
let d = memo.get(key);
|
|
195
|
+
if (!d) {
|
|
196
|
+
const names = classNames(cls, registry);
|
|
197
|
+
const objects = toMap(src.resources.filter((r) => matchesRow(r, op, names)));
|
|
198
|
+
const { winner } = decide(src.rules, subjects, objects);
|
|
199
|
+
d = toDecision(winner, objects, account);
|
|
200
|
+
memo.set(key, d);
|
|
201
|
+
}
|
|
202
|
+
return d;
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/** Читаемая ошибка отказа для цепочек. */
|
|
206
|
+
export function aclDenied(op, clsId, d) {
|
|
207
|
+
return new Error(`letopis: acl denies ${op} on ${clsId}${d.message ? ` — ${d.message}` : ''}`);
|
|
208
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Account, Credential } from './types.js';
|
|
2
|
+
import type { Tables } from './tables.js';
|
|
3
|
+
import { type SessionStore, type Sessions } from './sessions.js';
|
|
4
|
+
/** Код authenticator-а для секрета на момент времени (хелпер для тестов/сервисов). */
|
|
5
|
+
export declare function totpCode(secretBase32: string, atMs?: number): string;
|
|
6
|
+
/** Успешный вход: чей кред и какой именно. */
|
|
7
|
+
export interface AuthResult {
|
|
8
|
+
account: Account;
|
|
9
|
+
credential: Credential;
|
|
10
|
+
}
|
|
11
|
+
interface VerifyOpts {
|
|
12
|
+
/** false — пускать и неподтверждённые креды (default: подтверждённые). */
|
|
13
|
+
requireConfirmed?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface AuthApi {
|
|
16
|
+
/** Пароль (upsert): логин/пароль и почта/пароль — одна механика, различает category. */
|
|
17
|
+
setPassword(a: {
|
|
18
|
+
account: string | {
|
|
19
|
+
id: string;
|
|
20
|
+
};
|
|
21
|
+
identifier: string;
|
|
22
|
+
password: string;
|
|
23
|
+
category?: string;
|
|
24
|
+
confirmed?: boolean;
|
|
25
|
+
}): Promise<Credential>;
|
|
26
|
+
verifyPassword(a: {
|
|
27
|
+
identifier: string;
|
|
28
|
+
password: string;
|
|
29
|
+
category?: string;
|
|
30
|
+
} & VerifyOpts): Promise<AuthResult | null>;
|
|
31
|
+
/** Новый api-ключ `lts_<48hex>`: отдаётся ОДИН раз, в БД — только sha256. */
|
|
32
|
+
issueApiKey(a: {
|
|
33
|
+
account: string | {
|
|
34
|
+
id: string;
|
|
35
|
+
};
|
|
36
|
+
name?: string;
|
|
37
|
+
}): Promise<{
|
|
38
|
+
key: string;
|
|
39
|
+
credential: Credential;
|
|
40
|
+
}>;
|
|
41
|
+
verifyApiKey(key: string, opts?: VerifyOpts): Promise<AuthResult | null>;
|
|
42
|
+
/** Пара key(открытый id)+secret: secret отдаётся один раз, в БД — sha256. */
|
|
43
|
+
issueKeySecret(a: {
|
|
44
|
+
account: string | {
|
|
45
|
+
id: string;
|
|
46
|
+
};
|
|
47
|
+
name?: string;
|
|
48
|
+
}): Promise<{
|
|
49
|
+
key: string;
|
|
50
|
+
secret: string;
|
|
51
|
+
credential: Credential;
|
|
52
|
+
}>;
|
|
53
|
+
verifyKeySecret(key: string, secret: string, opts?: VerifyOpts): Promise<AuthResult | null>;
|
|
54
|
+
/** TOTP (authenticator): секрет + otpauth-URI для QR; активен после первой успешной проверки. */
|
|
55
|
+
enrollTotp(a: {
|
|
56
|
+
account: string | {
|
|
57
|
+
id: string;
|
|
58
|
+
};
|
|
59
|
+
issuer?: string;
|
|
60
|
+
label?: string;
|
|
61
|
+
}): Promise<{
|
|
62
|
+
secret: string;
|
|
63
|
+
uri: string;
|
|
64
|
+
credential: Credential;
|
|
65
|
+
}>;
|
|
66
|
+
/** Проверка 6-значного кода: окно ±window шагов (default 1), replay того же шага отбит. */
|
|
67
|
+
verifyTotp(a: {
|
|
68
|
+
account: string | {
|
|
69
|
+
id: string;
|
|
70
|
+
};
|
|
71
|
+
code: string;
|
|
72
|
+
window?: number;
|
|
73
|
+
}): Promise<boolean>;
|
|
74
|
+
/** true — TOTP-фактор включён (enroll подтверждён хотя бы одной проверкой). */
|
|
75
|
+
totpEnabled(account: string | {
|
|
76
|
+
id: string;
|
|
77
|
+
}): Promise<boolean>;
|
|
78
|
+
/** Одноразовый код (email/SMS/reset — доставка на приложении): в БД sha256 + TTL + лимит попыток. */
|
|
79
|
+
issueOtp(a: {
|
|
80
|
+
account: string | {
|
|
81
|
+
id: string;
|
|
82
|
+
};
|
|
83
|
+
identifier: string;
|
|
84
|
+
category?: string;
|
|
85
|
+
ttlSec?: number;
|
|
86
|
+
digits?: number;
|
|
87
|
+
}): Promise<{
|
|
88
|
+
code: string;
|
|
89
|
+
credential: Credential;
|
|
90
|
+
}>;
|
|
91
|
+
/** Успех сжигает код (одноразовость); maxAttempts (default 5) неудач тоже сжигают. */
|
|
92
|
+
verifyOtp(a: {
|
|
93
|
+
identifier: string;
|
|
94
|
+
code: string;
|
|
95
|
+
category?: string;
|
|
96
|
+
maxAttempts?: number;
|
|
97
|
+
}): Promise<AuthResult | null>;
|
|
98
|
+
/** Внешняя identity (TELEGRAM / GOOGLE / SSO-провайдер…): токен проверяет приложение, тут — связка. */
|
|
99
|
+
link(a: {
|
|
100
|
+
account: string | {
|
|
101
|
+
id: string;
|
|
102
|
+
};
|
|
103
|
+
category: string;
|
|
104
|
+
identifier: string;
|
|
105
|
+
meta?: Record<string, unknown>;
|
|
106
|
+
confirmed?: boolean;
|
|
107
|
+
}): Promise<Credential>;
|
|
108
|
+
lookup(a: {
|
|
109
|
+
category: string;
|
|
110
|
+
identifier: string;
|
|
111
|
+
} & VerifyOpts): Promise<AuthResult | null>;
|
|
112
|
+
/** Сессии во внешнем KV (Redis): store инжектирует приложение. */
|
|
113
|
+
sessions(store: SessionStore): Sessions;
|
|
114
|
+
}
|
|
115
|
+
export declare function makeAuth(tables: Tables): AuthApi;
|
|
116
|
+
export {};
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db.auth: вход по таблице Credential — на аккаунт много способов:
|
|
3
|
+
* пароль (логин/почта), api-ключ, ключ-секрет, внешние identity (oauth/sso/telegram).
|
|
4
|
+
* Пароли — scrypt (node:crypto, без зависимостей); ключи/секреты — в БД только sha256.
|
|
5
|
+
* Однозначность входа даёт credential_identity_udx: один живой кред на (category, identifier).
|
|
6
|
+
* OAuth-танец, rate-limit, lockout — на приложении: здесь хранение и проверка.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, createHmac, randomBytes, randomInt, scrypt, timingSafeEqual } from 'node:crypto';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import { makeSessions } from './sessions.js';
|
|
11
|
+
const scryptAsync = promisify(scrypt);
|
|
12
|
+
// OWASP-порядок стойкости; maxmem с запасом (128*N*r байт впритык к дефолтным 32MiB)
|
|
13
|
+
const SCRYPT = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
|
14
|
+
const KEYLEN = 32;
|
|
15
|
+
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
|
16
|
+
async function hashPassword(password) {
|
|
17
|
+
const salt = randomBytes(16);
|
|
18
|
+
const hash = await scryptAsync(password, salt, KEYLEN, SCRYPT);
|
|
19
|
+
return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString('base64')}$${hash.toString('base64')}`;
|
|
20
|
+
}
|
|
21
|
+
async function verifyHash(password, stored) {
|
|
22
|
+
const [algo, N, r, p, saltB64, hashB64] = stored.split('$');
|
|
23
|
+
if (algo !== 'scrypt')
|
|
24
|
+
return false; // legacy-форматы (bcrypt из дампа) не верифицируем
|
|
25
|
+
const want = Buffer.from(hashB64, 'base64');
|
|
26
|
+
const got = await scryptAsync(password, Buffer.from(saltB64, 'base64'), want.length, {
|
|
27
|
+
N: Number(N), r: Number(r), p: Number(p), maxmem: SCRYPT.maxmem,
|
|
28
|
+
});
|
|
29
|
+
return timingSafeEqual(got, want);
|
|
30
|
+
}
|
|
31
|
+
/** Выравнивание времени ответа: «identifier не существует» не быстрее «пароль неверен». */
|
|
32
|
+
const dummyVerify = (password) => scryptAsync(password, Buffer.alloc(16), KEYLEN, SCRYPT).then(() => false);
|
|
33
|
+
const eqHex = (a, b) => {
|
|
34
|
+
const ba = Buffer.from(a, 'hex');
|
|
35
|
+
const bb = Buffer.from(b, 'hex');
|
|
36
|
+
return ba.length === bb.length && timingSafeEqual(ba, bb);
|
|
37
|
+
};
|
|
38
|
+
const eqStr = (a, b) => {
|
|
39
|
+
const ba = Buffer.from(a);
|
|
40
|
+
const bb = Buffer.from(b);
|
|
41
|
+
return ba.length === bb.length && timingSafeEqual(ba, bb);
|
|
42
|
+
};
|
|
43
|
+
// --- TOTP (RFC 6238 / HOTP RFC 4226) ---------------------------------------
|
|
44
|
+
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
45
|
+
function base32Encode(buf) {
|
|
46
|
+
let bits = 0;
|
|
47
|
+
let value = 0;
|
|
48
|
+
let out = '';
|
|
49
|
+
for (const byte of buf) {
|
|
50
|
+
value = (value << 8) | byte;
|
|
51
|
+
bits += 8;
|
|
52
|
+
while (bits >= 5) {
|
|
53
|
+
out += B32[(value >>> (bits - 5)) & 31];
|
|
54
|
+
bits -= 5;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (bits > 0)
|
|
58
|
+
out += B32[(value << (5 - bits)) & 31];
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
function base32Decode(s) {
|
|
62
|
+
let bits = 0;
|
|
63
|
+
let value = 0;
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const ch of s.replace(/=+$/, '').toUpperCase()) {
|
|
66
|
+
const idx = B32.indexOf(ch);
|
|
67
|
+
if (idx < 0)
|
|
68
|
+
throw new Error('letopis: invalid base32 TOTP secret');
|
|
69
|
+
value = (value << 5) | idx;
|
|
70
|
+
bits += 5;
|
|
71
|
+
if (bits >= 8) {
|
|
72
|
+
out.push((value >>> (bits - 8)) & 255);
|
|
73
|
+
bits -= 8;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return Buffer.from(out);
|
|
77
|
+
}
|
|
78
|
+
const TOTP_PERIOD = 30; // секунд на шаг (стандарт authenticator-приложений)
|
|
79
|
+
const TOTP_DIGITS = 6;
|
|
80
|
+
function hotp(secret, counter) {
|
|
81
|
+
const buf = Buffer.alloc(8);
|
|
82
|
+
buf.writeBigUInt64BE(BigInt(counter));
|
|
83
|
+
const h = createHmac('sha1', secret).update(buf).digest();
|
|
84
|
+
const off = h[h.length - 1] & 0xf;
|
|
85
|
+
const code = (((h[off] & 0x7f) << 24) | (h[off + 1] << 16) | (h[off + 2] << 8) | h[off + 3]) % 10 ** TOTP_DIGITS;
|
|
86
|
+
return String(code).padStart(TOTP_DIGITS, '0');
|
|
87
|
+
}
|
|
88
|
+
const totpStep = (atMs) => Math.floor(atMs / 1000 / TOTP_PERIOD);
|
|
89
|
+
/** Код authenticator-а для секрета на момент времени (хелпер для тестов/сервисов). */
|
|
90
|
+
export function totpCode(secretBase32, atMs = Date.now()) {
|
|
91
|
+
return hotp(base32Decode(secretBase32), totpStep(atMs));
|
|
92
|
+
}
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
const accId = (a) => (typeof a === 'object' ? a.id : a);
|
|
95
|
+
export function makeAuth(tables) {
|
|
96
|
+
/** Живой кред по глобально-уникальной паре (category, identifier). */
|
|
97
|
+
const findLive = async (category, identifier) => (await tables.credentials.find({ category, identifier }))[0] ?? null;
|
|
98
|
+
/** Общие ворота: confirmed-кред + enabled-аккаунт. */
|
|
99
|
+
async function finalize(cred, opts) {
|
|
100
|
+
if (!cred.confirmed && opts.requireConfirmed !== false)
|
|
101
|
+
return null;
|
|
102
|
+
const account = await tables.accounts.get(cred.account);
|
|
103
|
+
if (!account?.enabled)
|
|
104
|
+
return null;
|
|
105
|
+
return { account, credential: cred };
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
async setPassword(a) {
|
|
109
|
+
return tables.credentials.set({
|
|
110
|
+
account: accId(a.account),
|
|
111
|
+
category: a.category ?? 'PASSWORD',
|
|
112
|
+
identifier: a.identifier,
|
|
113
|
+
meta: { password: await hashPassword(a.password) },
|
|
114
|
+
confirmed: a.confirmed,
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
async verifyPassword(a) {
|
|
118
|
+
const cred = await findLive(a.category ?? 'PASSWORD', a.identifier);
|
|
119
|
+
const stored = cred?.meta.password;
|
|
120
|
+
if (!cred || typeof stored !== 'string') {
|
|
121
|
+
await dummyVerify(a.password);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (!(await verifyHash(a.password, stored)))
|
|
125
|
+
return null;
|
|
126
|
+
return finalize(cred, a);
|
|
127
|
+
},
|
|
128
|
+
async issueApiKey(a) {
|
|
129
|
+
const key = `lts_${randomBytes(24).toString('hex')}`;
|
|
130
|
+
const credential = await tables.credentials.set({
|
|
131
|
+
account: accId(a.account),
|
|
132
|
+
category: 'APIKEY',
|
|
133
|
+
identifier: sha256(key),
|
|
134
|
+
meta: { name: a.name ?? null, prefix: key.slice(0, 12) },
|
|
135
|
+
});
|
|
136
|
+
return { key, credential };
|
|
137
|
+
},
|
|
138
|
+
async verifyApiKey(key, opts = {}) {
|
|
139
|
+
const cred = await findLive('APIKEY', sha256(key));
|
|
140
|
+
return cred ? finalize(cred, opts) : null;
|
|
141
|
+
},
|
|
142
|
+
async issueKeySecret(a) {
|
|
143
|
+
const key = randomBytes(8).toString('hex');
|
|
144
|
+
const secret = randomBytes(24).toString('hex');
|
|
145
|
+
const credential = await tables.credentials.set({
|
|
146
|
+
account: accId(a.account),
|
|
147
|
+
category: 'KEYSECRET',
|
|
148
|
+
identifier: key,
|
|
149
|
+
meta: { name: a.name ?? null, secret: sha256(secret) },
|
|
150
|
+
});
|
|
151
|
+
return { key, secret, credential };
|
|
152
|
+
},
|
|
153
|
+
async verifyKeySecret(key, secret, opts = {}) {
|
|
154
|
+
const cred = await findLive('KEYSECRET', key);
|
|
155
|
+
const stored = cred?.meta.secret;
|
|
156
|
+
if (!cred || typeof stored !== 'string' || !eqHex(sha256(secret), stored))
|
|
157
|
+
return null;
|
|
158
|
+
return finalize(cred, opts);
|
|
159
|
+
},
|
|
160
|
+
async enrollTotp(a) {
|
|
161
|
+
const id = accId(a.account);
|
|
162
|
+
const secret = base32Encode(randomBytes(20));
|
|
163
|
+
const label = encodeURIComponent(a.label ?? id);
|
|
164
|
+
const issuer = a.issuer ? `&issuer=${encodeURIComponent(a.issuer)}` : '';
|
|
165
|
+
const uri = `otpauth://totp/${label}?secret=${secret}${issuer}` +
|
|
166
|
+
`&algorithm=SHA1&digits=${TOTP_DIGITS}&period=${TOTP_PERIOD}`;
|
|
167
|
+
// re-enroll перезаписывает секрет и сбрасывает подтверждение
|
|
168
|
+
const credential = await tables.credentials.set({
|
|
169
|
+
account: id,
|
|
170
|
+
category: 'TOTP',
|
|
171
|
+
identifier: id, // один TOTP-фактор на аккаунт
|
|
172
|
+
meta: { secret, lastStep: 0 },
|
|
173
|
+
confirmed: false,
|
|
174
|
+
});
|
|
175
|
+
return { secret, uri, credential };
|
|
176
|
+
},
|
|
177
|
+
async verifyTotp(a) {
|
|
178
|
+
const id = accId(a.account);
|
|
179
|
+
const cred = (await tables.credentials.find({ account: id, category: 'TOTP' }))[0];
|
|
180
|
+
const secretB32 = cred?.meta.secret;
|
|
181
|
+
if (!cred || typeof secretB32 !== 'string')
|
|
182
|
+
return false;
|
|
183
|
+
const secret = base32Decode(secretB32);
|
|
184
|
+
const lastStep = typeof cred.meta.lastStep === 'number' ? cred.meta.lastStep : 0;
|
|
185
|
+
const now = totpStep(Date.now());
|
|
186
|
+
const window = a.window ?? 1;
|
|
187
|
+
for (let s = now - window; s <= now + window; s++) {
|
|
188
|
+
if (s <= lastStep)
|
|
189
|
+
continue; // replay: шаг уже использован
|
|
190
|
+
if (!eqStr(a.code, hotp(secret, s)))
|
|
191
|
+
continue;
|
|
192
|
+
await tables.credentials.set({
|
|
193
|
+
account: id,
|
|
194
|
+
category: 'TOTP',
|
|
195
|
+
identifier: cred.identifier,
|
|
196
|
+
meta: { ...cred.meta, lastStep: s },
|
|
197
|
+
confirmed: true, // первая успешная проверка активирует фактор
|
|
198
|
+
});
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
},
|
|
203
|
+
async totpEnabled(account) {
|
|
204
|
+
const cred = (await tables.credentials.find({ account: accId(account), category: 'TOTP' }))[0];
|
|
205
|
+
return cred?.confirmed === true;
|
|
206
|
+
},
|
|
207
|
+
async issueOtp(a) {
|
|
208
|
+
const digits = a.digits ?? 6;
|
|
209
|
+
const code = String(randomInt(0, 10 ** digits)).padStart(digits, '0');
|
|
210
|
+
// повторный issue на тот же identifier перезаписывает код (валиден только последний)
|
|
211
|
+
const credential = await tables.credentials.set({
|
|
212
|
+
account: accId(a.account),
|
|
213
|
+
category: a.category ?? 'OTP',
|
|
214
|
+
identifier: a.identifier,
|
|
215
|
+
meta: {
|
|
216
|
+
code: sha256(code),
|
|
217
|
+
expires: new Date(Date.now() + (a.ttlSec ?? 600) * 1000).toISOString(),
|
|
218
|
+
attempts: 0,
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
return { code, credential };
|
|
222
|
+
},
|
|
223
|
+
async verifyOtp(a) {
|
|
224
|
+
const cred = await findLive(a.category ?? 'OTP', a.identifier);
|
|
225
|
+
if (!cred)
|
|
226
|
+
return null;
|
|
227
|
+
const { code, expires, attempts } = cred.meta;
|
|
228
|
+
if (typeof code !== 'string' || !expires || Date.parse(expires) < Date.now()) {
|
|
229
|
+
await tables.credentials.delete(cred.id);
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
if (!eqHex(sha256(a.code), code)) {
|
|
233
|
+
const n = (attempts ?? 0) + 1;
|
|
234
|
+
if (n >= (a.maxAttempts ?? 5))
|
|
235
|
+
await tables.credentials.delete(cred.id); // перебор — код сожжён
|
|
236
|
+
else
|
|
237
|
+
await tables.credentials.set({
|
|
238
|
+
account: cred.account,
|
|
239
|
+
category: cred.category,
|
|
240
|
+
identifier: cred.identifier,
|
|
241
|
+
meta: { ...cred.meta, attempts: n },
|
|
242
|
+
});
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
await tables.credentials.delete(cred.id); // одноразовость: успех сжигает код
|
|
246
|
+
return finalize(cred, {});
|
|
247
|
+
},
|
|
248
|
+
async link(a) {
|
|
249
|
+
return tables.credentials.set({
|
|
250
|
+
account: accId(a.account),
|
|
251
|
+
category: a.category,
|
|
252
|
+
identifier: a.identifier,
|
|
253
|
+
meta: a.meta ?? {},
|
|
254
|
+
confirmed: a.confirmed,
|
|
255
|
+
});
|
|
256
|
+
},
|
|
257
|
+
async lookup(a) {
|
|
258
|
+
const cred = await findLive(a.category, a.identifier);
|
|
259
|
+
return cred ? finalize(cred, a) : null;
|
|
260
|
+
},
|
|
261
|
+
sessions: makeSessions,
|
|
262
|
+
};
|
|
263
|
+
}
|