@ultimabase/password 0.1.2 → 0.2.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 +20 -0
- package/dist/core.d.ts +7 -3
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +16 -8
- package/dist/email-field.d.ts +35 -0
- package/dist/email-field.d.ts.map +1 -0
- package/dist/email-field.js +77 -0
- package/dist/email-functions.d.ts.map +1 -1
- package/dist/email-functions.js +48 -6
- package/dist/input.d.ts +12 -0
- package/dist/input.d.ts.map +1 -1
- package/dist/input.js +7 -0
- package/dist/models.d.ts +8 -4
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +22 -8
- package/dist/plugin.d.ts +14 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +62 -12
- package/dist/tokens.d.ts +29 -10
- package/dist/tokens.d.ts.map +1 -1
- package/dist/tokens.js +10 -5
- package/package.json +8 -8
- package/src/core.ts +24 -8
- package/src/email-field.ts +104 -0
- package/src/email-functions.ts +65 -8
- package/src/input.ts +26 -0
- package/src/models.ts +33 -10
- package/src/plugin.ts +94 -14
- package/src/tokens.ts +34 -15
package/dist/plugin.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { authPlugin } from '@ultimabase/auth';
|
|
2
2
|
import { definePlugin, isMaster, master, PublicError, Refused, } from 'ultimabase';
|
|
3
3
|
import manifest from '../package.json' with { type: 'json' };
|
|
4
|
-
import { attemptOf, createUser, setPassword, signedIn, verified, } from './core.js';
|
|
4
|
+
import { attemptOf, checkCurrent, createUser, setPassword, signedIn, verified, } from './core.js';
|
|
5
|
+
import { created, committed, creating, readsEmail, updated, updating, } from './email-field.js';
|
|
5
6
|
import { emailFunctions, mailerOf } from './email-functions.js';
|
|
6
7
|
import { passwordFunctions } from './functions.js';
|
|
7
8
|
import { emailOf, fieldProperty, passwordOf, valid } from './input.js';
|
|
@@ -17,6 +18,14 @@ function stateOf(auth) {
|
|
|
17
18
|
throw new Error('password has not started.');
|
|
18
19
|
return state;
|
|
19
20
|
}
|
|
21
|
+
/** Deletes the user's hash, links and email requests (D-113, D-160). */
|
|
22
|
+
async function forget(userId) {
|
|
23
|
+
const where = { userId };
|
|
24
|
+
await PasswordHash.deleteMany({ where }, M);
|
|
25
|
+
await PasswordToken.deleteMany({ where }, M);
|
|
26
|
+
const emails = { kind: 'email', subject: userId };
|
|
27
|
+
await PasswordRequest.deleteMany({ where: emails }, M);
|
|
28
|
+
}
|
|
20
29
|
/** The one model whose `use` lists `password()`. */
|
|
21
30
|
function usersOf(models) {
|
|
22
31
|
// oxlint-disable-next-line typescript/unbound-method -- compared by identity, never called.
|
|
@@ -82,6 +91,14 @@ function configOf(options, models) {
|
|
|
82
91
|
return { users, fields };
|
|
83
92
|
}
|
|
84
93
|
const callerOf = (options) => options?.as ?? { kind: 'anonymous' };
|
|
94
|
+
/** Refuses `command` on user `id` but as Master or as that user. */
|
|
95
|
+
function selfOrMaster(options, id, command) {
|
|
96
|
+
const caller = callerOf(options);
|
|
97
|
+
const self = caller.kind === 'user' && caller.user.id === id;
|
|
98
|
+
if (!self && !isMaster(caller)) {
|
|
99
|
+
throw new PublicError('ACCESS_DENIED', `User.${command} runs as Master or as the user.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
85
102
|
const definition = definePlugin({
|
|
86
103
|
name: 'password',
|
|
87
104
|
manifest,
|
|
@@ -92,15 +109,31 @@ const definition = definePlugin({
|
|
|
92
109
|
emailVerified: f.boolean({ default: false }).with({
|
|
93
110
|
access: { create: () => false, update: () => false },
|
|
94
111
|
}),
|
|
112
|
+
// Only Master writes it; the hooks keep the account in step (D-162).
|
|
113
|
+
email: f.string({ optional: true, unique: true }).with({
|
|
114
|
+
access: { read: readsEmail, create: () => false, update: () => false },
|
|
115
|
+
}),
|
|
95
116
|
}),
|
|
96
117
|
hooks: {
|
|
118
|
+
// The email and the password account stay one (D-162).
|
|
119
|
+
beforeCreate: ({ data }) => {
|
|
120
|
+
creating(data);
|
|
121
|
+
},
|
|
122
|
+
afterCreate: async ({ record, plugins }) => {
|
|
123
|
+
await created(stateOf(plugins.auth), record);
|
|
124
|
+
},
|
|
125
|
+
beforeUpdate: async ({ record, data, plugins }) => {
|
|
126
|
+
await updating(stateOf(plugins.auth), record, data);
|
|
127
|
+
},
|
|
128
|
+
afterUpdate: async ({ record, previous, plugins }) => {
|
|
129
|
+
await updated(stateOf(plugins.auth), record, previous);
|
|
130
|
+
},
|
|
131
|
+
afterUpdateCommit: ({ record, previous, plugins }) => {
|
|
132
|
+
committed(stateOf(plugins.auth), record, previous);
|
|
133
|
+
},
|
|
97
134
|
// The user's hash and tokens go with them (D-113, D-117).
|
|
98
135
|
afterDelete: async ({ record }) => {
|
|
99
|
-
|
|
100
|
-
await PasswordHash.deleteMany({ where }, M);
|
|
101
|
-
await PasswordToken.deleteMany({ where }, M);
|
|
102
|
-
const emails = { kind: 'email', subject: record.id };
|
|
103
|
-
await PasswordRequest.deleteMany({ where: emails }, M);
|
|
136
|
+
await forget(record.id);
|
|
104
137
|
},
|
|
105
138
|
},
|
|
106
139
|
commands: ({ queries, plugins }) => {
|
|
@@ -124,14 +157,16 @@ const definition = definePlugin({
|
|
|
124
157
|
return opened;
|
|
125
158
|
},
|
|
126
159
|
setPassword: async ({ id, password }, options) => {
|
|
127
|
-
|
|
128
|
-
const self = caller.kind === 'user' && caller.user.id === id;
|
|
129
|
-
if (!self && !isMaster(caller)) {
|
|
130
|
-
throw new PublicError('ACCESS_DENIED', 'User.setPassword runs as Master or as the user.');
|
|
131
|
-
}
|
|
160
|
+
selfOrMaster(options, id, 'setPassword');
|
|
132
161
|
const state = stateOf(plugins.auth);
|
|
133
162
|
await setPassword(state, id, valid(passwordOf, password, 'password'));
|
|
134
163
|
},
|
|
164
|
+
checkPassword: async ({ id, password }, options) => {
|
|
165
|
+
selfOrMaster(options, id, 'checkPassword');
|
|
166
|
+
const client = options?.client;
|
|
167
|
+
// Counted without a client too: the check guards an action (D-161).
|
|
168
|
+
await checkCurrent(id, password, attemptOf(id, client) ?? { subject: id });
|
|
169
|
+
},
|
|
135
170
|
};
|
|
136
171
|
},
|
|
137
172
|
// The functions are built before the APIs (D-89): they read the state
|
|
@@ -140,7 +175,22 @@ const definition = definePlugin({
|
|
|
140
175
|
const { users, fields } = configOf(options, models);
|
|
141
176
|
const signUp = fields === false ? false : new Set(fields.keys());
|
|
142
177
|
const { auth } = plugins;
|
|
143
|
-
|
|
178
|
+
const { sendEmail } = options;
|
|
179
|
+
const sending = new Set();
|
|
180
|
+
states.set(auth, { auth, users, signUp, logger, sending, sendEmail });
|
|
181
|
+
// A closed account keeps its row, without a password, an email or a
|
|
182
|
+
// verified one (D-160, D-162).
|
|
183
|
+
auth.onCloseAccount(async (userId) => {
|
|
184
|
+
await forget(userId);
|
|
185
|
+
const where = { id: userId };
|
|
186
|
+
const user = await users.queries.findUnique({ where }, M);
|
|
187
|
+
if (user === null)
|
|
188
|
+
return;
|
|
189
|
+
if (user.email === null && user.emailVerified !== true)
|
|
190
|
+
return;
|
|
191
|
+
const data = { email: null, emailVerified: false };
|
|
192
|
+
await users.queries.update({ where, data }, M);
|
|
193
|
+
});
|
|
144
194
|
},
|
|
145
195
|
// The emails under way still query: they end before the database closes.
|
|
146
196
|
stop: async ({ plugins }) => {
|
package/dist/tokens.d.ts
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import type { Field, ModelOf, ScalarOptions } from 'ultimabase';
|
|
2
2
|
import { PublicError } from 'ultimabase';
|
|
3
3
|
/** What an email's token lets its holder do. */
|
|
4
|
-
export type EmailKind = 'reset' | 'verify';
|
|
5
|
-
/**
|
|
6
|
-
|
|
4
|
+
export type EmailKind = 'reset' | 'verify' | 'emailChange';
|
|
5
|
+
/**
|
|
6
|
+
* What `sendEmail` receives: the application writes the text and the link.
|
|
7
|
+
* `'emailChanged'` tells the old address its email changed (D-162).
|
|
8
|
+
*/
|
|
9
|
+
export type Email = {
|
|
7
10
|
readonly kind: EmailKind;
|
|
8
|
-
/** The
|
|
11
|
+
/** The address the link goes to, normalized. */
|
|
9
12
|
readonly to: string;
|
|
10
|
-
/** What the link carries, for
|
|
13
|
+
/** What the link carries, for the function its kind names. */
|
|
11
14
|
readonly token: string;
|
|
12
15
|
readonly userId: string;
|
|
13
|
-
}
|
|
16
|
+
} | {
|
|
17
|
+
readonly kind: 'emailChanged';
|
|
18
|
+
/** The old address. */
|
|
19
|
+
readonly to: string;
|
|
20
|
+
readonly newEmail: string;
|
|
21
|
+
readonly userId: string;
|
|
22
|
+
};
|
|
14
23
|
type TokenFields = {
|
|
15
24
|
readonly userId: Field<string, ScalarOptions<string>>;
|
|
16
25
|
readonly kind: Field<string, ScalarOptions<string>>;
|
|
@@ -19,12 +28,22 @@ type TokenFields = {
|
|
|
19
28
|
readonly unique: true;
|
|
20
29
|
}>;
|
|
21
30
|
readonly expiresAt: Field<Date, ScalarOptions<Date>>;
|
|
31
|
+
/** The address the email went to (D-162). */
|
|
32
|
+
readonly email: Field<string | null, {
|
|
33
|
+
readonly optional: true;
|
|
34
|
+
}>;
|
|
22
35
|
};
|
|
23
36
|
export declare const PasswordToken: ModelOf<'PasswordToken', TokenFields, []>;
|
|
24
|
-
/** A new token of `kind` for `userId`,
|
|
25
|
-
export declare function issueToken(userId: string, kind: EmailKind): Promise<string>;
|
|
26
|
-
/**
|
|
27
|
-
|
|
37
|
+
/** A new token of `kind` for `userId`, sent to `email`; it replaces the older one. */
|
|
38
|
+
export declare function issueToken(userId: string, kind: EmailKind, email: string): Promise<string>;
|
|
39
|
+
/**
|
|
40
|
+
* The user a token of `kind` was issued to, and the address it was sent to;
|
|
41
|
+
* a wrong, used or expired one fails.
|
|
42
|
+
*/
|
|
43
|
+
export declare function redeemToken(token: string, kind: EmailKind): Promise<{
|
|
44
|
+
readonly userId: string;
|
|
45
|
+
readonly email: string | null;
|
|
46
|
+
}>;
|
|
28
47
|
/**
|
|
29
48
|
* Counts an email to `userId`; past three within one hour, the refusal
|
|
30
49
|
* instead, and the email is not counted.
|
package/dist/tokens.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC/D,OAAO,EAAuB,WAAW,EAAE,MAAM,YAAY,CAAA;AAO7D,gDAAgD;AAChD,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,QAAQ,CAAA;
|
|
1
|
+
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC/D,OAAO,EAAuB,WAAW,EAAE,MAAM,YAAY,CAAA;AAO7D,gDAAgD;AAChD,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,QAAQ,GAAG,aAAa,CAAA;AAE1D;;;GAGG;AACH,MAAM,MAAM,KAAK,GACd;IACA,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;IACxB,gDAAgD;IAChD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,8DAA8D;IAC9D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACtB,GACD;IACA,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAA;IAC7B,uBAAuB;IACvB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACtB,CAAA;AAQJ,KAAK,WAAW,GAAG;IAClB,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACrD,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACnD,oEAAoE;IACpE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAA;KAAE,CAAC,CAAA;IACvD,QAAQ,CAAC,SAAS,EAAE,KAAK,CAAC,IAAI,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC,CAAA;IACpD,6CAA6C;IAC7C,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,GAAG,IAAI,EAAE;QAAE,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAA;KAAE,CAAC,CAAA;CACjE,CAAA;AAED,eAAO,MAAM,aAAa,EAAE,OAAO,CAAC,eAAe,EAAE,WAAW,EAAE,EAAE,CAWjE,CAAA;AAKH,sFAAsF;AACtF,wBAAsB,UAAU,CAC/B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,SAAS,EACf,KAAK,EAAE,MAAM,GACX,OAAO,CAAC,MAAM,CAAC,CAcjB;AAKD;;;GAGG;AACH,wBAAsB,WAAW,CAChC,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,SAAS,GACb,OAAO,CAAC;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,CAUrE;AAED;;;GAGG;AACH,eAAO,MAAM,YAAY,WAChB,MAAM,KACZ,OAAO,CAAC,WAAW,GAAG,SAAS,CAAoC,CAAA"}
|
package/dist/tokens.js
CHANGED
|
@@ -9,6 +9,7 @@ const emails = 3;
|
|
|
9
9
|
const lifetimes = {
|
|
10
10
|
reset: hour,
|
|
11
11
|
verify: 24 * hour,
|
|
12
|
+
emailChange: hour,
|
|
12
13
|
};
|
|
13
14
|
export const PasswordToken = defineModel({
|
|
14
15
|
name: 'PasswordToken',
|
|
@@ -17,22 +18,26 @@ export const PasswordToken = defineModel({
|
|
|
17
18
|
kind: f.string(),
|
|
18
19
|
hash: f.string({ unique: true }),
|
|
19
20
|
expiresAt: f.dateTime(),
|
|
21
|
+
email: f.string({ optional: true }),
|
|
20
22
|
}),
|
|
21
23
|
indexes: [{ fields: ['userId', 'kind'] }, { fields: ['expiresAt'] }],
|
|
22
24
|
});
|
|
23
25
|
const hashOf = (token) => createHash('sha256').update(token).digest('base64url');
|
|
24
|
-
/** A new token of `kind` for `userId`,
|
|
25
|
-
export async function issueToken(userId, kind) {
|
|
26
|
+
/** A new token of `kind` for `userId`, sent to `email`; it replaces the older one. */
|
|
27
|
+
export async function issueToken(userId, kind, email) {
|
|
26
28
|
const now = Date.now();
|
|
27
29
|
await PasswordToken.deleteMany({ where: { expiresAt: { lte: new Date(now) } } }, M);
|
|
28
30
|
await PasswordToken.deleteMany({ where: { userId, kind } }, M);
|
|
29
31
|
const token = randomBytes(32).toString('base64url');
|
|
30
32
|
const expiresAt = new Date(now + lifetimes[kind]);
|
|
31
|
-
await PasswordToken.create({ data: { userId, kind, hash: hashOf(token), expiresAt } }, M);
|
|
33
|
+
await PasswordToken.create({ data: { userId, kind, hash: hashOf(token), expiresAt, email } }, M);
|
|
32
34
|
return token;
|
|
33
35
|
}
|
|
34
36
|
const invalidLink = () => new PublicError('INVALID_CREDENTIALS', 'This link is invalid or has expired.');
|
|
35
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* The user a token of `kind` was issued to, and the address it was sent to;
|
|
39
|
+
* a wrong, used or expired one fails.
|
|
40
|
+
*/
|
|
36
41
|
export async function redeemToken(token, kind) {
|
|
37
42
|
const hash = hashOf(token);
|
|
38
43
|
const stored = await PasswordToken.findUnique({ where: { hash } }, M);
|
|
@@ -43,7 +48,7 @@ export async function redeemToken(token, kind) {
|
|
|
43
48
|
if (count === 0 || stored.expiresAt.getTime() <= Date.now()) {
|
|
44
49
|
throw invalidLink();
|
|
45
50
|
}
|
|
46
|
-
return stored.userId;
|
|
51
|
+
return { userId: stored.userId, email: stored.email };
|
|
47
52
|
}
|
|
48
53
|
/**
|
|
49
54
|
* Counts an email to `userId`; past three within one hour, the refusal
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimabase/password",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Passwords for UltimaBase: sign-up, sign-in and password changes, hashed with Argon2id.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -34,18 +34,18 @@
|
|
|
34
34
|
"ipaddr.js": "2.5.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@ultimabase/auth": "^0.
|
|
38
|
-
"ultimabase": "^0.
|
|
37
|
+
"@ultimabase/auth": "^0.2.0",
|
|
38
|
+
"ultimabase": "^0.2.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/pg": "8.23.1",
|
|
42
|
-
"@ultimabase/auth": "0.
|
|
43
|
-
"@ultimabase/graphql": "0.
|
|
44
|
-
"@ultimabase/http": "0.
|
|
45
|
-
"@ultimabase/postgres": "0.
|
|
42
|
+
"@ultimabase/auth": "0.2.0",
|
|
43
|
+
"@ultimabase/graphql": "0.2.0",
|
|
44
|
+
"@ultimabase/http": "0.2.0",
|
|
45
|
+
"@ultimabase/postgres": "0.2.0",
|
|
46
46
|
"graphql": "17.0.2",
|
|
47
47
|
"pg": "8.23.1",
|
|
48
|
-
"ultimabase": "0.
|
|
48
|
+
"ultimabase": "0.2.0",
|
|
49
49
|
"vite": "8.3.4",
|
|
50
50
|
"vitest": "5.0.5"
|
|
51
51
|
}
|
package/src/core.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
} from 'ultimabase'
|
|
14
14
|
import { master, PublicError, transaction } from 'ultimabase'
|
|
15
15
|
import { dummyHash, hashPassword, verifyPassword } from './hash.ts'
|
|
16
|
+
import type { SendEmail } from './email-functions.ts'
|
|
16
17
|
import { normalized } from './input.ts'
|
|
17
18
|
import type { Attempt } from './models.ts'
|
|
18
19
|
import { limited, PasswordHash } from './models.ts'
|
|
@@ -31,6 +32,7 @@ export interface State {
|
|
|
31
32
|
readonly logger: Logger
|
|
32
33
|
/** The sends under way, which stopping waits for (D-117). */
|
|
33
34
|
readonly sending: Set<Promise<void>>
|
|
35
|
+
readonly sendEmail: SendEmail | undefined
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
/** What a sign-in returns: its session, and the user read as itself. */
|
|
@@ -48,6 +50,10 @@ export interface SignedIn<U = Row> {
|
|
|
48
50
|
const invalid = () =>
|
|
49
51
|
new PublicError('INVALID_CREDENTIALS', 'Wrong email or password.')
|
|
50
52
|
|
|
53
|
+
/** The current password is known to be the user's: no email to name (D-161). */
|
|
54
|
+
const wrongPassword = () =>
|
|
55
|
+
new PublicError('INVALID_CREDENTIALS', 'Wrong password.')
|
|
56
|
+
|
|
51
57
|
/** The attempt a client makes for `subject`; server code is not counted. */
|
|
52
58
|
export const attemptOf = (
|
|
53
59
|
subject: string,
|
|
@@ -57,7 +63,8 @@ export const attemptOf = (
|
|
|
57
63
|
|
|
58
64
|
/**
|
|
59
65
|
* Creates the user, its account and its hash in one transaction, as Master:
|
|
60
|
-
* the caller's fields were checked against the list (D-113).
|
|
66
|
+
* the caller's fields were checked against the list (D-113). The email's
|
|
67
|
+
* hook links the account (D-162).
|
|
61
68
|
*/
|
|
62
69
|
export async function createUser(
|
|
63
70
|
state: State,
|
|
@@ -68,8 +75,10 @@ export async function createUser(
|
|
|
68
75
|
const hash = await hashPassword(password)
|
|
69
76
|
const stamp = randomUUID()
|
|
70
77
|
return transaction(async () => {
|
|
71
|
-
const user = await state.users.queries.create(
|
|
72
|
-
|
|
78
|
+
const user = await state.users.queries.create(
|
|
79
|
+
{ data: { ...data, email } },
|
|
80
|
+
M,
|
|
81
|
+
)
|
|
73
82
|
await PasswordHash.create({ data: { userId: user.id, hash, stamp } }, M)
|
|
74
83
|
return { user, stamp }
|
|
75
84
|
})
|
|
@@ -142,13 +151,14 @@ export async function checkCurrent(
|
|
|
142
151
|
attempt: Attempt,
|
|
143
152
|
): Promise<void> {
|
|
144
153
|
await limited(attempt, async () => {
|
|
145
|
-
if ((await matches(userId, password)) === undefined) throw
|
|
154
|
+
if ((await matches(userId, password)) === undefined) throw wrongPassword()
|
|
146
155
|
})
|
|
147
156
|
}
|
|
148
157
|
|
|
149
158
|
/**
|
|
150
|
-
* Replaces `userId`'s hash,
|
|
151
|
-
* pending reset tokens (D-117);
|
|
159
|
+
* Replaces `userId`'s hash, or gives one to an email without (D-162), ends
|
|
160
|
+
* every session of the user and deletes its pending reset tokens (D-117);
|
|
161
|
+
* the new stamp.
|
|
152
162
|
*/
|
|
153
163
|
export async function setPassword(
|
|
154
164
|
state: State,
|
|
@@ -156,7 +166,10 @@ export async function setPassword(
|
|
|
156
166
|
password: string,
|
|
157
167
|
): Promise<string> {
|
|
158
168
|
const stored = await PasswordHash.findUnique({ where: { userId } }, M)
|
|
159
|
-
|
|
169
|
+
const linked =
|
|
170
|
+
stored !== null ||
|
|
171
|
+
(await state.auth.subjectOf(userId, method)) !== undefined
|
|
172
|
+
if (!linked) {
|
|
160
173
|
throw new PublicError(
|
|
161
174
|
'NOT_FOUND',
|
|
162
175
|
`User ${userId} has no password: sign it up with signUp.`,
|
|
@@ -165,7 +178,10 @@ export async function setPassword(
|
|
|
165
178
|
const hash = await hashPassword(password)
|
|
166
179
|
const stamp = randomUUID()
|
|
167
180
|
await transaction(async () => {
|
|
168
|
-
|
|
181
|
+
const data = { hash, stamp }
|
|
182
|
+
await (stored === null
|
|
183
|
+
? PasswordHash.create({ data: { userId, ...data } }, M)
|
|
184
|
+
: PasswordHash.update({ where: { userId }, data }, M))
|
|
169
185
|
await PasswordToken.deleteMany({ where: { userId, kind: 'reset' } }, M)
|
|
170
186
|
await state.auth.revokeSessions(userId)
|
|
171
187
|
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The user's `email` (D-162): the password account's subject, which
|
|
2
|
+
// password's hooks keep in step with the field, whichever path writes it.
|
|
3
|
+
import type { Caller } from 'ultimabase'
|
|
4
|
+
import { isMaster, master, PublicError } from 'ultimabase'
|
|
5
|
+
import type { State } from './core.ts'
|
|
6
|
+
import { method } from './core.ts'
|
|
7
|
+
import { later } from './email-functions.ts'
|
|
8
|
+
import { emailOf, valid } from './input.ts'
|
|
9
|
+
import { PasswordToken } from './tokens.ts'
|
|
10
|
+
|
|
11
|
+
const M = { as: master }
|
|
12
|
+
|
|
13
|
+
type Emailed = { readonly id: string; readonly email: string | null }
|
|
14
|
+
type Draft = {
|
|
15
|
+
email?: string | null | undefined
|
|
16
|
+
emailVerified?: boolean | undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Only the user itself and Master read the email. */
|
|
20
|
+
export const readsEmail = (facts: {
|
|
21
|
+
readonly caller: Caller
|
|
22
|
+
readonly record: { readonly id: string }
|
|
23
|
+
}): boolean => {
|
|
24
|
+
const { caller, record } = facts
|
|
25
|
+
return (
|
|
26
|
+
isMaster(caller) || (caller.kind === 'user' && caller.user.id === record.id)
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Normalizes a created user's email, as at sign-up. */
|
|
31
|
+
export function creating(data: Draft): void {
|
|
32
|
+
if (typeof data.email === 'string') {
|
|
33
|
+
data.email = valid(emailOf, data.email, 'email')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Links the created user's email as its password account. */
|
|
38
|
+
export async function created(state: State, record: Emailed): Promise<void> {
|
|
39
|
+
if (record.email === null) return
|
|
40
|
+
await state.auth.linkAccount(record.id, method, record.email)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Normalizes an updated email, refuses `null` while the account is linked,
|
|
45
|
+
* and unverifies another email unless the write verifies it.
|
|
46
|
+
*/
|
|
47
|
+
export async function updating(
|
|
48
|
+
state: State,
|
|
49
|
+
record: Emailed,
|
|
50
|
+
data: Draft,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
if (data.email === undefined) return
|
|
53
|
+
const subject = await state.auth.subjectOf(record.id, method)
|
|
54
|
+
if (data.email === null) {
|
|
55
|
+
if (subject === undefined) return
|
|
56
|
+
const message = 'closeAccount removes the email.'
|
|
57
|
+
throw new PublicError('VALIDATION_FAILED', `email: ${message}`, {
|
|
58
|
+
details: { issues: [{ path: ['email'], message }] },
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
data.email = valid(emailOf, data.email, 'email')
|
|
62
|
+
if (data.email !== subject) data.emailVerified ??= false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Moves the password account to the new email, in the write's transaction,
|
|
67
|
+
* and deletes the links the old one received.
|
|
68
|
+
*/
|
|
69
|
+
export async function updated(
|
|
70
|
+
state: State,
|
|
71
|
+
record: Emailed,
|
|
72
|
+
previous: Emailed,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
if (record.email === null || record.email === previous.email) return
|
|
75
|
+
const { auth } = state
|
|
76
|
+
const subject = await auth.subjectOf(record.id, method)
|
|
77
|
+
// Filling the field from the account changes nothing else.
|
|
78
|
+
if (subject === record.email) return
|
|
79
|
+
if (subject !== undefined) await auth.unlinkAccount(record.id, method)
|
|
80
|
+
await auth.linkAccount(record.id, method, record.email)
|
|
81
|
+
const where = { userId: record.id, kind: { in: ['reset', 'verify'] } }
|
|
82
|
+
await PasswordToken.deleteMany({ where }, M)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Tells the old address, once the change commits, when `sendEmail` exists. */
|
|
86
|
+
export function committed(
|
|
87
|
+
state: State,
|
|
88
|
+
record: Emailed,
|
|
89
|
+
previous: Emailed,
|
|
90
|
+
): void {
|
|
91
|
+
const { sendEmail } = state
|
|
92
|
+
const [from, to] = [previous.email, record.email]
|
|
93
|
+
if (sendEmail === undefined || from === null || to === null || from === to) {
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
later(state, async () => {
|
|
97
|
+
await sendEmail({
|
|
98
|
+
kind: 'emailChanged',
|
|
99
|
+
to: from,
|
|
100
|
+
newEmail: to,
|
|
101
|
+
userId: record.id,
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
}
|
package/src/email-functions.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
// password's email functions (D-117): `requestPasswordReset`,
|
|
2
|
-
// `resetPassword`, `verifyEmail
|
|
3
|
-
//
|
|
1
|
+
// password's email functions (D-117, D-162): `requestPasswordReset`,
|
|
2
|
+
// `resetPassword`, `verifyEmail`, `sendVerificationEmail`,
|
|
3
|
+
// `requestEmailChange` and `confirmEmailChange`, there when the application
|
|
4
|
+
// gives `sendEmail`.
|
|
4
5
|
import type { FunctionShape } from 'ultimabase'
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
allow,
|
|
8
|
+
defineFunction,
|
|
9
|
+
master,
|
|
10
|
+
PublicError,
|
|
11
|
+
transaction,
|
|
12
|
+
} from 'ultimabase'
|
|
6
13
|
import type { State } from './core.ts'
|
|
7
|
-
import { method, setPassword } from './core.ts'
|
|
14
|
+
import { attemptOf, checkCurrent, method, setPassword } from './core.ts'
|
|
8
15
|
import {
|
|
16
|
+
confirmEmailChangeInput,
|
|
9
17
|
outputSchema,
|
|
18
|
+
requestEmailChangeInput,
|
|
10
19
|
requestResetInput,
|
|
11
20
|
resetPasswordInput,
|
|
12
21
|
verifyEmailInput,
|
|
@@ -49,7 +58,7 @@ export const mailerOf =
|
|
|
49
58
|
async (kind, userId, to) => {
|
|
50
59
|
const refused = await emailLimited(userId)
|
|
51
60
|
if (refused !== undefined) return refused
|
|
52
|
-
const token = await issueToken(userId, kind)
|
|
61
|
+
const token = await issueToken(userId, kind, to)
|
|
53
62
|
await sendEmail({ kind, to, token, userId })
|
|
54
63
|
return undefined
|
|
55
64
|
}
|
|
@@ -91,7 +100,7 @@ export function emailFunctions(
|
|
|
91
100
|
output: done,
|
|
92
101
|
run: async ({ input: { input } }) => {
|
|
93
102
|
const state = stateOf()
|
|
94
|
-
const userId = await redeemToken(input.token, 'reset')
|
|
103
|
+
const { userId } = await redeemToken(input.token, 'reset')
|
|
95
104
|
await setPassword(state, userId, input.password)
|
|
96
105
|
await verify(state, userId)
|
|
97
106
|
return true
|
|
@@ -102,7 +111,8 @@ export function emailFunctions(
|
|
|
102
111
|
input: verifyEmailInput,
|
|
103
112
|
output: done,
|
|
104
113
|
run: async ({ input: { input } }) => {
|
|
105
|
-
|
|
114
|
+
const { userId } = await redeemToken(input.token, 'verify')
|
|
115
|
+
await verify(stateOf(), userId)
|
|
106
116
|
return true
|
|
107
117
|
},
|
|
108
118
|
}).with({ access: allow.public })
|
|
@@ -133,5 +143,52 @@ export function emailFunctions(
|
|
|
133
143
|
resetPassword,
|
|
134
144
|
verifyEmail,
|
|
135
145
|
sendVerificationEmail,
|
|
146
|
+
...emailChangeFunctions(stateOf, mail),
|
|
136
147
|
]
|
|
137
148
|
}
|
|
149
|
+
|
|
150
|
+
/** `requestEmailChange` and `confirmEmailChange` (D-162). */
|
|
151
|
+
function emailChangeFunctions(
|
|
152
|
+
stateOf: () => State,
|
|
153
|
+
mail: Mailer,
|
|
154
|
+
): readonly FunctionShape[] {
|
|
155
|
+
const requestEmailChange = defineFunction({
|
|
156
|
+
name: 'requestEmailChange',
|
|
157
|
+
input: requestEmailChangeInput,
|
|
158
|
+
output: done,
|
|
159
|
+
run: async ({ input: { input }, caller, client }) => {
|
|
160
|
+
if (caller.kind !== 'user') {
|
|
161
|
+
throw new PublicError(
|
|
162
|
+
'UNAUTHENTICATED',
|
|
163
|
+
'requestEmailChange needs a signed-in user.',
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
const { id } = caller.user
|
|
167
|
+
await checkCurrent(id, input.currentPassword, attemptOf(id, client))
|
|
168
|
+
const { newEmail } = input
|
|
169
|
+
// The caller proved the password: telling it is no enumeration.
|
|
170
|
+
if ((await stateOf().auth.accountOf(method, newEmail)) !== undefined) {
|
|
171
|
+
throw new PublicError('UNIQUE_VIOLATION', 'This email has an account.')
|
|
172
|
+
}
|
|
173
|
+
const refused = await mail('emailChange', id, newEmail)
|
|
174
|
+
if (refused !== undefined) throw refused
|
|
175
|
+
return true
|
|
176
|
+
},
|
|
177
|
+
}).with({ access: allow.public })
|
|
178
|
+
const confirmEmailChange = defineFunction({
|
|
179
|
+
name: 'confirmEmailChange',
|
|
180
|
+
input: confirmEmailChangeInput,
|
|
181
|
+
output: done,
|
|
182
|
+
run: async ({ input: { input } }) => {
|
|
183
|
+
const { users } = stateOf()
|
|
184
|
+
// A taken email undoes the redeem too: the link works until it expires.
|
|
185
|
+
await transaction(async () => {
|
|
186
|
+
const { userId, email } = await redeemToken(input.token, 'emailChange')
|
|
187
|
+
const data = { email, emailVerified: true }
|
|
188
|
+
await users.queries.update({ where: { id: userId }, data }, M)
|
|
189
|
+
})
|
|
190
|
+
return true
|
|
191
|
+
},
|
|
192
|
+
}).with({ access: allow.public })
|
|
193
|
+
return [requestEmailChange, confirmEmailChange]
|
|
194
|
+
}
|
package/src/input.ts
CHANGED
|
@@ -332,6 +332,32 @@ export const verifyEmailInput: StandardSchema<
|
|
|
332
332
|
Named<{ readonly token: string }>
|
|
333
333
|
> = namedInput('VerifyEmail', new Map([['token', property(text, string)]]))
|
|
334
334
|
|
|
335
|
+
type RequestEmailChange = {
|
|
336
|
+
readonly newEmail: string
|
|
337
|
+
readonly currentPassword: string
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** What `requestEmailChange` takes: the new email and the password (D-162). */
|
|
341
|
+
export const requestEmailChangeInput: StandardSchema<
|
|
342
|
+
Named<RequestEmailChange>,
|
|
343
|
+
Named<RequestEmailChange>
|
|
344
|
+
> = namedInput(
|
|
345
|
+
'RequestEmailChange',
|
|
346
|
+
new Map([
|
|
347
|
+
['newEmail', email],
|
|
348
|
+
['currentPassword', property(text, string)],
|
|
349
|
+
]),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
/** What `confirmEmailChange` takes: the email's token (D-162). */
|
|
353
|
+
export const confirmEmailChangeInput: StandardSchema<
|
|
354
|
+
Named<{ readonly token: string }>,
|
|
355
|
+
Named<{ readonly token: string }>
|
|
356
|
+
> = namedInput(
|
|
357
|
+
'ConfirmEmailChange',
|
|
358
|
+
new Map([['token', property(text, string)]]),
|
|
359
|
+
)
|
|
360
|
+
|
|
335
361
|
/** The email, the password and the listed user fields, by their names. */
|
|
336
362
|
export const signUpInput = (
|
|
337
363
|
fields: ReadonlyMap<string, Property>,
|