@aweftjs/auth 0.1.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 +548 -0
- package/dist/auth-client.d.ts +248 -0
- package/dist/auth-client.js +287 -0
- package/dist/client-modules/Reset.d.ts +12 -0
- package/dist/client-modules/Reset.js +61 -0
- package/dist/client-modules/Session.d.ts +6 -0
- package/dist/client-modules/Session.js +59 -0
- package/dist/client-modules/SignIn.d.ts +13 -0
- package/dist/client-modules/SignIn.js +58 -0
- package/dist/client-modules/Verify.d.ts +12 -0
- package/dist/client-modules/Verify.js +54 -0
- package/dist/client-modules/stage-token.d.ts +3 -0
- package/dist/client-modules/stage-token.js +9 -0
- package/dist/client.d.ts +27 -0
- package/dist/client.js +33 -0
- package/dist/context.d.ts +11 -0
- package/dist/context.js +11 -0
- package/dist/cookie.d.ts +15 -0
- package/dist/cookie.js +39 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +42 -0
- package/dist/links.d.ts +37 -0
- package/dist/links.js +77 -0
- package/dist/mail.d.ts +55 -0
- package/dist/mail.js +53 -0
- package/dist/modules/Check.d.ts +10 -0
- package/dist/modules/Check.js +20 -0
- package/dist/modules/Enter.d.ts +57 -0
- package/dist/modules/Enter.js +143 -0
- package/dist/modules/Gate.d.ts +6 -0
- package/dist/modules/Gate.js +43 -0
- package/dist/modules/Password.d.ts +34 -0
- package/dist/modules/Password.js +138 -0
- package/dist/modules/Roles.d.ts +32 -0
- package/dist/modules/Roles.js +135 -0
- package/dist/modules/Session.d.ts +39 -0
- package/dist/modules/Session.js +159 -0
- package/dist/modules/State.d.ts +8 -0
- package/dist/modules/State.js +22 -0
- package/dist/modules/Verify.d.ts +32 -0
- package/dist/modules/Verify.js +97 -0
- package/dist/names.d.ts +25 -0
- package/dist/names.js +44 -0
- package/dist/password.d.ts +4 -0
- package/dist/password.js +41 -0
- package/dist/props.d.ts +15 -0
- package/dist/props.js +35 -0
- package/dist/token.d.ts +3 -0
- package/dist/token.js +11 -0
- package/dist/users.d.ts +9 -0
- package/dist/users.js +12 -0
- package/errors.txt +29 -0
- package/package.json +62 -0
- package/src/auth-client.ts +531 -0
- package/src/client-modules/Reset.tsx +97 -0
- package/src/client-modules/Session.ts +70 -0
- package/src/client-modules/SignIn.tsx +110 -0
- package/src/client-modules/Verify.tsx +82 -0
- package/src/client-modules/stage-token.ts +11 -0
- package/src/client.ts +38 -0
- package/src/context.ts +21 -0
- package/src/cookie.ts +35 -0
- package/src/index.ts +53 -0
- package/src/links.ts +107 -0
- package/src/mail.ts +88 -0
- package/src/modules/Check.ts +31 -0
- package/src/modules/Enter.ts +189 -0
- package/src/modules/Gate.ts +47 -0
- package/src/modules/Password.ts +155 -0
- package/src/modules/Roles.ts +163 -0
- package/src/modules/Session.ts +196 -0
- package/src/modules/State.ts +30 -0
- package/src/modules/Verify.ts +118 -0
- package/src/names.ts +49 -0
- package/src/password.ts +43 -0
- package/src/props.ts +47 -0
- package/src/token.ts +15 -0
- package/src/users.ts +18 -0
- package/surface.txt +19 -0
- package/text.json +50 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ModuleProps } from '@aweftjs/modules';
|
|
2
|
+
import { type Refusal } from '@aweftjs/server';
|
|
3
|
+
import type { Store } from '@aweftjs/store';
|
|
4
|
+
import { type AuthContext } from '../context.ts';
|
|
5
|
+
export declare const deps: string[];
|
|
6
|
+
export declare const defaults: {
|
|
7
|
+
attemptsPerEmail: number;
|
|
8
|
+
attemptsPerAddress: number;
|
|
9
|
+
attemptsWindowMs: number;
|
|
10
|
+
hashesInFlight: number;
|
|
11
|
+
passwordMin: number;
|
|
12
|
+
passwordMax: number;
|
|
13
|
+
refusePassword: null;
|
|
14
|
+
refuseSignUp: null;
|
|
15
|
+
};
|
|
16
|
+
/** What `refuseSignUp` is asked about: a sign-up the route is about to make (design 291). */
|
|
17
|
+
export interface SignUp {
|
|
18
|
+
/** The address as it will be stored, normalised; nobody holds it yet. */
|
|
19
|
+
readonly email: string;
|
|
20
|
+
/** Every field of the request body but `email` and `password`: an invite token, a role picked on the form. */
|
|
21
|
+
readonly extra: Readonly<Record<string, unknown>>;
|
|
22
|
+
/** What the gate identified for the request: `user` is null, `address` is the peer's. */
|
|
23
|
+
readonly context: AuthContext;
|
|
24
|
+
/** The store the battery writes the user into, for a rule that reads a document of yours. */
|
|
25
|
+
readonly store: Store;
|
|
26
|
+
}
|
|
27
|
+
/** The application's sign-up rule: a refusal closes the door to that sign-up, nothing opens it. */
|
|
28
|
+
export type RefuseSignUp = (signUp: SignUp) => Refusal | undefined | Promise<Refusal | undefined>;
|
|
29
|
+
/** What a `user:<id>` document holds. `password` is the hash, never the password. */
|
|
30
|
+
export interface UserDocument extends Record<string, unknown> {
|
|
31
|
+
email: string;
|
|
32
|
+
name: string | null;
|
|
33
|
+
password: string;
|
|
34
|
+
emailVerified: boolean;
|
|
35
|
+
createdAt: number;
|
|
36
|
+
modifiedAt: number;
|
|
37
|
+
}
|
|
38
|
+
export type Entered = {
|
|
39
|
+
readonly user: string;
|
|
40
|
+
readonly created: boolean;
|
|
41
|
+
} | {
|
|
42
|
+
readonly refused: readonly Refusal[];
|
|
43
|
+
};
|
|
44
|
+
export interface Enter {
|
|
45
|
+
readonly public: true;
|
|
46
|
+
/** Sign in, or sign up when nobody has the email. Refuses a wrong password. */
|
|
47
|
+
enter(email: string, password: string): Promise<Entered>;
|
|
48
|
+
/**
|
|
49
|
+
* The reasons a password is refused: not text, outside the configured length, or refused by
|
|
50
|
+
* `refusePassword`. Empty when it is taken. What the sign-in route checks, for a route that
|
|
51
|
+
* sets a password elsewhere (design 290).
|
|
52
|
+
*/
|
|
53
|
+
checkPassword(password: unknown): Promise<readonly Refusal[]>;
|
|
54
|
+
readonly routes: Record<string, (request: Request, context: AuthContext) => Promise<Response>>;
|
|
55
|
+
}
|
|
56
|
+
declare const _default: ({ imports, config, ...props }: ModuleProps) => Enter;
|
|
57
|
+
export default _default;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// auth/Enter: sign in, or sign up when the email is new, and hand the browser its cookie
|
|
2
|
+
// (design 074). The route counts attempts, bounds the hashing in flight and the password's
|
|
3
|
+
// length before anything is hashed (design 275), and asks the application's sign-up rule at
|
|
4
|
+
// the door (design 291).
|
|
5
|
+
import { createId, idToText } from '@aweftjs/codec';
|
|
6
|
+
import { atomic } from '@aweftjs/core';
|
|
7
|
+
import { sliding } from '@aweftjs/server';
|
|
8
|
+
import { addressOf } from "../context.js";
|
|
9
|
+
import { hashPassword, verifyPassword } from "../password.js";
|
|
10
|
+
import { bodyOf, invalidConfig, json, numberOf, storeOf } from "../props.js";
|
|
11
|
+
import { findUser, idOfUserDoc, looksLikeEmail, normalEmail, userDoc } from "../users.js";
|
|
12
|
+
export const deps = ['auth/Session', 'auth/Roles'];
|
|
13
|
+
export const defaults = {
|
|
14
|
+
attemptsPerEmail: 5,
|
|
15
|
+
attemptsPerAddress: 20,
|
|
16
|
+
attemptsWindowMs: 900_000,
|
|
17
|
+
hashesInFlight: 8,
|
|
18
|
+
passwordMin: 8,
|
|
19
|
+
passwordMax: 256,
|
|
20
|
+
refusePassword: null,
|
|
21
|
+
refuseSignUp: null,
|
|
22
|
+
};
|
|
23
|
+
const MODULE = 'auth/Enter';
|
|
24
|
+
const refuse = (detail, fix) => invalidConfig(MODULE, detail, fix);
|
|
25
|
+
export default ({ imports, config, ...props }) => {
|
|
26
|
+
const store = storeOf(props);
|
|
27
|
+
const Session = imports.Session;
|
|
28
|
+
const Roles = imports.Roles;
|
|
29
|
+
const windowMs = numberOf(MODULE, config, 'attemptsWindowMs');
|
|
30
|
+
const perEmail = sliding({ count: numberOf(MODULE, config, 'attemptsPerEmail'), windowMs });
|
|
31
|
+
const perAddress = sliding({ count: numberOf(MODULE, config, 'attemptsPerAddress'), windowMs });
|
|
32
|
+
const hashesInFlight = numberOf(MODULE, config, 'hashesInFlight');
|
|
33
|
+
const passwordMin = numberOf(MODULE, config, 'passwordMin');
|
|
34
|
+
const passwordMax = numberOf(MODULE, config, 'passwordMax');
|
|
35
|
+
if (passwordMax < passwordMin)
|
|
36
|
+
throw refuse(`passwordMax ${String(passwordMax)} under passwordMin ${String(passwordMin)}`, 'Give passwordMax at least passwordMin.');
|
|
37
|
+
if (config.refusePassword !== null && typeof config.refusePassword !== 'function') {
|
|
38
|
+
throw refuse(`refusePassword ${JSON.stringify(config.refusePassword)}`, 'Give refusePassword a function of the password answering true to refuse it, or null.');
|
|
39
|
+
}
|
|
40
|
+
const refusePassword = config.refusePassword;
|
|
41
|
+
if (config.refuseSignUp !== null && typeof config.refuseSignUp !== 'function') {
|
|
42
|
+
throw refuse(`refuseSignUp ${JSON.stringify(config.refuseSignUp)}`, 'Give refuseSignUp a function of the sign-up answering a refusal to refuse it and nothing to allow it, or null.');
|
|
43
|
+
}
|
|
44
|
+
const refuseSignUp = config.refuseSignUp;
|
|
45
|
+
let hashing = 0;
|
|
46
|
+
// The two checks that cost nothing, before anything is counted or hashed.
|
|
47
|
+
const shapeOf = (password) => {
|
|
48
|
+
if (typeof password !== 'string' || password === '')
|
|
49
|
+
return { code: 'password', message: 'password is text' };
|
|
50
|
+
const length = [...password].length;
|
|
51
|
+
if (length < passwordMin || length > passwordMax) {
|
|
52
|
+
return { code: 'password', message: `password is ${String(passwordMin)} to ${String(passwordMax)} characters` };
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
};
|
|
56
|
+
const notAllowed = () => ({ code: 'password', message: 'that password is not allowed here' });
|
|
57
|
+
const checkPassword = async (password) => {
|
|
58
|
+
const shape = shapeOf(password);
|
|
59
|
+
if (shape !== undefined)
|
|
60
|
+
return [shape];
|
|
61
|
+
return refusePassword !== null && await refusePassword(password) ? [notAllowed()] : [];
|
|
62
|
+
};
|
|
63
|
+
const enter = async (email, password) => {
|
|
64
|
+
const found = await findUser(store, email);
|
|
65
|
+
if (found === undefined) {
|
|
66
|
+
const id = idToText(createId());
|
|
67
|
+
const hash = await hashPassword(password);
|
|
68
|
+
const handle = await store.open(userDoc(id));
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
atomic(() => {
|
|
71
|
+
Object.assign(handle.root, {
|
|
72
|
+
email: normalEmail(email), name: null, password: hash, emailVerified: false, createdAt: now, modifiedAt: now,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
await store.settled(handle);
|
|
76
|
+
await store.close(handle);
|
|
77
|
+
await Roles.first(id);
|
|
78
|
+
return { user: id, created: true };
|
|
79
|
+
}
|
|
80
|
+
const handle = await store.open(found);
|
|
81
|
+
const ok = await verifyPassword(password, handle.root.password);
|
|
82
|
+
await store.close(handle);
|
|
83
|
+
if (!ok)
|
|
84
|
+
return { refused: [{ code: 'password', message: 'the password is wrong' }] };
|
|
85
|
+
return { user: idOfUserDoc(found), created: false };
|
|
86
|
+
};
|
|
87
|
+
const tooMany = (retryAfter) => json(429, { reasons: [{ code: 'attempts', message: 'too many sign-in attempts; wait and try again' }] }, { 'retry-after': String(retryAfter) });
|
|
88
|
+
return {
|
|
89
|
+
public: true,
|
|
90
|
+
enter,
|
|
91
|
+
checkPassword,
|
|
92
|
+
routes: {
|
|
93
|
+
'POST /api/session': async (request, context) => {
|
|
94
|
+
const body = await bodyOf(request);
|
|
95
|
+
const email = body?.email;
|
|
96
|
+
const password = body?.password;
|
|
97
|
+
if (typeof email !== 'string' || !looksLikeEmail(normalEmail(email))) {
|
|
98
|
+
return json(400, { reasons: [{ code: 'email', message: 'email is an address' }] });
|
|
99
|
+
}
|
|
100
|
+
const shape = shapeOf(password);
|
|
101
|
+
if (shape !== undefined)
|
|
102
|
+
return json(400, { reasons: [shape] });
|
|
103
|
+
// Counted before anything is hashed, so a flood buys no hashing. The email's count
|
|
104
|
+
// is cleared on success below; the address's is not, since it counts requests.
|
|
105
|
+
const key = normalEmail(email);
|
|
106
|
+
const byAddress = perAddress.take(addressOf(context) ?? 'unknown');
|
|
107
|
+
if (!byAddress.ok)
|
|
108
|
+
return tooMany(byAddress.retryAfter);
|
|
109
|
+
const byEmail = perEmail.take(key);
|
|
110
|
+
if (!byEmail.ok)
|
|
111
|
+
return tooMany(byEmail.retryAfter);
|
|
112
|
+
if (refusePassword !== null && await refusePassword(password))
|
|
113
|
+
return json(400, { reasons: [notAllowed()] });
|
|
114
|
+
// The application's rule, asked of a sign-up only: a known email is a sign-in and there
|
|
115
|
+
// is no door to close. After the password rule, so a refused password spends no invite.
|
|
116
|
+
if (refuseSignUp !== null && await findUser(store, key) === undefined) {
|
|
117
|
+
const extra = Object.fromEntries(Object.entries(body ?? {}).filter(([name]) => name !== 'email' && name !== 'password'));
|
|
118
|
+
const refusal = await refuseSignUp({ email: key, extra, context, store });
|
|
119
|
+
if (refusal !== undefined)
|
|
120
|
+
return json(403, { reasons: [refusal] });
|
|
121
|
+
}
|
|
122
|
+
if (hashing >= hashesInFlight) {
|
|
123
|
+
return json(503, { reasons: [{ code: 'busy', message: 'too many sign-ins are being checked; try again in a moment' }] }, { 'retry-after': '1' });
|
|
124
|
+
}
|
|
125
|
+
hashing += 1;
|
|
126
|
+
let outcome;
|
|
127
|
+
try {
|
|
128
|
+
outcome = await enter(email, password);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
hashing -= 1;
|
|
132
|
+
}
|
|
133
|
+
if ('refused' in outcome)
|
|
134
|
+
return json(401, { reasons: outcome.refused });
|
|
135
|
+
perEmail.clear(key);
|
|
136
|
+
const token = await Session.issue(outcome.user);
|
|
137
|
+
return json(outcome.created ? 201 : 200, { user: outcome.user, created: outcome.created }, {
|
|
138
|
+
'set-cookie': Session.setCookie(token, request),
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ModuleProps } from '@aweftjs/modules';
|
|
2
|
+
import type { Gate as ServerGate } from '@aweftjs/server';
|
|
3
|
+
import { type AuthContext } from '../context.ts';
|
|
4
|
+
export declare const deps: string[];
|
|
5
|
+
declare const _default: ({ imports }: ModuleProps) => ServerGate<AuthContext>;
|
|
6
|
+
export default _default;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// auth/Gate: the gate that reads `public` and `needs` (designs 071, 074, 289).
|
|
2
|
+
import { userOf } from "../context.js";
|
|
3
|
+
import { isName } from "../names.js";
|
|
4
|
+
export const deps = ['auth/Session', 'auth/Roles'];
|
|
5
|
+
const isPublic = (instance) => instance !== null && typeof instance === 'object' && instance.public === true;
|
|
6
|
+
/**
|
|
7
|
+
* The names a module declares it needs: one, a list, or none. A declaration that is neither a
|
|
8
|
+
* name nor a list of names is `undefined`: the module is refused to everyone, because a word
|
|
9
|
+
* that reads as narrow and admits broadly is the wrong way to fail.
|
|
10
|
+
*/
|
|
11
|
+
const needsOf = (instance) => {
|
|
12
|
+
if (instance === null || typeof instance !== 'object' || !('needs' in instance))
|
|
13
|
+
return [];
|
|
14
|
+
const held = instance.needs;
|
|
15
|
+
if (held === undefined)
|
|
16
|
+
return [];
|
|
17
|
+
if (isName(held))
|
|
18
|
+
return [held];
|
|
19
|
+
return Array.isArray(held) && held.every(isName) ? held : undefined;
|
|
20
|
+
};
|
|
21
|
+
export default ({ imports }) => {
|
|
22
|
+
const Session = imports.Session;
|
|
23
|
+
const Roles = imports.Roles;
|
|
24
|
+
return {
|
|
25
|
+
identify: (request, peer) => Session.whoIs(request, peer),
|
|
26
|
+
access: async ({ name, instance }, context) => {
|
|
27
|
+
const user = userOf(context);
|
|
28
|
+
const needs = needsOf(instance);
|
|
29
|
+
if (needs === undefined)
|
|
30
|
+
return [{ code: 'needs', message: `${name} declares needs that is not a name or a list of names` }];
|
|
31
|
+
// A module that needs a name needs a person, whatever else it declares.
|
|
32
|
+
if (needs.length === 0 && isPublic(instance))
|
|
33
|
+
return [];
|
|
34
|
+
if (user === null)
|
|
35
|
+
return [{ code: 'private', message: `${name} needs a signed-in user` }];
|
|
36
|
+
for (const wanted of needs) {
|
|
37
|
+
if (!(await Roles.may(user, wanted)))
|
|
38
|
+
return [{ code: 'needs', message: `${name} needs ${wanted}` }];
|
|
39
|
+
}
|
|
40
|
+
return [];
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ModuleProps } from '@aweftjs/modules';
|
|
2
|
+
import { type Refusal } from '@aweftjs/server';
|
|
3
|
+
import { type AuthContext } from '../context.ts';
|
|
4
|
+
import { type Outcome } from '../mail.ts';
|
|
5
|
+
export declare const deps: string[];
|
|
6
|
+
export declare const defaults: {
|
|
7
|
+
subject: string;
|
|
8
|
+
url: null;
|
|
9
|
+
resetMs: number;
|
|
10
|
+
attemptsPerUser: number;
|
|
11
|
+
attemptsWindowMs: number;
|
|
12
|
+
forgotPerEmail: number;
|
|
13
|
+
forgotPerAddress: number;
|
|
14
|
+
forgotWindowMs: number;
|
|
15
|
+
sweepMs: number;
|
|
16
|
+
};
|
|
17
|
+
export type Reset = {
|
|
18
|
+
readonly user: string;
|
|
19
|
+
} | {
|
|
20
|
+
readonly refused: readonly Refusal[];
|
|
21
|
+
};
|
|
22
|
+
export interface Password {
|
|
23
|
+
readonly public: true;
|
|
24
|
+
/** Set a new password for a person who gave the current one. Every other session of theirs is ended. */
|
|
25
|
+
change(user: string, current: unknown, password: unknown, keep?: string): Promise<Outcome>;
|
|
26
|
+
/** Mail the person with this address a link, or nothing for an address nobody has; `ok` either way. */
|
|
27
|
+
forgot(email: string): Promise<Outcome>;
|
|
28
|
+
/** Take a link and set the password. Every session of the person is ended. Refuses `taken` for a link already used and `token` for one that never was or is past its end. */
|
|
29
|
+
reset(token: unknown, password: unknown): Promise<Reset>;
|
|
30
|
+
stop(): void;
|
|
31
|
+
readonly routes: Record<string, (request: Request, context: AuthContext) => Promise<Response>>;
|
|
32
|
+
}
|
|
33
|
+
declare const _default: ({ imports, config, ...props }: ModuleProps) => Password;
|
|
34
|
+
export default _default;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// auth/Password: change with the current password, forgot by mail, reset by the link (design 290).
|
|
2
|
+
import { atomic } from '@aweftjs/core';
|
|
3
|
+
import { sliding } from '@aweftjs/server';
|
|
4
|
+
import { addressOf, userOf } from "../context.js";
|
|
5
|
+
import { NOT_LIVE, TAKEN, links } from "../links.js";
|
|
6
|
+
import { mailLink, textOf, urlOf } from "../mail.js";
|
|
7
|
+
import { hashPassword, verifyPassword } from "../password.js";
|
|
8
|
+
import { bodyOf, json, numberOf, storeOf } from "../props.js";
|
|
9
|
+
import { findUser, idOfUserDoc, looksLikeEmail, normalEmail, userDoc } from "../users.js";
|
|
10
|
+
export const deps = ['auth/Session', 'auth/Enter', 'notify/Send'];
|
|
11
|
+
export const defaults = {
|
|
12
|
+
subject: 'Reset your password',
|
|
13
|
+
url: null,
|
|
14
|
+
resetMs: 3_600_000,
|
|
15
|
+
attemptsPerUser: 5,
|
|
16
|
+
attemptsWindowMs: 900_000,
|
|
17
|
+
forgotPerEmail: 5,
|
|
18
|
+
forgotPerAddress: 20,
|
|
19
|
+
forgotWindowMs: 86_400_000,
|
|
20
|
+
sweepMs: 3_600_000,
|
|
21
|
+
};
|
|
22
|
+
const MODULE = 'auth/Password';
|
|
23
|
+
const WRONG = { code: 'password', message: 'the current password is wrong' };
|
|
24
|
+
export default ({ imports, config, ...props }) => {
|
|
25
|
+
const store = storeOf(props);
|
|
26
|
+
const Session = imports.Session;
|
|
27
|
+
const Enter = imports.Enter;
|
|
28
|
+
const mailer = imports.Send;
|
|
29
|
+
const subject = textOf(MODULE, config, 'subject');
|
|
30
|
+
const url = urlOf(MODULE, config);
|
|
31
|
+
const attempts = sliding({ count: numberOf(MODULE, config, 'attemptsPerUser'), windowMs: numberOf(MODULE, config, 'attemptsWindowMs') });
|
|
32
|
+
const forgotWindowMs = numberOf(MODULE, config, 'forgotWindowMs');
|
|
33
|
+
const perEmail = sliding({ count: numberOf(MODULE, config, 'forgotPerEmail'), windowMs: forgotWindowMs });
|
|
34
|
+
const perAddress = sliding({ count: numberOf(MODULE, config, 'forgotPerAddress'), windowMs: forgotWindowMs });
|
|
35
|
+
const held = links(store, 'reset', numberOf(MODULE, config, 'resetMs'), numberOf(MODULE, config, 'sweepMs'));
|
|
36
|
+
const rewrite = async (user, password) => {
|
|
37
|
+
const hash = await hashPassword(password);
|
|
38
|
+
const handle = await store.open(userDoc(user));
|
|
39
|
+
atomic(() => {
|
|
40
|
+
const root = handle.root;
|
|
41
|
+
root.password = hash;
|
|
42
|
+
root.modifiedAt = Date.now();
|
|
43
|
+
});
|
|
44
|
+
await store.settled(handle);
|
|
45
|
+
await store.close(handle);
|
|
46
|
+
};
|
|
47
|
+
const change = async (user, current, password, keep) => {
|
|
48
|
+
if (await store.head(userDoc(user)) === 0)
|
|
49
|
+
return { refused: [WRONG] };
|
|
50
|
+
const handle = await store.open(userDoc(user));
|
|
51
|
+
const ok = typeof current === 'string' && await verifyPassword(current, handle.root.password);
|
|
52
|
+
await store.close(handle);
|
|
53
|
+
if (!ok)
|
|
54
|
+
return { refused: [WRONG] };
|
|
55
|
+
const refused = await Enter.checkPassword(password);
|
|
56
|
+
if (refused.length > 0)
|
|
57
|
+
return { refused };
|
|
58
|
+
await rewrite(user, password);
|
|
59
|
+
await Session.revokeAll(user, keep);
|
|
60
|
+
return { ok: true };
|
|
61
|
+
};
|
|
62
|
+
const forgot = async (email) => {
|
|
63
|
+
const found = await findUser(store, email);
|
|
64
|
+
if (found === undefined)
|
|
65
|
+
return { ok: true };
|
|
66
|
+
const user = idOfUserDoc(found);
|
|
67
|
+
const token = await held.issue(user);
|
|
68
|
+
const failed = await mailLink(mailer, user, subject, 'Set a new password by opening this link:', url(token));
|
|
69
|
+
return failed === undefined ? { ok: true } : { refused: [failed] };
|
|
70
|
+
};
|
|
71
|
+
// The link is looked at before the password is checked, so a stranger's guess at a token
|
|
72
|
+
// costs no `refusePassword` lookup, and taken after, so a refused password burns no link.
|
|
73
|
+
const reset = async (token, password) => {
|
|
74
|
+
const seen = await held.peek(token);
|
|
75
|
+
if (seen === undefined || 'taken' in seen)
|
|
76
|
+
return { refused: [seen === undefined ? NOT_LIVE : TAKEN] };
|
|
77
|
+
const refused = await Enter.checkPassword(password);
|
|
78
|
+
if (refused.length > 0)
|
|
79
|
+
return { refused };
|
|
80
|
+
const link = await held.take(token);
|
|
81
|
+
if (link === undefined || 'taken' in link)
|
|
82
|
+
return { refused: [link === undefined ? NOT_LIVE : TAKEN] };
|
|
83
|
+
const { user } = link;
|
|
84
|
+
await rewrite(user, password);
|
|
85
|
+
await Session.revokeAll(user);
|
|
86
|
+
return { user };
|
|
87
|
+
};
|
|
88
|
+
const tooMany = (retryAfter) => json(429, { reasons: [{ code: 'attempts', message: 'too many attempts; wait and try again' }] }, { 'retry-after': String(retryAfter) });
|
|
89
|
+
return {
|
|
90
|
+
public: true,
|
|
91
|
+
change,
|
|
92
|
+
forgot,
|
|
93
|
+
reset,
|
|
94
|
+
stop: () => { held.stop(); },
|
|
95
|
+
routes: {
|
|
96
|
+
'POST /api/password': async (request, context) => {
|
|
97
|
+
const user = userOf(context);
|
|
98
|
+
if (user === null)
|
|
99
|
+
return json(401, { reasons: [{ code: 'private', message: 'sign in to change your password' }] });
|
|
100
|
+
// The current password is a password being guessed, so it is counted like a sign-in.
|
|
101
|
+
const taken = attempts.take(user);
|
|
102
|
+
if (!taken.ok)
|
|
103
|
+
return tooMany(taken.retryAfter);
|
|
104
|
+
const body = await bodyOf(request);
|
|
105
|
+
const outcome = await change(user, body?.current, body?.password, context.session ?? undefined);
|
|
106
|
+
if ('refused' in outcome)
|
|
107
|
+
return json(outcome.refused[0] === WRONG ? 401 : 400, { reasons: outcome.refused });
|
|
108
|
+
attempts.clear(user);
|
|
109
|
+
return json(200, outcome);
|
|
110
|
+
},
|
|
111
|
+
'POST /api/password/forgot': async (request, context) => {
|
|
112
|
+
const body = await bodyOf(request);
|
|
113
|
+
const email = body?.email;
|
|
114
|
+
if (typeof email !== 'string' || !looksLikeEmail(normalEmail(email))) {
|
|
115
|
+
return json(400, { reasons: [{ code: 'email', message: 'email is an address' }] });
|
|
116
|
+
}
|
|
117
|
+
// Counted before the lookup, because the send is what a stranger must not steer.
|
|
118
|
+
const byAddress = perAddress.take(addressOf(context) ?? 'unknown');
|
|
119
|
+
if (!byAddress.ok)
|
|
120
|
+
return tooMany(byAddress.retryAfter);
|
|
121
|
+
const byEmail = perEmail.take(normalEmail(email));
|
|
122
|
+
if (!byEmail.ok)
|
|
123
|
+
return tooMany(byEmail.retryAfter);
|
|
124
|
+
const outcome = await forgot(email);
|
|
125
|
+
if ('refused' in outcome)
|
|
126
|
+
return json(502, { reasons: outcome.refused });
|
|
127
|
+
return json(200, outcome);
|
|
128
|
+
},
|
|
129
|
+
'POST /api/password/reset': async (request) => {
|
|
130
|
+
const body = await bodyOf(request);
|
|
131
|
+
const outcome = await reset(body?.token, body?.password);
|
|
132
|
+
if ('refused' in outcome)
|
|
133
|
+
return json(400, { reasons: outcome.refused });
|
|
134
|
+
return json(200, outcome);
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ModuleProps } from '@aweftjs/modules';
|
|
2
|
+
import type { Connection } from '@aweftjs/server';
|
|
3
|
+
import { type AuthContext } from '../context.ts';
|
|
4
|
+
import { type Implies } from '../names.ts';
|
|
5
|
+
export declare const defaults: {
|
|
6
|
+
implies: {};
|
|
7
|
+
first: never[];
|
|
8
|
+
};
|
|
9
|
+
/** What a `roles:<user>` document holds. */
|
|
10
|
+
export interface RolesDocument extends Record<string, unknown> {
|
|
11
|
+
names: string[];
|
|
12
|
+
modifiedAt: number;
|
|
13
|
+
}
|
|
14
|
+
export interface Roles {
|
|
15
|
+
/** Does the person hold the name: granted, covered by a granted name, or implied by one. Reads the store. False for text that is not a name, since nobody holds one. */
|
|
16
|
+
may(user: string, name: string): Promise<boolean>;
|
|
17
|
+
/** Give the person these names. A name already held is left as it is. */
|
|
18
|
+
grant(user: string, ...names: string[]): Promise<void>;
|
|
19
|
+
/** Take these names away. A name not held is nothing. */
|
|
20
|
+
revoke(user: string, ...names: string[]): Promise<void>;
|
|
21
|
+
/** What the person was granted, and nothing implied. */
|
|
22
|
+
names(user: string): Promise<string[]>;
|
|
23
|
+
/** Grant the configured `first` names when nobody signed up before this person. True when they were the first. */
|
|
24
|
+
first(user: string): Promise<boolean>;
|
|
25
|
+
/** The table, for a page that runs the same check. */
|
|
26
|
+
call(args: unknown, context: unknown): {
|
|
27
|
+
implies: Implies;
|
|
28
|
+
};
|
|
29
|
+
connection(connection: Connection<AuthContext>): Promise<() => Promise<void>>;
|
|
30
|
+
}
|
|
31
|
+
declare const _default: ({ config, ...props }: ModuleProps) => Promise<Roles>;
|
|
32
|
+
export default _default;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// auth/Roles: the names a person holds, kept in `roles:<user>` and read live (design 289).
|
|
2
|
+
import { codecError } from '@aweftjs/codec';
|
|
3
|
+
import { atomic, createArray } from '@aweftjs/core';
|
|
4
|
+
import { userOf } from "../context.js";
|
|
5
|
+
import { holds, isName } from "../names.js";
|
|
6
|
+
import { notGated, storeOf } from "../props.js";
|
|
7
|
+
export const defaults = { implies: {}, first: [] };
|
|
8
|
+
const refuse = (detail, fix) => codecError('invalid-config', `auth/Roles was given ${detail}`, fix);
|
|
9
|
+
const NAME_FIX = 'A name is non-empty text with no whitespace: admin, verified, products.abc123.';
|
|
10
|
+
const LIST_FIX = 'Give first, and each entry of implies, a list of names: non-empty text with no whitespace.';
|
|
11
|
+
const namesOf = (value, what) => {
|
|
12
|
+
if (!Array.isArray(value) || !value.every(isName))
|
|
13
|
+
throw refuse(`${what} ${JSON.stringify(value)}`, LIST_FIX);
|
|
14
|
+
return value;
|
|
15
|
+
};
|
|
16
|
+
const impliesOf = (value) => {
|
|
17
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
18
|
+
throw refuse(`implies ${JSON.stringify(value)}`, 'Give implies an object from a name to the list of names it implies.');
|
|
19
|
+
}
|
|
20
|
+
// No prototype, so a key such as `__proto__` is a name in the table and not a write to Object.
|
|
21
|
+
const table = Object.create(null);
|
|
22
|
+
for (const [key, listed] of Object.entries(value)) {
|
|
23
|
+
if (!isName(key))
|
|
24
|
+
throw refuse(`the implies key ${JSON.stringify(key)}`, NAME_FIX);
|
|
25
|
+
table[key] = namesOf(listed, `implies.${key}`);
|
|
26
|
+
}
|
|
27
|
+
return table;
|
|
28
|
+
};
|
|
29
|
+
const invalidName = (name) => codecError('invalid-name', `auth/Roles was handed the name ${JSON.stringify(name)}`, NAME_FIX);
|
|
30
|
+
const invalidUser = (user) => codecError('invalid-user', `auth/Roles was handed the user ${JSON.stringify(user)}`, 'Hand it the id the gate put on the context, which is text.');
|
|
31
|
+
/** The one document this module keeps that is nobody's: whether a first sign-up has been seen. */
|
|
32
|
+
const FIRST = 'auth:first';
|
|
33
|
+
/** The page's write, refused: the names are the server's to give. */
|
|
34
|
+
const READ_ONLY = { accept: () => [{ code: 'read-only', message: 'names are granted by the server; a page reads them' }] };
|
|
35
|
+
export default async ({ config, ...props }) => {
|
|
36
|
+
const store = storeOf(props);
|
|
37
|
+
const implies = impliesOf(config.implies);
|
|
38
|
+
const first = namesOf(config.first, 'first');
|
|
39
|
+
const doc = (user) => `roles:${user}`;
|
|
40
|
+
const checked = (user, names) => {
|
|
41
|
+
if (typeof user !== 'string' || user === '')
|
|
42
|
+
throw invalidUser(user);
|
|
43
|
+
for (const name of names)
|
|
44
|
+
if (!isName(name))
|
|
45
|
+
throw invalidName(name);
|
|
46
|
+
return names;
|
|
47
|
+
};
|
|
48
|
+
const writeFirst = async (granted) => {
|
|
49
|
+
const marker = await store.open(FIRST);
|
|
50
|
+
const root = marker.root;
|
|
51
|
+
// Two sign-ups in flight open the same live document, so the first to get here writes
|
|
52
|
+
// it and the second reads it written.
|
|
53
|
+
const mine = root.granted === undefined;
|
|
54
|
+
if (mine)
|
|
55
|
+
atomic(() => { Object.assign(root, { granted, at: Date.now() }); });
|
|
56
|
+
await store.settled(marker);
|
|
57
|
+
await store.close(marker);
|
|
58
|
+
return mine;
|
|
59
|
+
};
|
|
60
|
+
// The marker says whether a first sign-up has been seen. Where people exist and no marker
|
|
61
|
+
// does, this module was added to a store that already had them, and the next stranger to
|
|
62
|
+
// sign up is not the first: the index is read once, here, and the marker written for nobody.
|
|
63
|
+
if (await store.head(FIRST) === 0) {
|
|
64
|
+
const hits = await store.find({ where: [{ field: 'email', op: 'gt', value: '' }] });
|
|
65
|
+
if (hits.some((hit) => hit.doc.startsWith('user:')))
|
|
66
|
+
await writeFirst(null);
|
|
67
|
+
}
|
|
68
|
+
// `open` creates a document, and a check must not write one per person it asks about.
|
|
69
|
+
const granted = async (user) => {
|
|
70
|
+
if (await store.head(doc(user)) === 0)
|
|
71
|
+
return [];
|
|
72
|
+
const handle = await store.open(doc(user));
|
|
73
|
+
const held = [...(handle.root.names ?? [])];
|
|
74
|
+
await store.close(handle);
|
|
75
|
+
return held;
|
|
76
|
+
};
|
|
77
|
+
const write = async (user, change) => {
|
|
78
|
+
const handle = await store.open(doc(user));
|
|
79
|
+
const root = handle.root;
|
|
80
|
+
atomic(() => {
|
|
81
|
+
if (root.names === undefined)
|
|
82
|
+
root.names = createArray();
|
|
83
|
+
change(root.names);
|
|
84
|
+
root.modifiedAt = Date.now();
|
|
85
|
+
});
|
|
86
|
+
await store.settled(handle);
|
|
87
|
+
await store.close(handle);
|
|
88
|
+
};
|
|
89
|
+
const grant = async (user, ...names) => {
|
|
90
|
+
const wanted = checked(user, names);
|
|
91
|
+
if (wanted.length === 0)
|
|
92
|
+
return;
|
|
93
|
+
await write(user, (held) => {
|
|
94
|
+
for (const name of wanted)
|
|
95
|
+
if (!held.includes(name))
|
|
96
|
+
held.push(name);
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
const firstOf = async (user) => {
|
|
100
|
+
checked(user, []);
|
|
101
|
+
if (await store.head(FIRST) !== 0)
|
|
102
|
+
return false;
|
|
103
|
+
if (!(await writeFirst(user)))
|
|
104
|
+
return false;
|
|
105
|
+
await grant(user, ...first);
|
|
106
|
+
return true;
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
// A name built from a client's input can come out as no name at all, and the answer to
|
|
110
|
+
// "does anyone hold that" is no, not a fault.
|
|
111
|
+
may: async (user, name) => { checked(user, []); return isName(name) && holds(await granted(user), implies, name); },
|
|
112
|
+
grant,
|
|
113
|
+
revoke: async (user, ...names) => {
|
|
114
|
+
const unwanted = checked(user, names);
|
|
115
|
+
if (unwanted.length === 0 || await store.head(doc(user)) === 0)
|
|
116
|
+
return;
|
|
117
|
+
await write(user, (held) => {
|
|
118
|
+
for (let at = held.length - 1; at >= 0; at -= 1)
|
|
119
|
+
if (unwanted.includes(held[at]))
|
|
120
|
+
held.splice(at, 1);
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
names: async (user) => { checked(user, []); return granted(user); },
|
|
124
|
+
first: firstOf,
|
|
125
|
+
call: () => ({ implies }),
|
|
126
|
+
connection: async ({ link, context }) => {
|
|
127
|
+
const user = userOf(context);
|
|
128
|
+
if (user === null)
|
|
129
|
+
throw notGated();
|
|
130
|
+
const handle = await store.open(doc(user));
|
|
131
|
+
link.share('roles', handle.root, READ_ONLY);
|
|
132
|
+
return async () => { await store.close(handle); };
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ModuleProps } from '@aweftjs/modules';
|
|
2
|
+
import type { Identified, Peer } from '@aweftjs/server';
|
|
3
|
+
import { type AuthContext } from '../context.ts';
|
|
4
|
+
export declare const defaults: {
|
|
5
|
+
cookie: string;
|
|
6
|
+
keep: number;
|
|
7
|
+
sweepMs: number;
|
|
8
|
+
};
|
|
9
|
+
/** What a `session:<token>` document holds. */
|
|
10
|
+
export interface SessionDocument extends Record<string, unknown> {
|
|
11
|
+
user: string;
|
|
12
|
+
/** When it stopped, or stops, being valid: the lifetime's end, the moment of revocation, or null for never. */
|
|
13
|
+
expires: number | null;
|
|
14
|
+
status: 'active' | 'revoked';
|
|
15
|
+
createdAt: number;
|
|
16
|
+
}
|
|
17
|
+
export interface Session {
|
|
18
|
+
readonly public: true;
|
|
19
|
+
/** Mint a session for a user. Returns its token, which is the cookie's value. */
|
|
20
|
+
issue(user: string): Promise<string>;
|
|
21
|
+
/** End a session. True when it was active. */
|
|
22
|
+
revoke(token: string): Promise<boolean>;
|
|
23
|
+
/** End every active session of a user but the one named. Returns how many it ended. */
|
|
24
|
+
revokeAll(user: string, except?: string): Promise<number>;
|
|
25
|
+
/** Who a request is, from its cookies, and where it came from. Always a context: this gate refuses nobody at the door. */
|
|
26
|
+
whoIs(request: Request, peer?: Peer): Promise<Identified<AuthContext>>;
|
|
27
|
+
/** The `Set-Cookie` value that sets the cookie to a token, or clears it for null. */
|
|
28
|
+
setCookie(token: string | null, request: Request): string;
|
|
29
|
+
/** Who the asking connection is. The module is public, so an anonymous one hears `{ user: null }`. */
|
|
30
|
+
call(args: unknown, context: unknown): {
|
|
31
|
+
user: string | null;
|
|
32
|
+
};
|
|
33
|
+
/** Remove every session that has been over for longer than `keep` days. Returns how many. */
|
|
34
|
+
sweep(): Promise<number>;
|
|
35
|
+
stop(): Promise<void>;
|
|
36
|
+
readonly routes: Record<string, (request: Request, context: AuthContext) => Promise<Response>>;
|
|
37
|
+
}
|
|
38
|
+
declare const _default: ({ config, ...props }: ModuleProps) => Promise<Session>;
|
|
39
|
+
export default _default;
|