初始提交:DSH 多租户平台(dshs)
This commit is contained in:
commit
43976fea6a
167 files changed
+24456
No files matched your search
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Password hashing, session-token helpers, and cookie assembly.
|
||||
*
|
||||
* Uses `node:crypto` scrypt so the scaffold has zero native dependency beyond
|
||||
* SQLite; production may swap in argon2id without changing call sites.
|
||||
* @module dshs/web/auth
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const scryptAsync = promisify(scrypt) as (password: string, salt: Buffer, keylen: number) => Promise<Buffer>
|
||||
|
||||
const KEY_LENGTH = 64
|
||||
|
||||
/** Hash a password into a `scrypt$<salt>$<hash>` string. */
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16)
|
||||
const derived = await scryptAsync(password, salt, KEY_LENGTH)
|
||||
return `scrypt$${salt.toString('hex')}$${derived.toString('hex')}`
|
||||
}
|
||||
|
||||
/** Constant-time password verification against a stored `scrypt$...` string. */
|
||||
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
const [scheme, saltHex, hashHex] = stored.split('$')
|
||||
if (scheme !== 'scrypt' || saltHex === undefined || hashHex === undefined) return false
|
||||
const expected = Buffer.from(hashHex, 'hex')
|
||||
const derived = await scryptAsync(password, Buffer.from(saltHex, 'hex'), KEY_LENGTH)
|
||||
return derived.length === expected.length && timingSafeEqual(derived, expected)
|
||||
}
|
||||
|
||||
/** Generate a fresh opaque session token. */
|
||||
export function newSessionToken(): string {
|
||||
return randomBytes(32).toString('base64url')
|
||||
}
|
||||
|
||||
/** Hash a session token for storage; the DB never holds the raw token. */
|
||||
export function hashSessionToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
/** Extract a named cookie value from a `Cookie` header. */
|
||||
export function parseCookie(header: string | undefined, name: string): string | undefined {
|
||||
if (!header) return undefined
|
||||
for (const part of header.split(';')) {
|
||||
const idx = part.indexOf('=')
|
||||
if (idx === -1) continue
|
||||
if (part.slice(0, idx).trim() === name) return part.slice(idx + 1).trim()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Build a `Set-Cookie` value for the session token.
|
||||
*
|
||||
* `SameSite=None` (with `Secure`) when behind HTTPS so the cookie survives the
|
||||
* cross-subdomain hop `dsh.<domain>` → `<user>.dsh.<domain>`; `SameSite=Lax`
|
||||
* otherwise. The subdomain auth check still validates cookie ↔ slug, so the
|
||||
* looser SameSite does not widen who a session can reach.
|
||||
*/
|
||||
export function sessionCookie(token: string, maxAgeSeconds: number, secure: boolean, domain?: string): string {
|
||||
const flags = ['sid=' + token, 'HttpOnly', secure ? 'SameSite=None' : 'SameSite=Lax', 'Path=/', `Max-Age=${maxAgeSeconds}`]
|
||||
if (secure) flags.push('Secure')
|
||||
if (domain !== undefined && domain !== '') flags.push(`Domain=${domain}`)
|
||||
return flags.join('; ')
|
||||
}
|
||||
|
||||
/** Build a `Set-Cookie` value that expires the session cookie. */
|
||||
export function clearSessionCookie(secure: boolean, domain?: string): string {
|
||||
const flags = ['sid=', 'HttpOnly', secure ? 'SameSite=None' : 'SameSite=Lax', 'Path=/', 'Max-Age=0']
|
||||
if (secure) flags.push('Secure')
|
||||
if (domain !== undefined && domain !== '') flags.push(`Domain=${domain}`)
|
||||
return flags.join('; ')
|
||||
}
|
||||
Reference in new issue
Block a user