This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -0,0 +1,15 @@
import type { Request } from 'express';
export type AuthRequestContext = {
clientType: string;
userAgent: string | null;
ip: string | null;
};
export function buildAuthRequestContext(request: Request): AuthRequestContext {
return {
clientType: 'browser',
userAgent: request.header('user-agent')?.trim() || null,
ip: request.ip || null,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
import { badRequest } from '../errors.js';
export type NormalizedPhoneNumber = {
countryCode: string;
nationalNumber: string;
e164: string;
maskedNationalNumber: string;
};
function stripPhoneInput(input: string) {
return input.replace(/[^\d+]/gu, '').trim();
}
export function maskNationalPhoneNumber(phoneNumber: string) {
if (phoneNumber.length < 7) {
return phoneNumber;
}
return `${phoneNumber.slice(0, 3)}****${phoneNumber.slice(-4)}`;
}
export function normalizeMainlandChinaPhoneNumber(
phoneInput: string,
): NormalizedPhoneNumber {
const trimmed = stripPhoneInput(phoneInput);
if (!trimmed) {
throw badRequest('请输入手机号');
}
let nationalNumber = trimmed;
if (nationalNumber.startsWith('+86')) {
nationalNumber = nationalNumber.slice(3);
} else if (nationalNumber.startsWith('86') && nationalNumber.length === 13) {
nationalNumber = nationalNumber.slice(2);
}
if (!/^1\d{10}$/u.test(nationalNumber)) {
throw badRequest('请输入正确的中国大陆手机号');
}
return {
countryCode: '86',
nationalNumber,
e164: `+86${nationalNumber}`,
maskedNationalNumber: maskNationalPhoneNumber(nationalNumber),
};
}
export function validateSmsVerifyCode(verifyCode: string) {
const normalizedVerifyCode = verifyCode.trim();
if (!/^[A-Za-z0-9]{4,8}$/u.test(normalizedVerifyCode)) {
throw badRequest('请输入正确的验证码');
}
return normalizedVerifyCode;
}

View File

@@ -0,0 +1,96 @@
import crypto from 'node:crypto';
import type { Request, Response } from 'express';
import type { AppConfig } from '../config.js';
export type RefreshSessionRequestContext = {
clientType: string;
userAgent: string | null;
ip: string | null;
};
function buildCookieParts(
config: AppConfig,
value: string,
options: {
maxAgeSeconds: number;
},
) {
const parts = [
`${config.authSession.refreshCookieName}=${encodeURIComponent(value)}`,
`Path=${config.authSession.refreshCookiePath}`,
'HttpOnly',
`SameSite=${config.authSession.refreshCookieSameSite}`,
`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`,
];
if (config.authSession.refreshCookieSecure) {
parts.push('Secure');
}
return parts.join('; ');
}
export function hashRefreshSessionToken(token: string) {
return crypto.createHash('sha256').update(token).digest('hex');
}
export function createRefreshSessionToken() {
return crypto.randomBytes(32).toString('base64url');
}
export function setRefreshSessionCookie(
response: Response,
config: AppConfig,
token: string,
maxAgeSeconds: number,
) {
response.setHeader(
'Set-Cookie',
buildCookieParts(config, token, {
maxAgeSeconds,
}),
);
}
export function clearRefreshSessionCookie(response: Response, config: AppConfig) {
response.setHeader(
'Set-Cookie',
buildCookieParts(config, '', {
maxAgeSeconds: 0,
}),
);
}
export function readRefreshSessionToken(request: Request, config: AppConfig) {
const cookieHeader = request.header('cookie')?.trim() || '';
if (!cookieHeader) {
return '';
}
const cookieEntries = cookieHeader.split(';');
for (const entry of cookieEntries) {
const [rawName, ...valueParts] = entry.split('=');
const name = rawName?.trim();
if (name !== config.authSession.refreshCookieName) {
continue;
}
const rawValue = valueParts.join('=').trim();
return rawValue ? decodeURIComponent(rawValue) : '';
}
return '';
}
export function buildRefreshSessionRequestContext(
request: Request,
): RefreshSessionRequestContext {
const userAgent = request.header('user-agent')?.trim() || null;
return {
clientType: 'browser',
userAgent,
ip: request.ip || null,
};
}

View File

@@ -1,8 +1,24 @@
import crypto from 'node:crypto';
import { jwtVerify, SignJWT } from 'jose';
import type { AppConfig } from '../config.js';
import { unauthorized } from '../errors.js';
if (!globalThis.crypto?.subtle) {
Object.assign(globalThis, {
crypto: crypto.webcrypto,
});
}
if (typeof globalThis.structuredClone !== 'function') {
Object.assign(globalThis, {
structuredClone<T>(value: T) {
return JSON.parse(JSON.stringify(value)) as T;
},
});
}
export type AccessTokenClaims = {
userId: string;
tokenVersion: number;
@@ -21,6 +37,7 @@ export async function signAccessToken(
.setSubject(claims.userId)
.setIssuer(config.jwtIssuer)
.setIssuedAt()
.setExpirationTime(config.jwtExpiresIn)
.sign(getSecret(config));
}