@crombolo/server 0.13.2 → 0.14.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/package.json +12 -12
- package/src/adapters/bundles.ts +20 -139
- package/src/adapters/members.ts +22 -10
- package/src/adapters/schema-proposals.ts +10 -2
- package/src/adapters/users.ts +23 -1
- package/src/backoffice-external.ts +99 -0
- package/src/bundle-install.ts +261 -77
- package/src/bundle-sections.ts +160 -0
- package/src/bundles.ts +4 -0
- package/src/config.ts +15 -1
- package/src/delivery-members.ts +11 -2
- package/src/external-sign-in.ts +270 -0
- package/src/identity-provider-overrides.ts +283 -0
- package/src/identity-providers.ts +185 -0
- package/src/index.ts +8 -0
- package/src/media-store-azure.ts +6 -0
- package/src/media-store-s3.ts +1 -0
- package/src/media-store.ts +21 -0
- package/src/member-auth.ts +232 -15
- package/src/ports.ts +55 -1
- package/src/schema-store.ts +16 -0
- package/src/schema.ts +5 -7
- package/src/server.ts +112 -7
- package/src/site-file.ts +258 -0
- package/src/site-replace.ts +188 -0
- package/src/site-search.ts +51 -0
- package/src/site-sections.ts +234 -0
- package/src/tar.ts +155 -0
- package/src/two-factor.ts +138 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External identity providers (story 031): typed presets a site's config lists,
|
|
3
|
+
* one list for members and one for backoffice users. Each preset knows its
|
|
4
|
+
* provider's issuer, scopes and claims; the rest says what a first sign-in may
|
|
5
|
+
* do and how groups follow the account.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* defineConfig({
|
|
9
|
+
* backofficeIdentityProviders: [entra({ tenantId, clientId, clientSecret, groups: { map: { … } } })],
|
|
10
|
+
* memberIdentityProviders: [awsIdentityCenter({ issuer, clientId, clientSecret })],
|
|
11
|
+
* })
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
import type { OidcClaims, OidcProviderOptions } from '@crombolo/auth'
|
|
15
|
+
|
|
16
|
+
/** What every preset takes besides the provider's own settings. */
|
|
17
|
+
export interface IdentityProviderOptions {
|
|
18
|
+
/** The provider's id, in its URLs and in `external_login`; the preset's name by default. */
|
|
19
|
+
id?: string
|
|
20
|
+
/** What its sign-in button says: "Sign in with {name}". */
|
|
21
|
+
name?: string
|
|
22
|
+
/** Create a member or user for an account nobody has yet. On by default. */
|
|
23
|
+
autoCreate?: boolean
|
|
24
|
+
/**
|
|
25
|
+
* Link a first sign-in to an existing member or user with the same email,
|
|
26
|
+
* when the provider says the email is verified. Off by default: without it, an
|
|
27
|
+
* email already taken is refused rather than merged.
|
|
28
|
+
*/
|
|
29
|
+
linkByEmail?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* Treat every email this provider sends as verified. For a provider whose
|
|
32
|
+
* emails are the organisation's own, such as one Entra tenant, which sends no
|
|
33
|
+
* `email_verified` claim.
|
|
34
|
+
*/
|
|
35
|
+
emailsVerified?: boolean
|
|
36
|
+
/**
|
|
37
|
+
* Groups that follow the account: claim values mapped to member groups (for
|
|
38
|
+
* members) or user group aliases (for users), made to match the claim on
|
|
39
|
+
* every sign-in. Groups outside the map are left as they are.
|
|
40
|
+
*/
|
|
41
|
+
groups?: { claim?: string; map: Record<string, string> }
|
|
42
|
+
/** Groups a new member or user joins, besides the mapped ones. */
|
|
43
|
+
defaultGroups?: readonly string[]
|
|
44
|
+
/** Members only: the member type a new member is created as, by alias; `Member` by default. */
|
|
45
|
+
memberType?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Which preset made a provider: what the Entra bundle looks for. */
|
|
49
|
+
export type IdentityProviderKind = 'entra' | 'aws' | 'oidc'
|
|
50
|
+
|
|
51
|
+
/** A provider, ready for the config. */
|
|
52
|
+
export interface IdentityProvider {
|
|
53
|
+
kind: IdentityProviderKind
|
|
54
|
+
/** Entra only: the tenant its issuer is for, which an override may change. */
|
|
55
|
+
tenantId?: string
|
|
56
|
+
oidc: OidcProviderOptions
|
|
57
|
+
autoCreate: boolean
|
|
58
|
+
linkByEmail: boolean
|
|
59
|
+
emailsVerified: boolean
|
|
60
|
+
groupMap: Readonly<Record<string, string>>
|
|
61
|
+
defaultGroups: readonly string[]
|
|
62
|
+
memberType: string | undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ID = /^[a-z0-9][a-z0-9-]*$/
|
|
66
|
+
|
|
67
|
+
/** Entra's issuer for a tenant. */
|
|
68
|
+
export const entraIssuer = (tenantId: string) =>
|
|
69
|
+
`https://login.microsoftonline.com/${tenantId}/v2.0`
|
|
70
|
+
|
|
71
|
+
function preset(
|
|
72
|
+
kind: IdentityProviderKind,
|
|
73
|
+
defaults: { id: string; name: string },
|
|
74
|
+
provider: { issuer: string; clientId: string; clientSecret?: string; scopes?: readonly string[] },
|
|
75
|
+
claims: OidcClaims,
|
|
76
|
+
options: IdentityProviderOptions,
|
|
77
|
+
): IdentityProvider {
|
|
78
|
+
const id = options.id ?? defaults.id
|
|
79
|
+
if (!ID.test(id))
|
|
80
|
+
throw new Error(`An identity provider's id is letters, digits and dashes: "${id}" is not.`)
|
|
81
|
+
if (!provider.issuer) throw new Error(`The ${defaults.name} identity provider needs an issuer.`)
|
|
82
|
+
if (!provider.clientId)
|
|
83
|
+
throw new Error(`The ${defaults.name} identity provider needs a clientId.`)
|
|
84
|
+
return {
|
|
85
|
+
kind,
|
|
86
|
+
oidc: {
|
|
87
|
+
id,
|
|
88
|
+
name: options.name ?? defaults.name,
|
|
89
|
+
issuer: provider.issuer,
|
|
90
|
+
clientId: provider.clientId,
|
|
91
|
+
clientSecret: provider.clientSecret,
|
|
92
|
+
scopes: provider.scopes ?? ['openid', 'profile', 'email'],
|
|
93
|
+
// A group map with no claim named reads the usual `groups` claim.
|
|
94
|
+
claims: {
|
|
95
|
+
...claims,
|
|
96
|
+
groups: options.groups?.claim ?? claims.groups ?? (options.groups ? 'groups' : undefined),
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
autoCreate: options.autoCreate ?? true,
|
|
100
|
+
linkByEmail: options.linkByEmail ?? false,
|
|
101
|
+
emailsVerified: options.emailsVerified ?? false,
|
|
102
|
+
groupMap: options.groups?.map ?? {},
|
|
103
|
+
defaultGroups: options.defaultGroups ?? [],
|
|
104
|
+
memberType: options.memberType,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Microsoft Entra ID, for one tenant. Register a web app with a client secret
|
|
110
|
+
* and the redirect URLs in `docs/operations.md`; for groups, add the `groups`
|
|
111
|
+
* claim to its token configuration.
|
|
112
|
+
*/
|
|
113
|
+
export function entra(
|
|
114
|
+
options: IdentityProviderOptions & { tenantId: string; clientId: string; clientSecret?: string },
|
|
115
|
+
): IdentityProvider {
|
|
116
|
+
if (!options.tenantId) throw new Error('The Entra identity provider needs a tenantId.')
|
|
117
|
+
const provider = preset(
|
|
118
|
+
'entra',
|
|
119
|
+
{ id: 'entra', name: 'Microsoft' },
|
|
120
|
+
{
|
|
121
|
+
issuer: entraIssuer(options.tenantId),
|
|
122
|
+
clientId: options.clientId,
|
|
123
|
+
clientSecret: options.clientSecret,
|
|
124
|
+
},
|
|
125
|
+
{ email: 'email', name: 'name', groups: 'groups' },
|
|
126
|
+
options,
|
|
127
|
+
)
|
|
128
|
+
return { ...provider, tenantId: options.tenantId }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* AWS IAM Identity Center, through the OpenID Connect application its
|
|
133
|
+
* instance issues; `issuer` is that instance's issuer URL.
|
|
134
|
+
*/
|
|
135
|
+
export function awsIdentityCenter(
|
|
136
|
+
options: IdentityProviderOptions & { issuer: string; clientId: string; clientSecret?: string },
|
|
137
|
+
): IdentityProvider {
|
|
138
|
+
return preset(
|
|
139
|
+
'aws',
|
|
140
|
+
{ id: 'aws', name: 'AWS' },
|
|
141
|
+
{ issuer: options.issuer, clientId: options.clientId, clientSecret: options.clientSecret },
|
|
142
|
+
{ email: 'email', name: 'name', groups: 'groups' },
|
|
143
|
+
options,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Any OpenID Connect provider, by its issuer: Google, Okta, Auth0, Keycloak and the like. */
|
|
148
|
+
export function oidc(
|
|
149
|
+
options: IdentityProviderOptions & {
|
|
150
|
+
id: string
|
|
151
|
+
name: string
|
|
152
|
+
issuer: string
|
|
153
|
+
clientId: string
|
|
154
|
+
clientSecret?: string
|
|
155
|
+
scopes?: readonly string[]
|
|
156
|
+
claims?: Partial<OidcClaims>
|
|
157
|
+
},
|
|
158
|
+
): IdentityProvider {
|
|
159
|
+
return preset(
|
|
160
|
+
'oidc',
|
|
161
|
+
{ id: options.id, name: options.name },
|
|
162
|
+
{
|
|
163
|
+
issuer: options.issuer,
|
|
164
|
+
clientId: options.clientId,
|
|
165
|
+
clientSecret: options.clientSecret,
|
|
166
|
+
scopes: options.scopes,
|
|
167
|
+
},
|
|
168
|
+
{ email: 'email', name: 'name', ...options.claims },
|
|
169
|
+
options,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Refuses two providers with one id in a list, which would share one set of URLs. */
|
|
174
|
+
export function checkIdentityProviders(
|
|
175
|
+
list: readonly IdentityProvider[],
|
|
176
|
+
label: string,
|
|
177
|
+
): readonly IdentityProvider[] {
|
|
178
|
+
const seen = new Set<string>()
|
|
179
|
+
for (const provider of list) {
|
|
180
|
+
if (seen.has(provider.oidc.id))
|
|
181
|
+
throw new Error(`Two ${label} identity providers have the id "${provider.oidc.id}".`)
|
|
182
|
+
seen.add(provider.oidc.id)
|
|
183
|
+
}
|
|
184
|
+
return list
|
|
185
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ export * from './email-postmark.ts'
|
|
|
34
34
|
export * from './email-resend.ts'
|
|
35
35
|
export * from './email-ses.ts'
|
|
36
36
|
export * from './extensions-notice.ts'
|
|
37
|
+
export * from './external-sign-in.ts'
|
|
37
38
|
export * from './file-intake.ts'
|
|
38
39
|
export * from './form-files.ts'
|
|
39
40
|
export * from './form-submit.ts'
|
|
@@ -43,6 +44,8 @@ export * from './forms.ts'
|
|
|
43
44
|
export * from './git.ts'
|
|
44
45
|
export * from './git-github.ts'
|
|
45
46
|
export * from './git-routes.ts'
|
|
47
|
+
export * from './identity-provider-overrides.ts'
|
|
48
|
+
export * from './identity-providers.ts'
|
|
46
49
|
export * from './imaging.ts'
|
|
47
50
|
export * from './jobs.ts'
|
|
48
51
|
export * from './listen.ts'
|
|
@@ -67,8 +70,13 @@ export * from './seo.ts'
|
|
|
67
70
|
export * from './server.ts'
|
|
68
71
|
export * from './server-events.ts'
|
|
69
72
|
export * from './setup.ts'
|
|
73
|
+
export * from './site-file.ts'
|
|
74
|
+
export * from './site-replace.ts'
|
|
75
|
+
export * from './site-sections.ts'
|
|
70
76
|
export * from './status.ts'
|
|
77
|
+
export * from './tar.ts'
|
|
71
78
|
export * from './transfer-dashboard.ts'
|
|
79
|
+
export * from './two-factor.ts'
|
|
72
80
|
export * from './udt.ts'
|
|
73
81
|
export * from './upgrade-summary.ts'
|
|
74
82
|
export * from './url-settings.ts'
|
package/src/media-store-azure.ts
CHANGED
|
@@ -156,6 +156,12 @@ export function azureMediaStore(options: AzureMediaStoreOptions): MediaStore {
|
|
|
156
156
|
if (!response.ok) throw new Error(`Azure GET ${key} failed with ${response.status}.`)
|
|
157
157
|
return new Uint8Array(await response.arrayBuffer())
|
|
158
158
|
},
|
|
159
|
+
stream: async () => {
|
|
160
|
+
const response = await send('GET', url)
|
|
161
|
+
if (!response.ok || !response.body)
|
|
162
|
+
throw new Error(`Azure GET ${key} failed with ${response.status}.`)
|
|
163
|
+
return response.body
|
|
164
|
+
},
|
|
159
165
|
}
|
|
160
166
|
},
|
|
161
167
|
|
package/src/media-store-s3.ts
CHANGED
|
@@ -61,6 +61,7 @@ export function s3MediaStore(options: S3MediaStoreOptions): MediaStore {
|
|
|
61
61
|
etag: stat.etag.replaceAll('"', ''),
|
|
62
62
|
contentType: stat.type || contentTypeFor(key),
|
|
63
63
|
bytes: async () => new Uint8Array(await file.arrayBuffer()),
|
|
64
|
+
stream: async () => file.stream(),
|
|
64
65
|
}
|
|
65
66
|
} catch {
|
|
66
67
|
// A missing object is a thrown NoSuchKey, not a null stat.
|
package/src/media-store.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface StoredFile {
|
|
|
31
31
|
etag: string
|
|
32
32
|
contentType: string
|
|
33
33
|
bytes(): Promise<Uint8Array>
|
|
34
|
+
/** The bytes as a stream, for a file too big to hold (story 104); `streamOf` falls back to `bytes()`. */
|
|
35
|
+
stream?(): Promise<ReadableStream<Uint8Array>>
|
|
34
36
|
/** A path on this machine, when the store has one; lets the server stream it. */
|
|
35
37
|
localPath?: string
|
|
36
38
|
}
|
|
@@ -51,6 +53,24 @@ export interface MediaStore {
|
|
|
51
53
|
publicUrl?(key: string): string | undefined
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
/** A stored file's bytes in chunks: streamed where the store can, whole where it cannot. */
|
|
57
|
+
export async function* streamOf(file: StoredFile): AsyncIterable<Uint8Array> {
|
|
58
|
+
if (!file.stream) {
|
|
59
|
+
yield await file.bytes()
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
const reader = (await file.stream()).getReader()
|
|
63
|
+
try {
|
|
64
|
+
for (;;) {
|
|
65
|
+
const { done, value } = await reader.read()
|
|
66
|
+
if (done) return
|
|
67
|
+
yield value
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
reader.releaseLock()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
54
74
|
/** Content types by extension, for stores that do not infer one. */
|
|
55
75
|
const CONTENT_TYPES: Record<string, string> = {
|
|
56
76
|
avif: 'image/avif',
|
|
@@ -139,6 +159,7 @@ export function fileSystemMediaStore(
|
|
|
139
159
|
etag: `${stat.size.toString(16)}-${Math.trunc(stat.mtimeMs).toString(16)}`,
|
|
140
160
|
contentType: file.type || contentTypeFor(key),
|
|
141
161
|
bytes: () => file.bytes(),
|
|
162
|
+
stream: async () => file.stream(),
|
|
142
163
|
localPath: path,
|
|
143
164
|
}
|
|
144
165
|
},
|
package/src/member-auth.ts
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* POST /umbraco/members/logout
|
|
13
13
|
* POST /umbraco/members/register name, email, username, password, returnUrl?
|
|
14
14
|
* GET /umbraco/members/current
|
|
15
|
+
* POST /umbraco/members/login/2fa code, returnUrl? the second step, when 2FA is on
|
|
16
|
+
* GET /umbraco/members/2fa the signed-in member's 2FA state
|
|
17
|
+
* POST /umbraco/members/2fa/setup | enable | disable turning it on and off (story 032)
|
|
15
18
|
*
|
|
16
19
|
* A form post is answered with a redirect, a JSON post with JSON, so the same
|
|
17
20
|
* endpoint serves a plain `<form>` in a TSX template and a fetch from the page.
|
|
@@ -27,14 +30,17 @@ import {
|
|
|
27
30
|
passwordConfigJson,
|
|
28
31
|
readCookie,
|
|
29
32
|
readMemberTicket,
|
|
33
|
+
readShortLived,
|
|
30
34
|
serializeCookie,
|
|
31
35
|
signMemberTicket,
|
|
36
|
+
signShortLived,
|
|
32
37
|
verifyPassword,
|
|
33
38
|
} from '@crombolo/auth'
|
|
34
39
|
import {
|
|
35
40
|
DEFAULT_PASSWORD_CONFIGURATION,
|
|
36
41
|
isValidPassword,
|
|
37
42
|
localReturnUrl,
|
|
43
|
+
memberMayOpen,
|
|
38
44
|
ObjectTypes,
|
|
39
45
|
} from '@crombolo/core'
|
|
40
46
|
import {
|
|
@@ -45,14 +51,25 @@ import {
|
|
|
45
51
|
type NodeSchemaState,
|
|
46
52
|
PublicAccessRepository,
|
|
47
53
|
} from '@crombolo/data'
|
|
48
|
-
import
|
|
54
|
+
import {
|
|
55
|
+
type AccessDecision,
|
|
56
|
+
MEMBER_EXTERNAL_SIGN_IN_PATH,
|
|
57
|
+
type PublishedContent,
|
|
58
|
+
type RequestMember,
|
|
59
|
+
} from '@crombolo/render'
|
|
49
60
|
import type { CromboloConfig } from './config.ts'
|
|
61
|
+
import { createExternalSignIn, type ExternalAccount } from './external-sign-in.ts'
|
|
62
|
+
import { IdentityProviderOverrides } from './identity-provider-overrides.ts'
|
|
63
|
+
import { MEMBER_TWO_FACTOR_PROVIDER, TwoFactorService } from './two-factor.ts'
|
|
50
64
|
|
|
51
65
|
/** The key member tickets are signed with, generated once and shared by every node. */
|
|
52
66
|
const SIGNING_KEY = 'crombolo/memberCookieKey'
|
|
53
67
|
|
|
54
68
|
export const MEMBER_ROUTE_PREFIX = '/umbraco/members'
|
|
55
69
|
|
|
70
|
+
/** How long a member has to enter their code after their password. */
|
|
71
|
+
const TWO_FACTOR_PENDING_SECONDS = 5 * 60
|
|
72
|
+
|
|
56
73
|
export interface MemberAuthOptions {
|
|
57
74
|
/** The schema state this node runs at; a registration is refused without it. */
|
|
58
75
|
nodeState?: NodeSchemaState
|
|
@@ -72,6 +89,11 @@ export interface MemberAuth {
|
|
|
72
89
|
handle(request: Request, pathname: string): Promise<Response | undefined>
|
|
73
90
|
/** The `Set-Cookie` value that ends a member's site session. */
|
|
74
91
|
signOutCookie(): string
|
|
92
|
+
/**
|
|
93
|
+
* Where a "Sign in with …" link for a member identity provider goes, by its
|
|
94
|
+
* id; undefined for a provider the site does not configure.
|
|
95
|
+
*/
|
|
96
|
+
externalSignInUrl(providerId: string, returnUrl?: string): string | undefined
|
|
75
97
|
}
|
|
76
98
|
|
|
77
99
|
const isFormPost = (request: Request) =>
|
|
@@ -149,6 +171,13 @@ export function createMemberAuth(
|
|
|
149
171
|
return signingKey
|
|
150
172
|
}
|
|
151
173
|
|
|
174
|
+
const twoFactor = new TwoFactorService(db, {
|
|
175
|
+
secretKey: config.secretKey,
|
|
176
|
+
issuer: config.siteName,
|
|
177
|
+
})
|
|
178
|
+
const pendingCookie = `${cookie}TwoFactor`
|
|
179
|
+
const flagged = (url: string, flags: string) => `${url}${url.includes('?') ? '&' : '?'}${flags}`
|
|
180
|
+
|
|
152
181
|
const asRequestMember = (member: {
|
|
153
182
|
key: string
|
|
154
183
|
name: string
|
|
@@ -176,6 +205,93 @@ export function createMemberAuth(
|
|
|
176
205
|
})
|
|
177
206
|
}
|
|
178
207
|
|
|
208
|
+
/** Member group keys by name, for the names a registration or a provider names. */
|
|
209
|
+
const groupKeysFor = async (names: readonly string[]): Promise<string[]> => {
|
|
210
|
+
const keys: string[] = []
|
|
211
|
+
for (const groupName of names) {
|
|
212
|
+
const rows = await db.query<{ unique_id: string }>(
|
|
213
|
+
'SELECT unique_id FROM node WHERE text = ? AND node_object_type = ?',
|
|
214
|
+
[groupName, ObjectTypes.MemberGroup],
|
|
215
|
+
)
|
|
216
|
+
if (rows[0]) keys.push(String(rows[0].unique_id))
|
|
217
|
+
}
|
|
218
|
+
return keys
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const asAccount = (member: { key: string; email: string; roles: string[] }): ExternalAccount => ({
|
|
222
|
+
key: member.key,
|
|
223
|
+
email: member.email,
|
|
224
|
+
groups: member.roles,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const MEMBER_EXTERNAL_PATH = MEMBER_EXTERNAL_SIGN_IN_PATH
|
|
228
|
+
const external =
|
|
229
|
+
config.memberIdentityProviders.length === 0
|
|
230
|
+
? undefined
|
|
231
|
+
: createExternalSignIn(
|
|
232
|
+
db,
|
|
233
|
+
{
|
|
234
|
+
audience: 'member',
|
|
235
|
+
providers: config.memberIdentityProviders,
|
|
236
|
+
basePath: MEMBER_EXTERNAL_PATH,
|
|
237
|
+
byKey: async (key) => {
|
|
238
|
+
const member = await members.byKey(key)
|
|
239
|
+
return member ? asAccount(member) : undefined
|
|
240
|
+
},
|
|
241
|
+
byEmail: async (email) => {
|
|
242
|
+
const member = await members.byEmail(email)
|
|
243
|
+
return member ? asAccount(member) : undefined
|
|
244
|
+
},
|
|
245
|
+
create: async (identity, provider, groups) => {
|
|
246
|
+
const alias = provider.memberType ?? config.memberRegistrationType
|
|
247
|
+
const type = await memberTypes.byAlias(alias)
|
|
248
|
+
if (!type) throw new Error(`The member type "${alias}" does not exist.`)
|
|
249
|
+
const email = identity.email as string
|
|
250
|
+
// The email is the username, as registration makes it, unless
|
|
251
|
+
// someone already signs in with that name.
|
|
252
|
+
const username = (await members.taken('username', email))
|
|
253
|
+
? `${provider.oidc.id}-${identity.subject}`
|
|
254
|
+
: email
|
|
255
|
+
const created = await members.create(
|
|
256
|
+
{
|
|
257
|
+
key: crypto.randomUUID(),
|
|
258
|
+
contentTypeKey: type.key,
|
|
259
|
+
componentKey: null,
|
|
260
|
+
parentKey: null,
|
|
261
|
+
values: [],
|
|
262
|
+
variants: [{ culture: null, segment: null, name: identity.name || email }],
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
email,
|
|
266
|
+
username,
|
|
267
|
+
isApproved: true,
|
|
268
|
+
isLockedOut: false,
|
|
269
|
+
groupKeys: await groupKeysFor(groups),
|
|
270
|
+
},
|
|
271
|
+
)
|
|
272
|
+
return asAccount(created)
|
|
273
|
+
},
|
|
274
|
+
setGroups: async (account, groups) => {
|
|
275
|
+
await members.setGroups(account.key, await groupKeysFor(groups))
|
|
276
|
+
},
|
|
277
|
+
signIn: async (account) => {
|
|
278
|
+
const member = await members.byKey(account.key)
|
|
279
|
+
if (!member?.isApproved || member.isLockedOut)
|
|
280
|
+
return { refused: 'This account cannot sign in at the moment.' }
|
|
281
|
+
await members.recordLogin(member.key)
|
|
282
|
+
return { cookies: [await issue(member.key, member.securityStamp, false)] }
|
|
283
|
+
},
|
|
284
|
+
failed: (returnUrl, reason) =>
|
|
285
|
+
`${returnUrl}${returnUrl.includes('?') ? '&' : '?'}memberLoginFailed=1&memberLoginError=${encodeURIComponent(reason)}`,
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
applicationUrl: config.applicationUrl,
|
|
289
|
+
secureCookies: config.secureCookies,
|
|
290
|
+
resolve: (provider) =>
|
|
291
|
+
new IdentityProviderOverrides(db, config.secretKey).apply('member', provider),
|
|
292
|
+
},
|
|
293
|
+
)
|
|
294
|
+
|
|
179
295
|
const resolve: MemberAuth['resolve'] = async (request) => {
|
|
180
296
|
const ticket = await readMemberTicket(await secret(), readCookie(request, cookie))
|
|
181
297
|
if (!ticket) return undefined
|
|
@@ -196,12 +312,7 @@ export function createMemberAuth(
|
|
|
196
312
|
// branch they serve, or the substitution would never terminate.
|
|
197
313
|
if (here === entry.loginNodeKey || here === entry.errorNodeKey) return { status: 'allowed' }
|
|
198
314
|
if (!member) return { status: 'substitute', contentKey: entry.loginNodeKey }
|
|
199
|
-
|
|
200
|
-
entry.memberUserNames.some((name) => name.toLowerCase() === member.username.toLowerCase()) ||
|
|
201
|
-
entry.memberGroupNames.some((name) =>
|
|
202
|
-
member.roles.some((role) => role.toLowerCase() === name.toLowerCase()),
|
|
203
|
-
)
|
|
204
|
-
return allowed
|
|
315
|
+
return memberMayOpen(entry, { username: member.username, groups: member.roles })
|
|
205
316
|
? { status: 'allowed' }
|
|
206
317
|
: { status: 'substitute', contentKey: entry.errorNodeKey }
|
|
207
318
|
}
|
|
@@ -268,6 +379,28 @@ export function createMemberAuth(
|
|
|
268
379
|
await hashPassword(input.password ?? ''),
|
|
269
380
|
passwordConfigJson(),
|
|
270
381
|
)
|
|
382
|
+
if ((await twoFactor.providers(member.key)).includes(MEMBER_TWO_FACTOR_PROVIDER)) {
|
|
383
|
+
const pending = await signShortLived(
|
|
384
|
+
await secret(),
|
|
385
|
+
{ sub: member.key, persist: input.rememberMe === 'true' ? 1 : 0 },
|
|
386
|
+
TWO_FACTOR_PENDING_SECONDS,
|
|
387
|
+
)
|
|
388
|
+
return answer(
|
|
389
|
+
request,
|
|
390
|
+
{ ok: true, body: { twoFactorRequired: true } },
|
|
391
|
+
{
|
|
392
|
+
returnUrl: flagged(back, 'memberTwoFactorRequired=1'),
|
|
393
|
+
cookies: [
|
|
394
|
+
serializeCookie({
|
|
395
|
+
name: pendingCookie,
|
|
396
|
+
value: pending,
|
|
397
|
+
secure: config.secureCookies,
|
|
398
|
+
maxAgeSeconds: TWO_FACTOR_PENDING_SECONDS,
|
|
399
|
+
}),
|
|
400
|
+
],
|
|
401
|
+
},
|
|
402
|
+
)
|
|
403
|
+
}
|
|
271
404
|
await members.recordLogin(member.key)
|
|
272
405
|
return answer(
|
|
273
406
|
request,
|
|
@@ -279,6 +412,87 @@ export function createMemberAuth(
|
|
|
279
412
|
)
|
|
280
413
|
}
|
|
281
414
|
|
|
415
|
+
/** The second step: the code from the app, or a recovery code, after the password. */
|
|
416
|
+
const verifyTwoFactor = async (request: Request): Promise<Response> => {
|
|
417
|
+
const input = await readInput(request)
|
|
418
|
+
const back = safeReturnUrl(input.returnUrl) ?? '/'
|
|
419
|
+
const pending = await readShortLived(await secret(), readCookie(request, pendingCookie))
|
|
420
|
+
const key = typeof pending?.sub === 'string' ? pending.sub : undefined
|
|
421
|
+
const member = key ? await members.byKey(key) : undefined
|
|
422
|
+
if (!member || member.isLockedOut || !member.isApproved)
|
|
423
|
+
return answer(
|
|
424
|
+
request,
|
|
425
|
+
{ ok: false, status: 401, error: 'Sign in again.' },
|
|
426
|
+
{
|
|
427
|
+
returnUrl: flagged(back, 'memberLoginFailed=1'),
|
|
428
|
+
cookies: [expireCookie(pendingCookie, config.secureCookies)],
|
|
429
|
+
},
|
|
430
|
+
)
|
|
431
|
+
if (!(await twoFactor.verify(member.key, MEMBER_TWO_FACTOR_PROVIDER, input.code ?? ''))) {
|
|
432
|
+
await members.recordFailedLogin(member.key, config.maxFailedPasswordAttempts)
|
|
433
|
+
return answer(
|
|
434
|
+
request,
|
|
435
|
+
{ ok: false, status: 400, error: 'That code is not right.' },
|
|
436
|
+
{ returnUrl: flagged(back, 'memberTwoFactorRequired=1&memberLoginFailed=1') },
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
await members.recordLogin(member.key)
|
|
440
|
+
return answer(
|
|
441
|
+
request,
|
|
442
|
+
{ ok: true, body: { member: asRequestMember(member) } },
|
|
443
|
+
{
|
|
444
|
+
returnUrl: back,
|
|
445
|
+
cookies: [
|
|
446
|
+
await issue(member.key, member.securityStamp, pending?.persist === 1),
|
|
447
|
+
expireCookie(pendingCookie, config.secureCookies),
|
|
448
|
+
],
|
|
449
|
+
},
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The signed-in member's own two-factor settings; JSON, as the recovery codes must be shown. */
|
|
454
|
+
const manageTwoFactor = async (request: Request, step: string): Promise<Response> => {
|
|
455
|
+
const member = await resolve(request)
|
|
456
|
+
if (!member) return Response.json({ error: 'Sign in first.' }, { status: 401 })
|
|
457
|
+
const refused = (message: string, status = 400) => Response.json({ error: message }, { status })
|
|
458
|
+
if (step === '') {
|
|
459
|
+
if (request.method !== 'GET') return new Response(null, { status: 405 })
|
|
460
|
+
return Response.json({
|
|
461
|
+
available: twoFactor.available,
|
|
462
|
+
enabled: (await twoFactor.providers(member.key)).includes(MEMBER_TWO_FACTOR_PROVIDER),
|
|
463
|
+
recoveryCodesLeft: await twoFactor.recoveryCodesLeft(member.key),
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
if (request.method !== 'POST') return new Response(null, { status: 405 })
|
|
467
|
+
if (!fromThisSite(request)) return new Response('Forbidden', { status: 403 })
|
|
468
|
+
const input = await readInput(request)
|
|
469
|
+
switch (step) {
|
|
470
|
+
case 'setup': {
|
|
471
|
+
const setup = twoFactor.setup(member.email || member.username)
|
|
472
|
+
return setup.ok ? Response.json(setup.value) : refused(setup.message)
|
|
473
|
+
}
|
|
474
|
+
case 'enable': {
|
|
475
|
+
const enabled = await twoFactor.enable(
|
|
476
|
+
member.key,
|
|
477
|
+
MEMBER_TWO_FACTOR_PROVIDER,
|
|
478
|
+
input.secret ?? '',
|
|
479
|
+
input.code ?? '',
|
|
480
|
+
)
|
|
481
|
+
return enabled.ok ? Response.json(enabled.value) : refused(enabled.message)
|
|
482
|
+
}
|
|
483
|
+
case 'disable': {
|
|
484
|
+
const disabled = await twoFactor.disable(
|
|
485
|
+
member.key,
|
|
486
|
+
MEMBER_TWO_FACTOR_PROVIDER,
|
|
487
|
+
input.code ?? '',
|
|
488
|
+
)
|
|
489
|
+
return disabled.ok ? Response.json({ ok: true }) : refused(disabled.message)
|
|
490
|
+
}
|
|
491
|
+
default:
|
|
492
|
+
return new Response(null, { status: 404 })
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
282
496
|
const logout = async (request: Request): Promise<Response> => {
|
|
283
497
|
const input = await readInput(request)
|
|
284
498
|
return answer(
|
|
@@ -334,14 +548,7 @@ export function createMemberAuth(
|
|
|
334
548
|
status: 400,
|
|
335
549
|
error: `A password must be at least ${DEFAULT_PASSWORD_CONFIGURATION.minimumPasswordLength} characters.`,
|
|
336
550
|
})
|
|
337
|
-
const groupKeys
|
|
338
|
-
for (const groupName of config.memberRegistrationGroups) {
|
|
339
|
-
const rows = await db.query<{ unique_id: string }>(
|
|
340
|
-
'SELECT unique_id FROM node WHERE text = ? AND node_object_type = ?',
|
|
341
|
-
[groupName, ObjectTypes.MemberGroup],
|
|
342
|
-
)
|
|
343
|
-
if (rows[0]) groupKeys.push(String(rows[0].unique_id))
|
|
344
|
-
}
|
|
551
|
+
const groupKeys = await groupKeysFor(config.memberRegistrationGroups)
|
|
345
552
|
const created = await members.create(
|
|
346
553
|
{
|
|
347
554
|
key: crypto.randomUUID(),
|
|
@@ -375,19 +582,29 @@ export function createMemberAuth(
|
|
|
375
582
|
resolve,
|
|
376
583
|
decide,
|
|
377
584
|
signOutCookie: () => expireCookie(cookie, config.secureCookies),
|
|
585
|
+
externalSignInUrl: (providerId, returnUrl) =>
|
|
586
|
+
config.memberIdentityProviders.some((p) => p.oidc.id === providerId)
|
|
587
|
+
? external?.startUrl(providerId, returnUrl)
|
|
588
|
+
: undefined,
|
|
378
589
|
async handle(request, pathname) {
|
|
379
590
|
if (!pathname.startsWith(`${MEMBER_ROUTE_PREFIX}/`)) return undefined
|
|
591
|
+
if (pathname.startsWith(`${MEMBER_EXTERNAL_PATH}/`))
|
|
592
|
+
return external?.handle(request, pathname) ?? undefined
|
|
380
593
|
const action = pathname.slice(MEMBER_ROUTE_PREFIX.length + 1)
|
|
381
594
|
if (action === 'current') {
|
|
382
595
|
if (request.method !== 'GET') return new Response(null, { status: 405 })
|
|
383
596
|
const member = await resolve(request)
|
|
384
597
|
return member ? Response.json({ member }) : Response.json({ member: null }, { status: 401 })
|
|
385
598
|
}
|
|
599
|
+
if (action === '2fa' || action.startsWith('2fa/'))
|
|
600
|
+
return manageTwoFactor(request, action.slice(4))
|
|
386
601
|
if (request.method !== 'POST') return new Response(null, { status: 405 })
|
|
387
602
|
if (!fromThisSite(request)) return new Response('Forbidden', { status: 403 })
|
|
388
603
|
switch (action) {
|
|
389
604
|
case 'login':
|
|
390
605
|
return login(request)
|
|
606
|
+
case 'login/2fa':
|
|
607
|
+
return verifyTwoFactor(request)
|
|
391
608
|
case 'logout':
|
|
392
609
|
return logout(request)
|
|
393
610
|
case 'register':
|