114 lines
4.3 KiB
TypeScript
114 lines
4.3 KiB
TypeScript
import {
|
|
createHash,
|
|
createHmac,
|
|
randomBytes,
|
|
scrypt as scryptCallback,
|
|
timingSafeEqual,
|
|
} from 'node:crypto';
|
|
import { promisify } from 'node:util';
|
|
|
|
const scrypt = promisify(scryptCallback) as (
|
|
password: string,
|
|
salt: string,
|
|
keyLength: number,
|
|
options: { N: number; r: number; p: number; maxmem: number },
|
|
) => Promise<Buffer>;
|
|
// 使用较高成本的 scrypt 参数,控制离线破解密码哈希时的时间与内存成本。
|
|
const scryptOptions = { N: 16_384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 };
|
|
|
|
export async function hashPassword(password: string): Promise<string> {
|
|
// 每个密码使用独立随机盐,数据库泄露时不能直接复用预计算结果。
|
|
const salt = randomBytes(16).toString('base64url');
|
|
const derived = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
|
|
return `scrypt$${salt}$${derived.toString('base64url')}`;
|
|
}
|
|
|
|
export async function verifyPassword(
|
|
password: string,
|
|
encoded: string,
|
|
): Promise<boolean> {
|
|
const [algorithm, salt, expected] = encoded.split('$');
|
|
if (algorithm !== 'scrypt' || !salt || !expected) return false;
|
|
const actual = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
|
|
const expectedBuffer = Buffer.from(expected, 'base64url');
|
|
return (
|
|
expectedBuffer.length === actual.length &&
|
|
timingSafeEqual(expectedBuffer, actual)
|
|
);
|
|
}
|
|
|
|
export function createOpaqueToken(): string {
|
|
return randomBytes(48).toString('base64url');
|
|
}
|
|
export function tokenHash(token: string): string {
|
|
// refresh token 只以摘要形式存库;服务端无需保存可直接用于登录的原文。
|
|
return createHash('sha256').update(token).digest('base64url');
|
|
}
|
|
|
|
export type AccessTokenClaims = {
|
|
sub: string;
|
|
sid: string;
|
|
exp: number;
|
|
iat: number;
|
|
};
|
|
|
|
export type ExtensionAccessTokenClaims = AccessTokenClaims & { typ: 'extension'; wid: string };
|
|
export function signAccessToken(
|
|
claims: AccessTokenClaims,
|
|
secret: string,
|
|
): string {
|
|
// access token 是短期自包含凭证;真正可撤销的会话状态仍以 PostgreSQL 为准。
|
|
const header = Buffer.from(
|
|
JSON.stringify({ alg: 'HS256', typ: 'JWT' }),
|
|
).toString('base64url');
|
|
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
const signature = createHmac('sha256', secret)
|
|
.update(`${header}.${payload}`)
|
|
.digest('base64url');
|
|
return `${header}.${payload}.${signature}`;
|
|
}
|
|
export function verifyAccessToken(
|
|
token: string,
|
|
secret: string,
|
|
): AccessTokenClaims | null {
|
|
const [header, payload, signature] = token.split('.');
|
|
if (!header || !payload || !signature) return null;
|
|
const expected = createHmac('sha256', secret)
|
|
.update(`${header}.${payload}`)
|
|
.digest();
|
|
const actual = Buffer.from(signature, 'base64url');
|
|
// 恒定时间比较可避免根据签名比对耗时推测正确前缀。
|
|
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
|
return null;
|
|
try {
|
|
const claims = JSON.parse(
|
|
Buffer.from(payload, 'base64url').toString('utf8'),
|
|
) as AccessTokenClaims;
|
|
return typeof claims.sub === 'string' &&
|
|
typeof claims.sid === 'string' &&
|
|
typeof claims.exp === 'number' &&
|
|
claims.exp > Math.floor(Date.now() / 1000)
|
|
? claims
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Extension tokens have an explicit audience so they can never be accepted by
|
|
* the normal Web/API guard, even though both are signed by the same key. */
|
|
export function signExtensionAccessToken(
|
|
claims: ExtensionAccessTokenClaims,
|
|
secret: string,
|
|
): string {
|
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
|
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
const signature = createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url');
|
|
return `${header}.${payload}.${signature}`;
|
|
}
|
|
|
|
export function verifyExtensionAccessToken(token: string, secret: string): ExtensionAccessTokenClaims | null {
|
|
const claims = verifyAccessToken(token, secret) as Partial<ExtensionAccessTokenClaims> | null;
|
|
return claims?.typ === 'extension' ? claims as ExtensionAccessTokenClaims : null;
|
|
}
|