@crombolo/auth 0.11.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/service.ts +13 -2
- 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/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
|
|
|
@@ -131,7 +131,11 @@ export class AuthService {
|
|
|
131
131
|
await verifyPassword(password, await DUMMY_HASH_PROMISE)
|
|
132
132
|
return { status: 'notAllowed', reason: 'lockedOut', user } as const
|
|
133
133
|
}
|
|
134
|
-
if (
|
|
134
|
+
if (
|
|
135
|
+
!(await verifyPassword(password, user.passwordHash, {
|
|
136
|
+
passwordConfig: user.passwordConfig,
|
|
137
|
+
}))
|
|
138
|
+
) {
|
|
135
139
|
const attempts = await this.#store.recordLoginFailure(user.id)
|
|
136
140
|
const lockedOut =
|
|
137
141
|
this.#settings.maxFailedLoginAttempts > 0 &&
|
|
@@ -143,6 +147,13 @@ export class AuthService {
|
|
|
143
147
|
// attacker can induce, so answering it only to whoever knows the password
|
|
144
148
|
// discloses strictly less than checking it up front.
|
|
145
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
|
+
)
|
|
146
157
|
|
|
147
158
|
const sessionId = crypto.randomUUID()
|
|
148
159
|
await this.#store.recordLoginSuccess(user.id, sessionId, ipAddress)
|
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
|
+
}
|