1
This commit is contained in:
15
server-node/src/auth/authRequestContext.ts
Normal file
15
server-node/src/auth/authRequestContext.ts
Normal 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
55
server-node/src/auth/phoneNumber.ts
Normal file
55
server-node/src/auth/phoneNumber.ts
Normal 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;
|
||||
}
|
||||
96
server-node/src/auth/refreshSessionCookie.ts
Normal file
96
server-node/src/auth/refreshSessionCookie.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user