@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/src/models.ts
CHANGED
|
@@ -86,9 +86,15 @@ const tooMany = (oldest: Date, span: number, now: number): PublicError =>
|
|
|
86
86
|
},
|
|
87
87
|
})
|
|
88
88
|
|
|
89
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Where an attempt comes from: a client's address, none for server code
|
|
91
|
+
* that is still counted (D-161), `undefined` for server code that is not.
|
|
92
|
+
*/
|
|
90
93
|
export type Attempt =
|
|
91
|
-
{ readonly subject: string; readonly address
|
|
94
|
+
{ readonly subject: string; readonly address?: string } | undefined
|
|
95
|
+
|
|
96
|
+
/** What an attempt without an address is stored with; no address is it. */
|
|
97
|
+
const server = 'server'
|
|
92
98
|
|
|
93
99
|
/** The `n`th most recent failure but `id` matching `where`, if there is one. */
|
|
94
100
|
async function nth(
|
|
@@ -103,11 +109,30 @@ async function nth(
|
|
|
103
109
|
return failures[n - 1]?.at
|
|
104
110
|
}
|
|
105
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The failures but `id` that refuse the attempt: from one address, the 10th
|
|
114
|
+
* on the subject or the 100th on it or from the address; without one, the
|
|
115
|
+
* 10th on the subject from anywhere, and no address is filled (D-161).
|
|
116
|
+
*/
|
|
117
|
+
async function reached(
|
|
118
|
+
subject: string,
|
|
119
|
+
address: string | undefined,
|
|
120
|
+
id: string,
|
|
121
|
+
): Promise<readonly (Date | undefined)[]> {
|
|
122
|
+
if (address === undefined) return [await nth({ subject }, id, limit)]
|
|
123
|
+
return [
|
|
124
|
+
await nth({ subject, address }, id, limit),
|
|
125
|
+
await nth({ subject }, id, ceiling),
|
|
126
|
+
await nth({ address }, id, ceiling),
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
|
|
106
130
|
/**
|
|
107
131
|
* Counts an attempt as failed until `run` succeeds, so concurrent attempts
|
|
108
132
|
* cannot pass the limit (D-113, D-114). Within 15 minutes, the 11th from one
|
|
109
|
-
* address on a subject is refused, as the 101st on it or from the address
|
|
110
|
-
*
|
|
133
|
+
* address on a subject is refused, as the 101st on it or from the address,
|
|
134
|
+
* and the 11th without an address on a subject (D-161); a success clears
|
|
135
|
+
* its address's count, a busy refusal is not counted.
|
|
111
136
|
*/
|
|
112
137
|
export async function limited<T>(
|
|
113
138
|
attempt: Attempt,
|
|
@@ -115,7 +140,9 @@ export async function limited<T>(
|
|
|
115
140
|
): Promise<T> {
|
|
116
141
|
if (attempt === undefined) return run()
|
|
117
142
|
const { subject } = attempt
|
|
118
|
-
const
|
|
143
|
+
const network =
|
|
144
|
+
attempt.address === undefined ? undefined : networkOf(attempt.address)
|
|
145
|
+
const address = network ?? server
|
|
119
146
|
const now = Date.now()
|
|
120
147
|
const since = new Date(now - window)
|
|
121
148
|
await PasswordAttempt.deleteMany({ where: { at: { lte: since } } }, M)
|
|
@@ -124,11 +151,7 @@ export async function limited<T>(
|
|
|
124
151
|
M,
|
|
125
152
|
)
|
|
126
153
|
// Several may be reached: the latest says when to retry.
|
|
127
|
-
const [oldest] =
|
|
128
|
-
await nth({ subject, address }, id, limit),
|
|
129
|
-
await nth({ subject }, id, ceiling),
|
|
130
|
-
await nth({ address }, id, ceiling),
|
|
131
|
-
]
|
|
154
|
+
const [oldest] = (await reached(subject, network, id))
|
|
132
155
|
.filter((at) => at !== undefined)
|
|
133
156
|
.toSorted((a, b) => b.getTime() - a.getTime())
|
|
134
157
|
if (oldest !== undefined) {
|
package/src/plugin.ts
CHANGED
|
@@ -26,11 +26,20 @@ import manifest from '../package.json' with { type: 'json' }
|
|
|
26
26
|
import type { SignedIn, State } from './core.ts'
|
|
27
27
|
import {
|
|
28
28
|
attemptOf,
|
|
29
|
+
checkCurrent,
|
|
29
30
|
createUser,
|
|
30
31
|
setPassword,
|
|
31
32
|
signedIn,
|
|
32
33
|
verified,
|
|
33
34
|
} from './core.ts'
|
|
35
|
+
import {
|
|
36
|
+
created,
|
|
37
|
+
committed,
|
|
38
|
+
creating,
|
|
39
|
+
readsEmail,
|
|
40
|
+
updated,
|
|
41
|
+
updating,
|
|
42
|
+
} from './email-field.ts'
|
|
34
43
|
import type { SendEmail } from './email-functions.ts'
|
|
35
44
|
import { emailFunctions, mailerOf } from './email-functions.ts'
|
|
36
45
|
import { passwordFunctions } from './functions.ts'
|
|
@@ -87,6 +96,15 @@ export interface PasswordCommands<F> {
|
|
|
87
96
|
args: { readonly id: string; readonly password: string },
|
|
88
97
|
options?: Options,
|
|
89
98
|
): Promise<void>
|
|
99
|
+
/**
|
|
100
|
+
* Checks the user's current password before a sensitive action, as
|
|
101
|
+
* Master or as the user; opens no session. Always counted: with a
|
|
102
|
+
* `client` as changePassword, otherwise 10 failures per user (D-161).
|
|
103
|
+
*/
|
|
104
|
+
checkPassword(
|
|
105
|
+
args: { readonly id: string; readonly password: string },
|
|
106
|
+
options?: SignInOptions,
|
|
107
|
+
): Promise<void>
|
|
90
108
|
}
|
|
91
109
|
|
|
92
110
|
/** password's types (D-57). */
|
|
@@ -96,6 +114,11 @@ export interface Password extends PluginTypes {
|
|
|
96
114
|
readonly appOptions: PasswordOptions
|
|
97
115
|
readonly addedFields: {
|
|
98
116
|
readonly emailVerified: Field<boolean, { readonly default: false }>
|
|
117
|
+
/** The password account's email (D-162). */
|
|
118
|
+
readonly email: Field<
|
|
119
|
+
string | null,
|
|
120
|
+
{ readonly optional: true; readonly unique: true }
|
|
121
|
+
>
|
|
99
122
|
}
|
|
100
123
|
readonly commands: PasswordCommands<this['fields']>
|
|
101
124
|
}
|
|
@@ -111,6 +134,15 @@ function stateOf(auth: AuthApi): State {
|
|
|
111
134
|
return state
|
|
112
135
|
}
|
|
113
136
|
|
|
137
|
+
/** Deletes the user's hash, links and email requests (D-113, D-160). */
|
|
138
|
+
async function forget(userId: string): Promise<void> {
|
|
139
|
+
const where = { userId }
|
|
140
|
+
await PasswordHash.deleteMany({ where }, M)
|
|
141
|
+
await PasswordToken.deleteMany({ where }, M)
|
|
142
|
+
const emails = { kind: 'email', subject: userId }
|
|
143
|
+
await PasswordRequest.deleteMany({ where: emails }, M)
|
|
144
|
+
}
|
|
145
|
+
|
|
114
146
|
/** The one model whose `use` lists `password()`. */
|
|
115
147
|
function usersOf(models: readonly ExposedModel[]): ExposedModel {
|
|
116
148
|
// oxlint-disable-next-line typescript/unbound-method -- compared by identity, never called.
|
|
@@ -211,6 +243,22 @@ function configOf(
|
|
|
211
243
|
const callerOf = (options: Options | undefined): Identity =>
|
|
212
244
|
options?.as ?? { kind: 'anonymous' }
|
|
213
245
|
|
|
246
|
+
/** Refuses `command` on user `id` but as Master or as that user. */
|
|
247
|
+
function selfOrMaster(
|
|
248
|
+
options: Options | undefined,
|
|
249
|
+
id: string,
|
|
250
|
+
command: string,
|
|
251
|
+
): void {
|
|
252
|
+
const caller = callerOf(options)
|
|
253
|
+
const self = caller.kind === 'user' && caller.user.id === id
|
|
254
|
+
if (!self && !isMaster(caller)) {
|
|
255
|
+
throw new PublicError(
|
|
256
|
+
'ACCESS_DENIED',
|
|
257
|
+
`User.${command} runs as Master or as the user.`,
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
214
262
|
const definition = definePlugin<Password>({
|
|
215
263
|
name: 'password',
|
|
216
264
|
manifest,
|
|
@@ -221,15 +269,31 @@ const definition = definePlugin<Password>({
|
|
|
221
269
|
emailVerified: f.boolean({ default: false }).with({
|
|
222
270
|
access: { create: () => false, update: () => false },
|
|
223
271
|
}),
|
|
272
|
+
// Only Master writes it; the hooks keep the account in step (D-162).
|
|
273
|
+
email: f.string({ optional: true, unique: true }).with({
|
|
274
|
+
access: { read: readsEmail, create: () => false, update: () => false },
|
|
275
|
+
}),
|
|
224
276
|
}),
|
|
225
277
|
hooks: {
|
|
278
|
+
// The email and the password account stay one (D-162).
|
|
279
|
+
beforeCreate: ({ data }) => {
|
|
280
|
+
creating(data)
|
|
281
|
+
},
|
|
282
|
+
afterCreate: async ({ record, plugins }) => {
|
|
283
|
+
await created(stateOf(plugins.auth), record)
|
|
284
|
+
},
|
|
285
|
+
beforeUpdate: async ({ record, data, plugins }) => {
|
|
286
|
+
await updating(stateOf(plugins.auth), record, data)
|
|
287
|
+
},
|
|
288
|
+
afterUpdate: async ({ record, previous, plugins }) => {
|
|
289
|
+
await updated(stateOf(plugins.auth), record, previous)
|
|
290
|
+
},
|
|
291
|
+
afterUpdateCommit: ({ record, previous, plugins }) => {
|
|
292
|
+
committed(stateOf(plugins.auth), record, previous)
|
|
293
|
+
},
|
|
226
294
|
// The user's hash and tokens go with them (D-113, D-117).
|
|
227
295
|
afterDelete: async ({ record }) => {
|
|
228
|
-
|
|
229
|
-
await PasswordHash.deleteMany({ where }, M)
|
|
230
|
-
await PasswordToken.deleteMany({ where }, M)
|
|
231
|
-
const emails = { kind: 'email', subject: record.id }
|
|
232
|
-
await PasswordRequest.deleteMany({ where: emails }, M)
|
|
296
|
+
await forget(record.id)
|
|
233
297
|
},
|
|
234
298
|
},
|
|
235
299
|
commands: ({ queries, plugins }) => {
|
|
@@ -258,17 +322,20 @@ const definition = definePlugin<Password>({
|
|
|
258
322
|
return opened as SignedIn<UserRow<Password['addedFields']>>
|
|
259
323
|
},
|
|
260
324
|
setPassword: async ({ id, password }, options) => {
|
|
261
|
-
|
|
262
|
-
const self = caller.kind === 'user' && caller.user.id === id
|
|
263
|
-
if (!self && !isMaster(caller)) {
|
|
264
|
-
throw new PublicError(
|
|
265
|
-
'ACCESS_DENIED',
|
|
266
|
-
'User.setPassword runs as Master or as the user.',
|
|
267
|
-
)
|
|
268
|
-
}
|
|
325
|
+
selfOrMaster(options, id, 'setPassword')
|
|
269
326
|
const state = stateOf(plugins.auth)
|
|
270
327
|
await setPassword(state, id, valid(passwordOf, password, 'password'))
|
|
271
328
|
},
|
|
329
|
+
checkPassword: async ({ id, password }, options) => {
|
|
330
|
+
selfOrMaster(options, id, 'checkPassword')
|
|
331
|
+
const client = options?.client
|
|
332
|
+
// Counted without a client too: the check guards an action (D-161).
|
|
333
|
+
await checkCurrent(
|
|
334
|
+
id,
|
|
335
|
+
password,
|
|
336
|
+
attemptOf(id, client) ?? { subject: id },
|
|
337
|
+
)
|
|
338
|
+
},
|
|
272
339
|
}
|
|
273
340
|
},
|
|
274
341
|
// The functions are built before the APIs (D-89): they read the state
|
|
@@ -277,7 +344,20 @@ const definition = definePlugin<Password>({
|
|
|
277
344
|
const { users, fields } = configOf(options, models)
|
|
278
345
|
const signUp = fields === false ? false : new Set(fields.keys())
|
|
279
346
|
const { auth } = plugins
|
|
280
|
-
|
|
347
|
+
const { sendEmail } = options
|
|
348
|
+
const sending = new Set<Promise<void>>()
|
|
349
|
+
states.set(auth, { auth, users, signUp, logger, sending, sendEmail })
|
|
350
|
+
// A closed account keeps its row, without a password, an email or a
|
|
351
|
+
// verified one (D-160, D-162).
|
|
352
|
+
auth.onCloseAccount(async (userId) => {
|
|
353
|
+
await forget(userId)
|
|
354
|
+
const where = { id: userId }
|
|
355
|
+
const user = await users.queries.findUnique({ where }, M)
|
|
356
|
+
if (user === null) return
|
|
357
|
+
if (user.email === null && user.emailVerified !== true) return
|
|
358
|
+
const data = { email: null, emailVerified: false }
|
|
359
|
+
await users.queries.update({ where, data }, M)
|
|
360
|
+
})
|
|
281
361
|
},
|
|
282
362
|
// The emails under way still query: they end before the database closes.
|
|
283
363
|
stop: async ({ plugins }) => {
|
package/src/tokens.ts
CHANGED
|
@@ -10,21 +10,33 @@ const M = { as: master }
|
|
|
10
10
|
const emails = 3
|
|
11
11
|
|
|
12
12
|
/** What an email's token lets its holder do. */
|
|
13
|
-
export type EmailKind = 'reset' | 'verify'
|
|
13
|
+
export type EmailKind = 'reset' | 'verify' | 'emailChange'
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
15
|
+
/**
|
|
16
|
+
* What `sendEmail` receives: the application writes the text and the link.
|
|
17
|
+
* `'emailChanged'` tells the old address its email changed (D-162).
|
|
18
|
+
*/
|
|
19
|
+
export type Email =
|
|
20
|
+
| {
|
|
21
|
+
readonly kind: EmailKind
|
|
22
|
+
/** The address the link goes to, normalized. */
|
|
23
|
+
readonly to: string
|
|
24
|
+
/** What the link carries, for the function its kind names. */
|
|
25
|
+
readonly token: string
|
|
26
|
+
readonly userId: string
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
readonly kind: 'emailChanged'
|
|
30
|
+
/** The old address. */
|
|
31
|
+
readonly to: string
|
|
32
|
+
readonly newEmail: string
|
|
33
|
+
readonly userId: string
|
|
34
|
+
}
|
|
24
35
|
|
|
25
36
|
const lifetimes: Readonly<Record<EmailKind, number>> = {
|
|
26
37
|
reset: hour,
|
|
27
38
|
verify: 24 * hour,
|
|
39
|
+
emailChange: hour,
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
type TokenFields = {
|
|
@@ -33,6 +45,8 @@ type TokenFields = {
|
|
|
33
45
|
/** The token's SHA-256: a leaked database gives no working link. */
|
|
34
46
|
readonly hash: Field<string, { readonly unique: true }>
|
|
35
47
|
readonly expiresAt: Field<Date, ScalarOptions<Date>>
|
|
48
|
+
/** The address the email went to (D-162). */
|
|
49
|
+
readonly email: Field<string | null, { readonly optional: true }>
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
export const PasswordToken: ModelOf<'PasswordToken', TokenFields, []> =
|
|
@@ -43,6 +57,7 @@ export const PasswordToken: ModelOf<'PasswordToken', TokenFields, []> =
|
|
|
43
57
|
kind: f.string(),
|
|
44
58
|
hash: f.string({ unique: true }),
|
|
45
59
|
expiresAt: f.dateTime(),
|
|
60
|
+
email: f.string({ optional: true }),
|
|
46
61
|
}),
|
|
47
62
|
indexes: [{ fields: ['userId', 'kind'] }, { fields: ['expiresAt'] }],
|
|
48
63
|
})
|
|
@@ -50,10 +65,11 @@ export const PasswordToken: ModelOf<'PasswordToken', TokenFields, []> =
|
|
|
50
65
|
const hashOf = (token: string): string =>
|
|
51
66
|
createHash('sha256').update(token).digest('base64url')
|
|
52
67
|
|
|
53
|
-
/** A new token of `kind` for `userId`,
|
|
68
|
+
/** A new token of `kind` for `userId`, sent to `email`; it replaces the older one. */
|
|
54
69
|
export async function issueToken(
|
|
55
70
|
userId: string,
|
|
56
71
|
kind: EmailKind,
|
|
72
|
+
email: string,
|
|
57
73
|
): Promise<string> {
|
|
58
74
|
const now = Date.now()
|
|
59
75
|
await PasswordToken.deleteMany(
|
|
@@ -64,7 +80,7 @@ export async function issueToken(
|
|
|
64
80
|
const token = randomBytes(32).toString('base64url')
|
|
65
81
|
const expiresAt = new Date(now + lifetimes[kind])
|
|
66
82
|
await PasswordToken.create(
|
|
67
|
-
{ data: { userId, kind, hash: hashOf(token), expiresAt } },
|
|
83
|
+
{ data: { userId, kind, hash: hashOf(token), expiresAt, email } },
|
|
68
84
|
M,
|
|
69
85
|
)
|
|
70
86
|
return token
|
|
@@ -73,11 +89,14 @@ export async function issueToken(
|
|
|
73
89
|
const invalidLink = () =>
|
|
74
90
|
new PublicError('INVALID_CREDENTIALS', 'This link is invalid or has expired.')
|
|
75
91
|
|
|
76
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* The user a token of `kind` was issued to, and the address it was sent to;
|
|
94
|
+
* a wrong, used or expired one fails.
|
|
95
|
+
*/
|
|
77
96
|
export async function redeemToken(
|
|
78
97
|
token: string,
|
|
79
98
|
kind: EmailKind,
|
|
80
|
-
): Promise<string> {
|
|
99
|
+
): Promise<{ readonly userId: string; readonly email: string | null }> {
|
|
81
100
|
const hash = hashOf(token)
|
|
82
101
|
const stored = await PasswordToken.findUnique({ where: { hash } }, M)
|
|
83
102
|
if (stored?.kind !== kind) throw invalidLink()
|
|
@@ -86,7 +105,7 @@ export async function redeemToken(
|
|
|
86
105
|
if (count === 0 || stored.expiresAt.getTime() <= Date.now()) {
|
|
87
106
|
throw invalidLink()
|
|
88
107
|
}
|
|
89
|
-
return stored.userId
|
|
108
|
+
return { userId: stored.userId, email: stored.email }
|
|
90
109
|
}
|
|
91
110
|
|
|
92
111
|
/**
|