@ultimabase/password 0.0.0-stage → 0.1.1
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 +21 -0
- package/README.md +4 -2
- package/dist/core.d.ts +57 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +124 -0
- package/dist/email-functions.d.ts +20 -0
- package/dist/email-functions.d.ts.map +1 -0
- package/dist/email-functions.js +107 -0
- package/dist/functions.d.ts +10 -0
- package/dist/functions.d.ts.map +1 -0
- package/dist/functions.js +75 -0
- package/dist/hash.d.ts +16 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +103 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/input.d.ts +104 -0
- package/dist/input.d.ts.map +1 -0
- package/dist/input.js +231 -0
- package/dist/models.d.ts +57 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +122 -0
- package/dist/plugin.d.ts +75 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +172 -0
- package/dist/tokens.d.ts +34 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +52 -0
- package/package.json +49 -3
- package/src/core.ts +206 -0
- package/src/email-functions.ts +137 -0
- package/src/functions.ts +102 -0
- package/src/hash.ts +144 -0
- package/src/index.ts +13 -0
- package/src/input.ts +360 -0
- package/src/models.ts +190 -0
- package/src/plugin.ts +310 -0
- package/src/tokens.ts +98 -0
package/src/models.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// password's own models (D-110, D-113, D-114, D-117): each user's hash, and
|
|
2
|
+
// what the sign-in limit and the hourly limits count.
|
|
3
|
+
import ipaddr from 'ipaddr.js'
|
|
4
|
+
import type { Field, ModelOf, ScalarOptions } from 'ultimabase'
|
|
5
|
+
import { defineModel, master, PublicError } from 'ultimabase'
|
|
6
|
+
|
|
7
|
+
const M = { as: master }
|
|
8
|
+
const limit = 10
|
|
9
|
+
const ceiling = 100
|
|
10
|
+
const window = 15 * 60 * 1000
|
|
11
|
+
const requests = 10
|
|
12
|
+
export const hour: number = 60 * 60 * 1000
|
|
13
|
+
|
|
14
|
+
type HashFields = {
|
|
15
|
+
readonly userId: Field<string, { readonly unique: true }>
|
|
16
|
+
readonly hash: Field<string, ScalarOptions<string>>
|
|
17
|
+
/** New each time the password is set, kept when it is rehashed. */
|
|
18
|
+
readonly stamp: Field<string, { readonly unique: true }>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const PasswordHash: ModelOf<'PasswordHash', HashFields, []> =
|
|
22
|
+
defineModel({
|
|
23
|
+
name: 'PasswordHash',
|
|
24
|
+
fields: (f) => ({
|
|
25
|
+
userId: f.string({ unique: true }),
|
|
26
|
+
hash: f.string(),
|
|
27
|
+
stamp: f.string({ unique: true }),
|
|
28
|
+
}),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
type AttemptFields = {
|
|
32
|
+
readonly subject: Field<string, ScalarOptions<string>>
|
|
33
|
+
readonly address: Field<string, ScalarOptions<string>>
|
|
34
|
+
readonly at: Field<Date, ScalarOptions<Date>>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const PasswordAttempt: ModelOf<'PasswordAttempt', AttemptFields, []> =
|
|
38
|
+
defineModel({
|
|
39
|
+
name: 'PasswordAttempt',
|
|
40
|
+
fields: (f) => ({
|
|
41
|
+
subject: f.string(),
|
|
42
|
+
address: f.string(),
|
|
43
|
+
at: f.dateTime(),
|
|
44
|
+
}),
|
|
45
|
+
indexes: [{ fields: ['subject', 'address'] }, { fields: ['at'] }],
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
type RequestFields = {
|
|
49
|
+
readonly kind: Field<string, ScalarOptions<string>>
|
|
50
|
+
/** An address's network, or a user for the emails it receives. */
|
|
51
|
+
readonly subject: Field<string, ScalarOptions<string>>
|
|
52
|
+
readonly at: Field<Date, ScalarOptions<Date>>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const PasswordRequest: ModelOf<'PasswordRequest', RequestFields, []> =
|
|
56
|
+
defineModel({
|
|
57
|
+
name: 'PasswordRequest',
|
|
58
|
+
fields: (f) => ({
|
|
59
|
+
kind: f.string(),
|
|
60
|
+
subject: f.string(),
|
|
61
|
+
at: f.dateTime(),
|
|
62
|
+
}),
|
|
63
|
+
indexes: [{ fields: ['kind', 'subject'] }, { fields: ['at'] }],
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What the limits count for `address`: an IPv6 address's /64 network, the
|
|
68
|
+
* IPv4 address an IPv4-mapped one holds, in any notation, any other as it is
|
|
69
|
+
* (D-114).
|
|
70
|
+
*/
|
|
71
|
+
export function networkOf(address: string): string {
|
|
72
|
+
if (!ipaddr.IPv6.isValid(address)) return address
|
|
73
|
+
const ip = ipaddr.IPv6.parse(address)
|
|
74
|
+
if (ip.isIPv4MappedAddress()) return ip.toIPv4Address().toString()
|
|
75
|
+
const prefix = ip.parts.slice(0, 4).map((group) => group.toString(16))
|
|
76
|
+
return `${prefix.join(':')}::/64`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const tooMany = (oldest: Date, span: number, now: number): PublicError =>
|
|
80
|
+
new PublicError('TOO_MANY_ATTEMPTS', 'Too many attempts: try again later.', {
|
|
81
|
+
details: {
|
|
82
|
+
retryAfter: Math.max(
|
|
83
|
+
Math.ceil((oldest.getTime() + span - now) / 1000),
|
|
84
|
+
1,
|
|
85
|
+
),
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
/** Where an attempt comes from; `undefined` for server code, not counted. */
|
|
90
|
+
export type Attempt =
|
|
91
|
+
{ readonly subject: string; readonly address: string } | undefined
|
|
92
|
+
|
|
93
|
+
/** The `n`th most recent failure but `id` matching `where`, if there is one. */
|
|
94
|
+
async function nth(
|
|
95
|
+
where: { readonly subject?: string; readonly address?: string },
|
|
96
|
+
id: string,
|
|
97
|
+
n: number,
|
|
98
|
+
): Promise<Date | undefined> {
|
|
99
|
+
const failures = await PasswordAttempt.findMany(
|
|
100
|
+
{ where: { ...where, id: { not: id } }, orderBy: { at: 'desc' }, take: n },
|
|
101
|
+
M,
|
|
102
|
+
)
|
|
103
|
+
return failures[n - 1]?.at
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Counts an attempt as failed until `run` succeeds, so concurrent attempts
|
|
108
|
+
* 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.
|
|
111
|
+
*/
|
|
112
|
+
export async function limited<T>(
|
|
113
|
+
attempt: Attempt,
|
|
114
|
+
run: () => Promise<T>,
|
|
115
|
+
): Promise<T> {
|
|
116
|
+
if (attempt === undefined) return run()
|
|
117
|
+
const { subject } = attempt
|
|
118
|
+
const address = networkOf(attempt.address)
|
|
119
|
+
const now = Date.now()
|
|
120
|
+
const since = new Date(now - window)
|
|
121
|
+
await PasswordAttempt.deleteMany({ where: { at: { lte: since } } }, M)
|
|
122
|
+
const { id } = await PasswordAttempt.create(
|
|
123
|
+
{ data: { subject, address, at: new Date(now) } },
|
|
124
|
+
M,
|
|
125
|
+
)
|
|
126
|
+
// 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
|
+
]
|
|
132
|
+
.filter((at) => at !== undefined)
|
|
133
|
+
.toSorted((a, b) => b.getTime() - a.getTime())
|
|
134
|
+
if (oldest !== undefined) {
|
|
135
|
+
await PasswordAttempt.deleteMany({ where: { id } }, M)
|
|
136
|
+
throw tooMany(oldest, window, now)
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const result = await run()
|
|
140
|
+
await PasswordAttempt.deleteMany({ where: { subject, address } }, M)
|
|
141
|
+
return result
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error instanceof PublicError && error.code === 'TOO_MANY_ATTEMPTS') {
|
|
144
|
+
await PasswordAttempt.deleteMany({ where: { id } }, M)
|
|
145
|
+
}
|
|
146
|
+
throw error
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What the hourly limits count (D-114, D-117). */
|
|
151
|
+
export type RequestKind = 'signUp' | 'reset' | 'verify' | 'email'
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Counts a request of `kind` from `subject`; past `most` within one hour,
|
|
155
|
+
* the refusal instead, and the request is not counted. It is counted before
|
|
156
|
+
* the others are, so concurrent requests cannot pass the limit (D-114).
|
|
157
|
+
*/
|
|
158
|
+
export async function hourly(
|
|
159
|
+
kind: RequestKind,
|
|
160
|
+
subject: string,
|
|
161
|
+
most: number,
|
|
162
|
+
): Promise<PublicError | undefined> {
|
|
163
|
+
const now = Date.now()
|
|
164
|
+
await PasswordRequest.deleteMany(
|
|
165
|
+
{ where: { at: { lte: new Date(now - hour) } } },
|
|
166
|
+
M,
|
|
167
|
+
)
|
|
168
|
+
const { id } = await PasswordRequest.create(
|
|
169
|
+
{ data: { kind, subject, at: new Date(now) } },
|
|
170
|
+
M,
|
|
171
|
+
)
|
|
172
|
+
const where = { kind, subject, id: { not: id } }
|
|
173
|
+
const recent = await PasswordRequest.findMany(
|
|
174
|
+
{ where, orderBy: { at: 'desc' }, take: most },
|
|
175
|
+
M,
|
|
176
|
+
)
|
|
177
|
+
const oldest = recent[most - 1]
|
|
178
|
+
if (oldest === undefined) return undefined
|
|
179
|
+
await PasswordRequest.deleteMany({ where: { id } }, M)
|
|
180
|
+
return tooMany(oldest.at, hour, now)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Refuses the eleventh request of `kind` from `address` within one hour (D-114, D-117). */
|
|
184
|
+
export async function requestLimited(
|
|
185
|
+
kind: RequestKind,
|
|
186
|
+
address: string,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
const refused = await hourly(kind, networkOf(address), requests)
|
|
189
|
+
if (refused !== undefined) throw refused
|
|
190
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// `passwordPlugin()` and `password()` (D-113): a sign-in method, an ordinary
|
|
2
|
+
// plugin with its functions, its models, its commands and its options.
|
|
3
|
+
import type { Auth, AuthApi } from '@ultimabase/auth'
|
|
4
|
+
import { authPlugin } from '@ultimabase/auth'
|
|
5
|
+
import type {
|
|
6
|
+
Client,
|
|
7
|
+
ExposedModel,
|
|
8
|
+
Field,
|
|
9
|
+
Identity,
|
|
10
|
+
Payload,
|
|
11
|
+
PluginOf,
|
|
12
|
+
PluginTypes,
|
|
13
|
+
Queries,
|
|
14
|
+
Register,
|
|
15
|
+
UseOf,
|
|
16
|
+
UserOf,
|
|
17
|
+
} from 'ultimabase'
|
|
18
|
+
import {
|
|
19
|
+
definePlugin,
|
|
20
|
+
isMaster,
|
|
21
|
+
master,
|
|
22
|
+
PublicError,
|
|
23
|
+
Refused,
|
|
24
|
+
} from 'ultimabase'
|
|
25
|
+
import manifest from '../package.json' with { type: 'json' }
|
|
26
|
+
import type { SignedIn, State } from './core.ts'
|
|
27
|
+
import {
|
|
28
|
+
attemptOf,
|
|
29
|
+
createUser,
|
|
30
|
+
setPassword,
|
|
31
|
+
signedIn,
|
|
32
|
+
verified,
|
|
33
|
+
} from './core.ts'
|
|
34
|
+
import type { SendEmail } from './email-functions.ts'
|
|
35
|
+
import { emailFunctions, mailerOf } from './email-functions.ts'
|
|
36
|
+
import { passwordFunctions } from './functions.ts'
|
|
37
|
+
import type { Credentials, Property } from './input.ts'
|
|
38
|
+
import { emailOf, fieldProperty, passwordOf, valid } from './input.ts'
|
|
39
|
+
import { PasswordAttempt, PasswordHash, PasswordRequest } from './models.ts'
|
|
40
|
+
import { PasswordToken } from './tokens.ts'
|
|
41
|
+
|
|
42
|
+
/** The user fields a sign-up may take; any name before the user is registered. */
|
|
43
|
+
export type UserField = Register extends { readonly user: unknown }
|
|
44
|
+
? Extract<
|
|
45
|
+
Exclude<keyof UserOf<Register>, 'id' | 'createdAt' | 'updatedAt'>,
|
|
46
|
+
string
|
|
47
|
+
>
|
|
48
|
+
: string
|
|
49
|
+
|
|
50
|
+
export interface PasswordOptions {
|
|
51
|
+
/** The user fields `signUp` takes besides the email and the password; `false` closes sign-up. */
|
|
52
|
+
readonly signUp?: readonly UserField[] | false
|
|
53
|
+
/**
|
|
54
|
+
* Sends an email that carries a token: a reset or a verification link
|
|
55
|
+
* the application writes. Without it, no function sends one (D-117).
|
|
56
|
+
*/
|
|
57
|
+
readonly sendEmail?: SendEmail
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type Options = { readonly as?: Identity }
|
|
61
|
+
/** A transport's client, which counts the attempt and gets the cookie (D-115). */
|
|
62
|
+
type SignInOptions = Options & { readonly client?: Client }
|
|
63
|
+
type CreateData<F> = Parameters<Queries<F>['create']>[0]['data']
|
|
64
|
+
type UserRow<F> = Payload<F, {}>
|
|
65
|
+
|
|
66
|
+
/** password's commands on the user model (D-113). */
|
|
67
|
+
export interface PasswordCommands<F> {
|
|
68
|
+
/** Creates the user, its account and its hash; opens no session. */
|
|
69
|
+
signUp(
|
|
70
|
+
args: Credentials & { readonly data: CreateData<F> },
|
|
71
|
+
options?: Options,
|
|
72
|
+
): Promise<UserRow<F>>
|
|
73
|
+
/**
|
|
74
|
+
* Opens a session; with a `client`, counted and with its cookie as the
|
|
75
|
+
* signIn function, otherwise never limited.
|
|
76
|
+
*/
|
|
77
|
+
signIn(
|
|
78
|
+
args: Credentials,
|
|
79
|
+
options?: SignInOptions,
|
|
80
|
+
): Promise<SignedIn<UserRow<F>>>
|
|
81
|
+
/**
|
|
82
|
+
* Replaces the password and ends every session, as Master or as the user.
|
|
83
|
+
* It takes no current password: check the user's intent first, as
|
|
84
|
+
* changePassword does.
|
|
85
|
+
*/
|
|
86
|
+
setPassword(
|
|
87
|
+
args: { readonly id: string; readonly password: string },
|
|
88
|
+
options?: Options,
|
|
89
|
+
): Promise<void>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** password's types (D-57). */
|
|
93
|
+
export interface Password extends PluginTypes {
|
|
94
|
+
readonly name: 'password'
|
|
95
|
+
readonly requires: Auth
|
|
96
|
+
readonly appOptions: PasswordOptions
|
|
97
|
+
readonly addedFields: {
|
|
98
|
+
readonly emailVerified: Field<boolean, { readonly default: false }>
|
|
99
|
+
}
|
|
100
|
+
readonly commands: PasswordCommands<this['fields']>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const M = { as: master }
|
|
104
|
+
const states = new WeakMap<AuthApi, State>()
|
|
105
|
+
/** The `create` of each model whose `use` lists `password()`. */
|
|
106
|
+
const using = new WeakSet<object>()
|
|
107
|
+
|
|
108
|
+
function stateOf(auth: AuthApi): State {
|
|
109
|
+
const state = states.get(auth)
|
|
110
|
+
if (state === undefined) throw new Error('password has not started.')
|
|
111
|
+
return state
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The one model whose `use` lists `password()`. */
|
|
115
|
+
function usersOf(models: readonly ExposedModel[]): ExposedModel {
|
|
116
|
+
// oxlint-disable-next-line typescript/unbound-method -- compared by identity, never called.
|
|
117
|
+
const users = models.filter(({ queries }) => using.has(queries.create))
|
|
118
|
+
const [first, second] = users
|
|
119
|
+
if (first === undefined) {
|
|
120
|
+
throw new Refused(
|
|
121
|
+
'passwordPlugin() needs the user model to list it: add `use: [password()]` to defineUser.',
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
if (second !== undefined) {
|
|
125
|
+
throw new Refused(
|
|
126
|
+
`${first.schema.name} and ${second.schema.name} both use password(): only the user model does.`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
return first
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const reserved = new Set([
|
|
133
|
+
'id',
|
|
134
|
+
'createdAt',
|
|
135
|
+
'updatedAt',
|
|
136
|
+
'email',
|
|
137
|
+
'password',
|
|
138
|
+
'emailVerified',
|
|
139
|
+
])
|
|
140
|
+
|
|
141
|
+
/** The listed fields' properties; an unknown, a reserved or a missing one stops startup. */
|
|
142
|
+
function signUpFields(
|
|
143
|
+
users: ExposedModel,
|
|
144
|
+
listed: readonly string[],
|
|
145
|
+
): ReadonlyMap<string, Property> {
|
|
146
|
+
const { name, fields } = users.schema
|
|
147
|
+
const where = `passwordPlugin({ signUp })`
|
|
148
|
+
const properties = new Map<string, Property>()
|
|
149
|
+
for (const field of listed) {
|
|
150
|
+
const found = fields.find((each) => each.name === field)
|
|
151
|
+
if (found === undefined || reserved.has(field)) {
|
|
152
|
+
throw new Refused(
|
|
153
|
+
`${where}: ${name}.${field} is not a field sign-up may take.`,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
properties.set(
|
|
157
|
+
field,
|
|
158
|
+
fieldProperty(
|
|
159
|
+
found,
|
|
160
|
+
users.required.includes(field),
|
|
161
|
+
where,
|
|
162
|
+
users.validators[field],
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
const missing = users.required.filter((field) => !properties.has(field))
|
|
167
|
+
if (missing.length > 0) {
|
|
168
|
+
throw new Refused(
|
|
169
|
+
`${where}: ${name} needs ${missing.join(', ')} to be created; list them.`,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
return properties
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A sign-up's data, checked against the list unless the caller is Master. */
|
|
176
|
+
function signUpData(state: State, data: object, caller: Identity): object {
|
|
177
|
+
if (isMaster(caller)) return data
|
|
178
|
+
if (state.signUp === false) {
|
|
179
|
+
throw new PublicError('ACCESS_DENIED', 'Sign-up is closed.')
|
|
180
|
+
}
|
|
181
|
+
const { signUp } = state
|
|
182
|
+
const issues = Object.keys(data)
|
|
183
|
+
.filter((key) => !signUp.has(key))
|
|
184
|
+
.map((key) => ({ path: ['data', key], message: 'Unknown field.' }))
|
|
185
|
+
if (issues.length > 0) {
|
|
186
|
+
throw new PublicError(
|
|
187
|
+
'VALIDATION_FAILED',
|
|
188
|
+
'Sign-up takes the listed fields only.',
|
|
189
|
+
{
|
|
190
|
+
details: { issues },
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
return data
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The user model and the sign-up fields, `false` when sign-up is closed. */
|
|
198
|
+
function configOf(
|
|
199
|
+
options: PasswordOptions,
|
|
200
|
+
models: readonly ExposedModel[],
|
|
201
|
+
): {
|
|
202
|
+
readonly users: ExposedModel
|
|
203
|
+
readonly fields: ReadonlyMap<string, Property> | false
|
|
204
|
+
} {
|
|
205
|
+
const users = usersOf(models)
|
|
206
|
+
const listed = options.signUp ?? []
|
|
207
|
+
const fields = listed === false ? false : signUpFields(users, listed)
|
|
208
|
+
return { users, fields }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const callerOf = (options: Options | undefined): Identity =>
|
|
212
|
+
options?.as ?? { kind: 'anonymous' }
|
|
213
|
+
|
|
214
|
+
const definition = definePlugin<Password>({
|
|
215
|
+
name: 'password',
|
|
216
|
+
manifest,
|
|
217
|
+
requires: { auth: authPlugin },
|
|
218
|
+
models: [PasswordHash, PasswordAttempt, PasswordRequest, PasswordToken],
|
|
219
|
+
addedFields: (f) => ({
|
|
220
|
+
// Only Master confirms an email: rules read it (D-117).
|
|
221
|
+
emailVerified: f.boolean({ default: false }).with({
|
|
222
|
+
access: { create: () => false, update: () => false },
|
|
223
|
+
}),
|
|
224
|
+
}),
|
|
225
|
+
hooks: {
|
|
226
|
+
// The user's hash and tokens go with them (D-113, D-117).
|
|
227
|
+
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)
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
commands: ({ queries, plugins }) => {
|
|
236
|
+
using.add(queries.create)
|
|
237
|
+
return {
|
|
238
|
+
signUp: async ({ email, password, data }, options) => {
|
|
239
|
+
const state = stateOf(plugins.auth)
|
|
240
|
+
const checked = signUpData(state, data, callerOf(options))
|
|
241
|
+
const { user } = await createUser(
|
|
242
|
+
state,
|
|
243
|
+
valid(emailOf, email, 'email'),
|
|
244
|
+
valid(passwordOf, password, 'password'),
|
|
245
|
+
checked,
|
|
246
|
+
)
|
|
247
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the user model's row, created by its own queries.
|
|
248
|
+
return user as UserRow<Password['addedFields']>
|
|
249
|
+
},
|
|
250
|
+
signIn: async ({ email, password }, options) => {
|
|
251
|
+
const state = stateOf(plugins.auth)
|
|
252
|
+
const subject = valid(emailOf, email, 'email')
|
|
253
|
+
const client = options?.client
|
|
254
|
+
const attempt = attemptOf(subject, client)
|
|
255
|
+
const checked = await verified(state, subject, password, attempt)
|
|
256
|
+
const opened = await signedIn(state, checked, client)
|
|
257
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the user model's row, read by its own queries.
|
|
258
|
+
return opened as SignedIn<UserRow<Password['addedFields']>>
|
|
259
|
+
},
|
|
260
|
+
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
|
+
}
|
|
269
|
+
const state = stateOf(plugins.auth)
|
|
270
|
+
await setPassword(state, id, valid(passwordOf, password, 'password'))
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
// The functions are built before the APIs (D-89): they read the state
|
|
275
|
+
// when they run, and `api` stores it once auth's API exists.
|
|
276
|
+
api: ({ options, plugins, models, logger }) => {
|
|
277
|
+
const { users, fields } = configOf(options, models)
|
|
278
|
+
const signUp = fields === false ? false : new Set(fields.keys())
|
|
279
|
+
const { auth } = plugins
|
|
280
|
+
states.set(auth, { auth, users, signUp, logger, sending: new Set() })
|
|
281
|
+
},
|
|
282
|
+
// The emails under way still query: they end before the database closes.
|
|
283
|
+
stop: async ({ plugins }) => {
|
|
284
|
+
await Promise.all(stateOf(plugins.auth).sending)
|
|
285
|
+
},
|
|
286
|
+
functions: ({ options, plugins, models }) => {
|
|
287
|
+
const { users, fields } = configOf(options, models)
|
|
288
|
+
const state = () => stateOf(plugins.auth)
|
|
289
|
+
const { sendEmail } = options
|
|
290
|
+
const mailer = sendEmail === undefined ? undefined : mailerOf(sendEmail)
|
|
291
|
+
return [
|
|
292
|
+
...passwordFunctions(state, users, fields, mailer),
|
|
293
|
+
...(mailer === undefined ? [] : emailFunctions(state, mailer)),
|
|
294
|
+
]
|
|
295
|
+
},
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The password sign-in method; it requires `authPlugin()`.
|
|
300
|
+
*
|
|
301
|
+
* Known and kept (D-115): `signUp` refuses a taken email with
|
|
302
|
+
* `UNIQUE_VIOLATION`, which tells that the email has an account until email
|
|
303
|
+
* verification can hide it; a hundred failures on one account within 15
|
|
304
|
+
* minutes, from any addresses, lock it for everyone, and a hundred from one
|
|
305
|
+
* address lock that address out of every account (D-114); `SignedIn`
|
|
306
|
+
* holds the token even when the cookie is set.
|
|
307
|
+
*/
|
|
308
|
+
export const passwordPlugin: PluginOf<Password> = definition.plugin
|
|
309
|
+
/** The user model's entry: its commands, and its hash deleted with it. */
|
|
310
|
+
export const password: UseOf<Password> = definition.use
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// password's emails (D-117): the tokens their links carry, used once, and
|
|
2
|
+
// the emails each account receives within one hour.
|
|
3
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
4
|
+
import type { Field, ModelOf, ScalarOptions } from 'ultimabase'
|
|
5
|
+
import { defineModel, master, PublicError } from 'ultimabase'
|
|
6
|
+
import { hour, hourly } from './models.ts'
|
|
7
|
+
|
|
8
|
+
const M = { as: master }
|
|
9
|
+
/** The emails an account receives within one hour, of either kind. */
|
|
10
|
+
const emails = 3
|
|
11
|
+
|
|
12
|
+
/** What an email's token lets its holder do. */
|
|
13
|
+
export type EmailKind = 'reset' | 'verify'
|
|
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
|
+
}
|
|
24
|
+
|
|
25
|
+
const lifetimes: Readonly<Record<EmailKind, number>> = {
|
|
26
|
+
reset: hour,
|
|
27
|
+
verify: 24 * hour,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type TokenFields = {
|
|
31
|
+
readonly userId: Field<string, ScalarOptions<string>>
|
|
32
|
+
readonly kind: Field<string, ScalarOptions<string>>
|
|
33
|
+
/** The token's SHA-256: a leaked database gives no working link. */
|
|
34
|
+
readonly hash: Field<string, { readonly unique: true }>
|
|
35
|
+
readonly expiresAt: Field<Date, ScalarOptions<Date>>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const PasswordToken: ModelOf<'PasswordToken', TokenFields, []> =
|
|
39
|
+
defineModel({
|
|
40
|
+
name: 'PasswordToken',
|
|
41
|
+
fields: (f) => ({
|
|
42
|
+
userId: f.string(),
|
|
43
|
+
kind: f.string(),
|
|
44
|
+
hash: f.string({ unique: true }),
|
|
45
|
+
expiresAt: f.dateTime(),
|
|
46
|
+
}),
|
|
47
|
+
indexes: [{ fields: ['userId', 'kind'] }, { fields: ['expiresAt'] }],
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const hashOf = (token: string): string =>
|
|
51
|
+
createHash('sha256').update(token).digest('base64url')
|
|
52
|
+
|
|
53
|
+
/** A new token of `kind` for `userId`, which replaces the older one. */
|
|
54
|
+
export async function issueToken(
|
|
55
|
+
userId: string,
|
|
56
|
+
kind: EmailKind,
|
|
57
|
+
): Promise<string> {
|
|
58
|
+
const now = Date.now()
|
|
59
|
+
await PasswordToken.deleteMany(
|
|
60
|
+
{ where: { expiresAt: { lte: new Date(now) } } },
|
|
61
|
+
M,
|
|
62
|
+
)
|
|
63
|
+
await PasswordToken.deleteMany({ where: { userId, kind } }, M)
|
|
64
|
+
const token = randomBytes(32).toString('base64url')
|
|
65
|
+
const expiresAt = new Date(now + lifetimes[kind])
|
|
66
|
+
await PasswordToken.create(
|
|
67
|
+
{ data: { userId, kind, hash: hashOf(token), expiresAt } },
|
|
68
|
+
M,
|
|
69
|
+
)
|
|
70
|
+
return token
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const invalidLink = () =>
|
|
74
|
+
new PublicError('INVALID_CREDENTIALS', 'This link is invalid or has expired.')
|
|
75
|
+
|
|
76
|
+
/** The user a token of `kind` was issued to; a wrong, used or expired one fails. */
|
|
77
|
+
export async function redeemToken(
|
|
78
|
+
token: string,
|
|
79
|
+
kind: EmailKind,
|
|
80
|
+
): Promise<string> {
|
|
81
|
+
const hash = hashOf(token)
|
|
82
|
+
const stored = await PasswordToken.findUnique({ where: { hash } }, M)
|
|
83
|
+
if (stored?.kind !== kind) throw invalidLink()
|
|
84
|
+
// One delete wins, so a token used twice at once works once.
|
|
85
|
+
const { count } = await PasswordToken.deleteMany({ where: { hash } }, M)
|
|
86
|
+
if (count === 0 || stored.expiresAt.getTime() <= Date.now()) {
|
|
87
|
+
throw invalidLink()
|
|
88
|
+
}
|
|
89
|
+
return stored.userId
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Counts an email to `userId`; past three within one hour, the refusal
|
|
94
|
+
* instead, and the email is not counted.
|
|
95
|
+
*/
|
|
96
|
+
export const emailLimited = async (
|
|
97
|
+
userId: string,
|
|
98
|
+
): Promise<PublicError | undefined> => hourly('email', userId, emails)
|