feat(api): 开发auth、login等接口,规范格式化
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
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>;
|
||||
const scryptOptions = { N: 16_384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 };
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
// The random salt and expensive KDF keep a database leak from exposing passwords directly.
|
||||
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 {
|
||||
return createHash('sha256').update(token).digest('base64url');
|
||||
}
|
||||
|
||||
export type AccessTokenClaims = {
|
||||
sub: string;
|
||||
sid: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
};
|
||||
export function signAccessToken(
|
||||
claims: AccessTokenClaims,
|
||||
secret: string,
|
||||
): string {
|
||||
// Access tokens are short-lived; refresh sessions remain revocable in 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user