@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/src/models.ts CHANGED
@@ -86,9 +86,15 @@ const tooMany = (oldest: Date, span: number, now: number): PublicError =>
86
86
  },
87
87
  })
88
88
 
89
- /** Where an attempt comes from; `undefined` for server code, not counted. */
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: string } | undefined
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
- * a success clears its address's count, a busy refusal is not counted.
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 address = networkOf(attempt.address)
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
- const where = { userId: record.id }
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
- const caller = callerOf(options)
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
- states.set(auth, { auth, users, signUp, logger, sending: new Set() })
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
- /** What `sendEmail` receives: the application writes the text and the link. */
16
- export interface Email {
17
- readonly kind: EmailKind
18
- /** The account's email, normalized. */
19
- readonly to: string
20
- /** What the link carries, for `resetPassword` or `verifyEmail`. */
21
- readonly token: string
22
- readonly userId: string
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`, which replaces the older one. */
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
- /** The user a token of `kind` was issued to; a wrong, used or expired one fails. */
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
  /**