@crombolo/auth 0.10.0 → 0.12.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 +3 -3
- package/src/index.ts +1 -0
- package/src/password.ts +19 -8
- package/src/routes.ts +49 -3
- package/src/service.ts +34 -15
- package/src/store.ts +19 -1
- package/src/umbraco-passwords.ts +128 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "OAuth2 authorization server, password hashing, cookies and member tickets for crombolo.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
32
|
-
"@crombolo/data": "0.
|
|
31
|
+
"@crombolo/core": "0.12.1",
|
|
32
|
+
"@crombolo/data": "0.12.1"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/index.ts
CHANGED
package/src/password.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Password hashing
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `password_config` marker column so the algorithm is recorded per row and can be
|
|
7
|
-
* rotated later exactly as Umbraco rotates its legacy formats.
|
|
2
|
+
* Password hashing: argon2id, with the algorithm recorded per row in
|
|
3
|
+
* `password_config`. A hash imported from Umbraco (story 071) is verified in
|
|
4
|
+
* its own format (`umbraco-passwords.ts`) and replaced with argon2id on the
|
|
5
|
+
* first successful sign-in, as Umbraco rotates its legacy formats.
|
|
8
6
|
*/
|
|
7
|
+
import { verifyUmbracoPassword } from './umbraco-passwords.ts'
|
|
8
|
+
|
|
9
9
|
export const PASSWORD_ALGORITHM = 'ARGON2ID'
|
|
10
10
|
|
|
11
11
|
export interface PasswordConfig {
|
|
@@ -19,12 +19,23 @@ export async function hashPassword(password: string): Promise<string> {
|
|
|
19
19
|
return Bun.password.hash(password, { algorithm: 'argon2id' })
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Whether a stored hash is crombolo's own; anything else is replaced on a successful sign-in. Pure. */
|
|
23
|
+
export const isNativeHash = (hash: string | null | undefined): boolean =>
|
|
24
|
+
typeof hash === 'string' && hash.startsWith('$argon2')
|
|
25
|
+
|
|
22
26
|
/**
|
|
23
27
|
* Verifies a password. Returns false rather than throwing for a malformed or
|
|
24
|
-
* absent hash, so a user row with no password simply cannot sign in.
|
|
28
|
+
* absent hash, so a user row with no password simply cannot sign in. A hash
|
|
29
|
+
* that is not argon2id is tried as Umbraco's, by its `passwordConfig`.
|
|
25
30
|
*/
|
|
26
|
-
export async function verifyPassword(
|
|
31
|
+
export async function verifyPassword(
|
|
32
|
+
password: string,
|
|
33
|
+
hash: string | null,
|
|
34
|
+
imported: { passwordConfig?: string | null; forMember?: boolean } = {},
|
|
35
|
+
): Promise<boolean> {
|
|
27
36
|
if (!hash) return false
|
|
37
|
+
if (!isNativeHash(hash))
|
|
38
|
+
return verifyUmbracoPassword(password, hash, imported.passwordConfig, imported.forMember)
|
|
28
39
|
try {
|
|
29
40
|
return await Bun.password.verify(password, hash)
|
|
30
41
|
} catch {
|
package/src/routes.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from './cookies.ts'
|
|
18
18
|
import type { AuthService } from './service.ts'
|
|
19
19
|
import { BACKOFFICE_CLIENT_ID, DEFAULT_SCOPE } from './service.ts'
|
|
20
|
+
import type { UserRow } from './store.ts'
|
|
20
21
|
|
|
21
22
|
export const SECURITY_PREFIX = `${MANAGEMENT_API_PATH}/security/back-office`
|
|
22
23
|
|
|
@@ -24,6 +25,17 @@ export interface AuthRouteOptions {
|
|
|
24
25
|
backOfficePath: string
|
|
25
26
|
service: AuthService
|
|
26
27
|
cookies: CookieOptions
|
|
28
|
+
/** Told about every sign-in, failed sign-in and sign-out, for the audit log. */
|
|
29
|
+
onAudit?: (event: AuthAuditEvent, request: Request) => Promise<void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AuthAuditEvent {
|
|
33
|
+
action: 'SignIn' | 'SignInFailed' | 'LockedOut' | 'SignOut'
|
|
34
|
+
/** Absent when the login named nobody. */
|
|
35
|
+
user?: Pick<UserRow, 'key' | 'userName'>
|
|
36
|
+
/** The login that was tried, for a failure that names nobody. */
|
|
37
|
+
login?: string
|
|
38
|
+
detail?: string
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
export interface AuthRoutes {
|
|
@@ -47,6 +59,8 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
47
59
|
const names = cookieNames(options.cookies)
|
|
48
60
|
const secure = options.cookies.securePrefix
|
|
49
61
|
const { service } = options
|
|
62
|
+
const audit = (event: AuthAuditEvent, request: Request) =>
|
|
63
|
+
options.onAudit?.(event, request) ?? Promise.resolve()
|
|
50
64
|
|
|
51
65
|
const expectedRedirectUri = (origin: string) =>
|
|
52
66
|
`${origin}${options.backOfficePath}/oauth_complete`
|
|
@@ -144,7 +158,14 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
144
158
|
// API users: a machine client presents its own id and secret and gets a bearer token back
|
|
145
159
|
if (grantType === 'client_credentials') {
|
|
146
160
|
const issued = await service.clientCredentials(clientId, form.get('client_secret') ?? '')
|
|
147
|
-
if (!issued.ok)
|
|
161
|
+
if (!issued.ok) {
|
|
162
|
+
await audit(
|
|
163
|
+
{ action: 'SignInFailed', login: clientId, detail: 'Client credentials refused' },
|
|
164
|
+
request,
|
|
165
|
+
)
|
|
166
|
+
return oauthError(issued.error, issued.description, 401)
|
|
167
|
+
}
|
|
168
|
+
await audit({ action: 'SignIn', user: issued.user, detail: 'Client credentials' }, request)
|
|
148
169
|
return Response.json({
|
|
149
170
|
access_token: issued.accessToken,
|
|
150
171
|
token_type: 'Bearer',
|
|
@@ -262,6 +283,29 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
262
283
|
request.headers.get('x-forwarded-for') ?? undefined,
|
|
263
284
|
)
|
|
264
285
|
|
|
286
|
+
if (result.status === 'success') await audit({ action: 'SignIn', user: result.user }, request)
|
|
287
|
+
else if (result.status === 'invalid') {
|
|
288
|
+
await audit(
|
|
289
|
+
result.user
|
|
290
|
+
? { action: 'SignInFailed', user: result.user, detail: 'Wrong password' }
|
|
291
|
+
: { action: 'SignInFailed', login: username, detail: 'No such user' },
|
|
292
|
+
request,
|
|
293
|
+
)
|
|
294
|
+
if (result.lockedOut && result.user)
|
|
295
|
+
await audit(
|
|
296
|
+
{ action: 'LockedOut', user: result.user, detail: 'Too many failed attempts' },
|
|
297
|
+
request,
|
|
298
|
+
)
|
|
299
|
+
} else if (result.status === 'notAllowed')
|
|
300
|
+
await audit(
|
|
301
|
+
{
|
|
302
|
+
action: 'SignInFailed',
|
|
303
|
+
...(result.user ? { user: result.user } : { login: username }),
|
|
304
|
+
detail: result.reason === 'notApproved' ? 'Account disabled' : 'Account locked',
|
|
305
|
+
},
|
|
306
|
+
request,
|
|
307
|
+
)
|
|
308
|
+
|
|
265
309
|
// Status codes the login UI branches on: 401 invalid, 402 needs 2FA,
|
|
266
310
|
// 403 locked or not approved.
|
|
267
311
|
if (result.status === 'invalid') {
|
|
@@ -296,7 +340,8 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
296
340
|
async function handleRevoke(request: Request): Promise<Response> {
|
|
297
341
|
const form = new URLSearchParams(await request.text())
|
|
298
342
|
const token = resolveRedacted(form.get('token'), readCookie(request, names.accessToken))
|
|
299
|
-
|
|
343
|
+
const revoked = token ? await service.revokeAccessToken(token) : undefined
|
|
344
|
+
if (revoked) await audit({ action: 'SignOut', user: revoked }, request)
|
|
300
345
|
const response = new Response(null, { status: 200 })
|
|
301
346
|
for (const cookie of clearedCookies()) response.headers.append('set-cookie', cookie)
|
|
302
347
|
return response
|
|
@@ -304,7 +349,8 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
304
349
|
|
|
305
350
|
async function handleSignOut(request: Request, url: URL): Promise<Response> {
|
|
306
351
|
const sessionId = readCookie(request, names.session)
|
|
307
|
-
|
|
352
|
+
const ended = sessionId ? await service.signOut(sessionId) : undefined
|
|
353
|
+
if (ended) await audit({ action: 'SignOut', user: ended }, request)
|
|
308
354
|
|
|
309
355
|
const requested = url.searchParams.get('post_logout_redirect_uri')
|
|
310
356
|
const allowed = `${url.origin}${options.backOfficePath}/logout`
|
package/src/service.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* actually sends. See docs/backoffice-hosting.md for the wire contract.
|
|
4
4
|
*/
|
|
5
5
|
import { type ExtensionSectionAlias, type SectionAlias, toSectionAliases } from '@crombolo/core'
|
|
6
|
-
import { hashPassword, passwordConfigJson, verifyPassword } from './password.ts'
|
|
6
|
+
import { hashPassword, isNativeHash, passwordConfigJson, verifyPassword } from './password.ts'
|
|
7
7
|
import { type CodeChallengeMethod, generateToken, verifyChallenge } from './pkce.ts'
|
|
8
8
|
import type { AuthStore, UserRow } from './store.ts'
|
|
9
9
|
|
|
@@ -38,11 +38,15 @@ export const DEFAULT_AUTH_SETTINGS: AuthSettings = {
|
|
|
38
38
|
maxFailedLoginAttempts: 10,
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* `user` on a refusal is for the audit log only, and never reaches the response:
|
|
43
|
+
* the status alone is what the sign-in page may learn.
|
|
44
|
+
*/
|
|
41
45
|
export type LoginResult =
|
|
42
46
|
| { status: 'success'; user: UserRow; sessionId: string }
|
|
43
|
-
| { status: 'invalid' }
|
|
47
|
+
| { status: 'invalid'; user?: UserRow; lockedOut?: boolean }
|
|
44
48
|
| { status: 'twoFactorRequired'; providers: string[] }
|
|
45
|
-
| { status: 'notAllowed'; reason: 'disabled' | 'lockedOut' | 'notApproved' }
|
|
49
|
+
| { status: 'notAllowed'; reason: 'disabled' | 'lockedOut' | 'notApproved'; user?: UserRow }
|
|
46
50
|
|
|
47
51
|
export interface AuthorizationRequest {
|
|
48
52
|
clientId: string
|
|
@@ -125,22 +129,31 @@ export class AuthService {
|
|
|
125
129
|
// this path costing what the others cost.
|
|
126
130
|
if (user.isLockedOut) {
|
|
127
131
|
await verifyPassword(password, await DUMMY_HASH_PROMISE)
|
|
128
|
-
return { status: 'notAllowed', reason: 'lockedOut' } as const
|
|
132
|
+
return { status: 'notAllowed', reason: 'lockedOut', user } as const
|
|
129
133
|
}
|
|
130
|
-
if (
|
|
134
|
+
if (
|
|
135
|
+
!(await verifyPassword(password, user.passwordHash, {
|
|
136
|
+
passwordConfig: user.passwordConfig,
|
|
137
|
+
}))
|
|
138
|
+
) {
|
|
131
139
|
const attempts = await this.#store.recordLoginFailure(user.id)
|
|
132
|
-
|
|
140
|
+
const lockedOut =
|
|
133
141
|
this.#settings.maxFailedLoginAttempts > 0 &&
|
|
134
142
|
attempts >= this.#settings.maxFailedLoginAttempts
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
return { status: 'invalid' } as const
|
|
143
|
+
if (lockedOut) await this.#store.lockOut(user.id)
|
|
144
|
+
return { status: 'invalid', user, lockedOut } as const
|
|
139
145
|
}
|
|
140
146
|
// `isApproved` stays behind the password on purpose: it is not a state an
|
|
141
147
|
// attacker can induce, so answering it only to whoever knows the password
|
|
142
148
|
// discloses strictly less than checking it up front.
|
|
143
|
-
if (!user.isApproved) return { status: 'notAllowed', reason: 'notApproved' } as const
|
|
149
|
+
if (!user.isApproved) return { status: 'notAllowed', reason: 'notApproved', user } as const
|
|
150
|
+
// An imported Umbraco hash is replaced now the password is known to match it.
|
|
151
|
+
if (!isNativeHash(user.passwordHash))
|
|
152
|
+
await this.#store.rehashPassword(
|
|
153
|
+
user.id,
|
|
154
|
+
await hashPassword(password),
|
|
155
|
+
passwordConfigJson(),
|
|
156
|
+
)
|
|
144
157
|
|
|
145
158
|
const sessionId = crypto.randomUUID()
|
|
146
159
|
await this.#store.recordLoginSuccess(user.id, sessionId, ipAddress)
|
|
@@ -247,7 +260,7 @@ export class AuthService {
|
|
|
247
260
|
clientId: string,
|
|
248
261
|
secret: string,
|
|
249
262
|
): Promise<
|
|
250
|
-
| { ok: true; accessToken: string; expiresIn: number; issuedAt: number }
|
|
263
|
+
| { ok: true; accessToken: string; expiresIn: number; issuedAt: number; user: UserRow }
|
|
251
264
|
| { ok: false; error: TokenError; description: string }
|
|
252
265
|
> {
|
|
253
266
|
const credential = await this.#store.findClientCredential(clientId)
|
|
@@ -273,6 +286,7 @@ export class AuthService {
|
|
|
273
286
|
accessToken,
|
|
274
287
|
expiresIn: this.#settings.accessTokenSeconds,
|
|
275
288
|
issuedAt: Math.floor(now / 1000),
|
|
289
|
+
user,
|
|
276
290
|
}
|
|
277
291
|
}
|
|
278
292
|
|
|
@@ -301,13 +315,18 @@ export class AuthService {
|
|
|
301
315
|
return this.#store.findSession(sessionId)
|
|
302
316
|
}
|
|
303
317
|
|
|
304
|
-
|
|
318
|
+
/** Ends the session, and returns whose it was while it was still open. */
|
|
319
|
+
async signOut(sessionId: string): Promise<UserRow | undefined> {
|
|
320
|
+
const session = await this.#store.findSession(sessionId)
|
|
321
|
+
const user = session ? await this.#store.findUserById(session.userId) : undefined
|
|
305
322
|
await this.#store.endSession(sessionId)
|
|
323
|
+
return user
|
|
306
324
|
}
|
|
307
325
|
|
|
308
|
-
async revokeAccessToken(accessToken: string): Promise<
|
|
326
|
+
async revokeAccessToken(accessToken: string): Promise<UserRow | undefined> {
|
|
309
327
|
const token = await this.#store.findToken(accessToken, 'access')
|
|
310
|
-
if (token)
|
|
328
|
+
if (!token) return undefined
|
|
329
|
+
return this.signOut(token.sessionId)
|
|
311
330
|
}
|
|
312
331
|
|
|
313
332
|
async #issue(
|
package/src/store.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface UserRow {
|
|
|
14
14
|
login: string
|
|
15
15
|
email: string
|
|
16
16
|
passwordHash: string | null
|
|
17
|
+
/** The algorithm marker beside the hash; an imported one names Umbraco's. */
|
|
18
|
+
passwordConfig: string | null
|
|
17
19
|
language: string | null
|
|
18
20
|
avatar: string | null
|
|
19
21
|
isLockedOut: boolean
|
|
@@ -71,6 +73,7 @@ function mapUser(row: Record<string, unknown>): UserRow {
|
|
|
71
73
|
login: String(row.login),
|
|
72
74
|
email: String(row.email),
|
|
73
75
|
passwordHash: (row.password_hash as string | null) ?? null,
|
|
76
|
+
passwordConfig: (row.password_config as string | null) ?? null,
|
|
74
77
|
language: (row.language as string | null) ?? null,
|
|
75
78
|
avatar: (row.avatar as string | null) ?? null,
|
|
76
79
|
isLockedOut: fromDbBool(row.is_locked_out),
|
|
@@ -81,7 +84,7 @@ function mapUser(row: Record<string, unknown>): UserRow {
|
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
const USER_COLUMNS =
|
|
84
|
-
'id, key, user_name, login, email, password_hash, language, avatar, is_locked_out, is_approved, failed_login_attempts, kind'
|
|
87
|
+
'id, key, user_name, login, email, password_hash, password_config, language, avatar, is_locked_out, is_approved, failed_login_attempts, kind'
|
|
85
88
|
|
|
86
89
|
export class AuthStore {
|
|
87
90
|
#db: Db
|
|
@@ -154,6 +157,21 @@ export class AuthStore {
|
|
|
154
157
|
* Replaces a user's password. The caller supplies an already-hashed value, so
|
|
155
158
|
* this module never needs to know which algorithm is in use.
|
|
156
159
|
*/
|
|
160
|
+
/**
|
|
161
|
+
* Replaces an imported hash with argon2id after a successful sign-in. The
|
|
162
|
+
* password is the same, so the security stamp and change date stand.
|
|
163
|
+
*/
|
|
164
|
+
async rehashPassword(
|
|
165
|
+
userId: number,
|
|
166
|
+
passwordHash: string,
|
|
167
|
+
passwordConfig: string,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
await this.#db.exec(
|
|
170
|
+
'UPDATE user_account SET password_hash = ?, password_config = ? WHERE id = ?',
|
|
171
|
+
[passwordHash, passwordConfig, userId],
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
157
175
|
async setPassword(userId: number, passwordHash: string, passwordConfig: string): Promise<void> {
|
|
158
176
|
const now = DbDate.toDb(new Date())
|
|
159
177
|
await this.#db.exec(
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifying the password hashes an Umbraco site stores, so an imported user or
|
|
3
|
+
* member signs in with the password they had; a success is then re-hashed to
|
|
4
|
+
* argon2id. In the order Umbraco's `UmbracoPasswordHasher` and
|
|
5
|
+
* `MemberPasswordHasher` try them:
|
|
6
|
+
*
|
|
7
|
+
* - ASP.NET Core Identity v3 (`0x01`, PBKDF2 with the PRF and iterations it
|
|
8
|
+
* records) and v2 (`0x00`, PBKDF2-HMAC-SHA1, 1,000 iterations);
|
|
9
|
+
* - Umbraco 4's HMAC-SHA1 keyed with the password itself;
|
|
10
|
+
* - Umbraco 6–8's HMAC-SHA256 (or plain SHA1) over a base64 salt that prefixes
|
|
11
|
+
* the hash, named by `passwordConfig` — or assumed for a member with none,
|
|
12
|
+
* as a site upgraded from 8 has.
|
|
13
|
+
*
|
|
14
|
+
* Every comparison is constant-time. Pure.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, createHmac, pbkdf2Sync, timingSafeEqual } from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
export const UMBRACO_PASSWORD_ALGORITHMS = [
|
|
19
|
+
'PBKDF2.ASPNETCORE.V3',
|
|
20
|
+
'PBKDF2.ASPNETCORE.V2',
|
|
21
|
+
'HMACSHA256',
|
|
22
|
+
'HMACSHA1',
|
|
23
|
+
] as const
|
|
24
|
+
|
|
25
|
+
const same = (a: Buffer, b: Buffer) => a.length === b.length && timingSafeEqual(a, b)
|
|
26
|
+
|
|
27
|
+
const utf16 = (value: string) => Buffer.from(value, 'utf16le')
|
|
28
|
+
|
|
29
|
+
function base64(value: string): Buffer | undefined {
|
|
30
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) return undefined
|
|
31
|
+
return Buffer.from(value, 'base64')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PRFS = ['sha1', 'sha256', 'sha512'] as const
|
|
35
|
+
|
|
36
|
+
/** ASP.NET Core Identity v3 or v2, by its first byte; the password as UTF-8. */
|
|
37
|
+
export function verifyAspNetIdentity(password: string, hash: string): boolean {
|
|
38
|
+
const bytes = base64(hash)
|
|
39
|
+
if (!bytes || bytes.length === 0) return false
|
|
40
|
+
if (bytes[0] === 0x00) {
|
|
41
|
+
if (bytes.length !== 49) return false
|
|
42
|
+
const salt = bytes.subarray(1, 17)
|
|
43
|
+
const expected = bytes.subarray(17)
|
|
44
|
+
return same(pbkdf2Sync(password, salt, 1000, 32, 'sha1'), expected)
|
|
45
|
+
}
|
|
46
|
+
if (bytes[0] === 0x01) {
|
|
47
|
+
if (bytes.length < 13) return false
|
|
48
|
+
const prf = PRFS[bytes.readUInt32BE(1)]
|
|
49
|
+
const iterations = bytes.readUInt32BE(5)
|
|
50
|
+
const saltLength = bytes.readUInt32BE(9)
|
|
51
|
+
if (!prf || iterations < 1 || saltLength < 16 || bytes.length < 13 + saltLength + 16)
|
|
52
|
+
return false
|
|
53
|
+
const salt = bytes.subarray(13, 13 + saltLength)
|
|
54
|
+
const expected = bytes.subarray(13 + saltLength)
|
|
55
|
+
return same(pbkdf2Sync(password, salt, iterations, expected.length, prf), expected)
|
|
56
|
+
}
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Umbraco 4's "legacy encoding": HMAC-SHA1 whose key is the password, over the password. */
|
|
61
|
+
export function verifyUmbraco4(password: string, hash: string): boolean {
|
|
62
|
+
const expected = base64(hash)
|
|
63
|
+
if (!expected) return false
|
|
64
|
+
return same(createHmac('sha1', utf16(password)).update(utf16(password)).digest(), expected)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Umbraco 6–8: a 24-character base64 salt, then the base64 hash of the
|
|
69
|
+
* password (UTF-16) under HMAC-SHA256 keyed with the salt repeated to 64
|
|
70
|
+
* bytes — .NET's key length for it — or SHA1 over salt and password.
|
|
71
|
+
*/
|
|
72
|
+
export function verifyUmbraco8(password: string, hash: string, algorithm = 'HMACSHA256'): boolean {
|
|
73
|
+
if (hash.length <= 24) return false
|
|
74
|
+
const salt = base64(hash.slice(0, 24))
|
|
75
|
+
const expected = base64(hash.slice(24))
|
|
76
|
+
if (!salt || !expected || salt.length === 0) return false
|
|
77
|
+
const name = algorithm.toUpperCase()
|
|
78
|
+
if (name === 'HMACSHA256') {
|
|
79
|
+
const key = Buffer.alloc(64)
|
|
80
|
+
for (let at = 0; at < key.length; at += salt.length)
|
|
81
|
+
salt.copy(key, at, 0, Math.min(salt.length, key.length - at))
|
|
82
|
+
return same(createHmac('sha256', key).update(utf16(password)).digest(), expected)
|
|
83
|
+
}
|
|
84
|
+
if (name === 'SHA1')
|
|
85
|
+
return same(
|
|
86
|
+
createHash('sha1')
|
|
87
|
+
.update(Buffer.concat([salt, utf16(password)]))
|
|
88
|
+
.digest(),
|
|
89
|
+
expected,
|
|
90
|
+
)
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The `hashAlgorithm` an Umbraco `passwordConfig` names, when it names one. Pure. */
|
|
95
|
+
export function umbracoAlgorithmOf(passwordConfig: string | null | undefined): string | undefined {
|
|
96
|
+
if (!passwordConfig) return undefined
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(passwordConfig) as { hashAlgorithm?: unknown }
|
|
99
|
+
return typeof parsed.hashAlgorithm === 'string' ? parsed.hashAlgorithm : undefined
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether a password matches a hash an Umbraco site stored. `forMember`: a
|
|
107
|
+
* member with no `passwordConfig` is assumed to be on Umbraco 8's hashing,
|
|
108
|
+
* which is how Umbraco treats one upgraded from 8.
|
|
109
|
+
*/
|
|
110
|
+
export function verifyUmbracoPassword(
|
|
111
|
+
password: string,
|
|
112
|
+
hash: string | null | undefined,
|
|
113
|
+
passwordConfig: string | null | undefined,
|
|
114
|
+
forMember = false,
|
|
115
|
+
): boolean {
|
|
116
|
+
if (!hash || !password) return false
|
|
117
|
+
if (verifyAspNetIdentity(password, hash)) return true
|
|
118
|
+
if (verifyUmbraco4(password, hash)) return true
|
|
119
|
+
const algorithm = umbracoAlgorithmOf(passwordConfig) ?? (forMember ? 'HMACSHA256' : undefined)
|
|
120
|
+
if (!algorithm) return false
|
|
121
|
+
return verifyUmbraco8(password, hash, algorithm)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Whether a `passwordConfig` is one of Umbraco's, rather than crombolo's argon2id. Pure. */
|
|
125
|
+
export const isUmbracoPasswordConfig = (passwordConfig: string | null | undefined): boolean => {
|
|
126
|
+
const algorithm = umbracoAlgorithmOf(passwordConfig)
|
|
127
|
+
return algorithm !== undefined && algorithm.toUpperCase() !== 'ARGON2ID'
|
|
128
|
+
}
|