feat: migrate runtime backend to node server
This commit is contained in:
70
server-node/src/auth/authService.ts
Normal file
70
server-node/src/auth/authService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
16
server-node/src/auth/password.ts
Normal file
16
server-node/src/auth/password.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
46
server-node/src/auth/token.ts
Normal file
46
server-node/src/auth/token.ts
Normal 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 校验失败');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user