feat: migrate runtime backend to node server

This commit is contained in:
victo
2026-04-08 16:41:29 +08:00
parent 9d2fc9e4b8
commit a83841ff2d
70 changed files with 8239 additions and 1561 deletions

View File

@@ -0,0 +1,70 @@
import type { AppContext } from '../context.js';
import { badRequest, unauthorized } from '../errors.js';
import { hashPassword, verifyPassword } from './password.js';
import { signAccessToken } from './token.js';
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,24}$/u;
function normalizeUsername(username: string) {
return username.trim();
}
function validateCredentials(username: string, password: string) {
if (!USERNAME_PATTERN.test(username)) {
throw badRequest('用户名只允许 3 到 24 位字母、数字、下划线');
}
if (password.length < 6 || password.length > 128) {
throw badRequest('密码长度需要在 6 到 128 位之间');
}
}
export async function entryWithPassword(
context: AppContext,
usernameInput: string,
password: string,
) {
const username = normalizeUsername(usernameInput);
validateCredentials(username, password);
let user = context.userRepository.findByUsername(username);
if (!user) {
const passwordHash = await hashPassword(password);
user = context.userRepository.create(username, passwordHash);
} else {
const isValid = await verifyPassword(user.passwordHash, password);
if (!isValid) {
throw unauthorized('用户名或密码错误');
}
}
if (!user) {
throw new Error('failed to resolve user after auth entry');
}
const token = await signAccessToken(
{
userId: user.id,
tokenVersion: user.tokenVersion,
},
context.config,
);
return {
token,
user: {
id: user.id,
username: user.username,
},
};
}
export async function logoutUser(context: AppContext, userId: string) {
const user = context.userRepository.incrementTokenVersion(userId);
if (!user) {
throw unauthorized('用户不存在');
}
return {
ok: true as const,
};
}

View File

@@ -0,0 +1,16 @@
import { Algorithm, hash, verify } from '@node-rs/argon2';
export async function hashPassword(password: string) {
return hash(password, {
algorithm: Algorithm.Argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
});
}
export async function verifyPassword(passwordHash: string, password: string) {
return verify(passwordHash, password, {
algorithm: Algorithm.Argon2id,
});
}

View File

@@ -0,0 +1,46 @@
import { jwtVerify, SignJWT } from 'jose';
import type { AppConfig } from '../config.js';
import { unauthorized } from '../errors.js';
export type AccessTokenClaims = {
userId: string;
tokenVersion: number;
};
function getSecret(config: AppConfig) {
return new TextEncoder().encode(config.jwtSecret);
}
export async function signAccessToken(
claims: AccessTokenClaims,
config: AppConfig,
) {
return new SignJWT({ ver: claims.tokenVersion })
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setSubject(claims.userId)
.setIssuer(config.jwtIssuer)
.setIssuedAt()
.sign(getSecret(config));
}
export async function verifyAccessToken(token: string, config: AppConfig) {
try {
const { payload } = await jwtVerify(token, getSecret(config), {
issuer: config.jwtIssuer,
});
const userId = typeof payload.sub === 'string' ? payload.sub : '';
const tokenVersion = typeof payload.ver === 'number' ? payload.ver : NaN;
if (!userId || !Number.isFinite(tokenVersion)) {
throw unauthorized('JWT 内容无效');
}
return {
userId,
tokenVersion,
} satisfies AccessTokenClaims;
} catch (error) {
throw unauthorized('JWT 校验失败');
}
}