实战:注册与登录
本章代码展示核心流程,可以整合进 Express 5 路由。为了保持篇幅清晰,使用内存数组,并用简单函数完成输入校验。
1. 基础类型与存储
import { createHash, randomBytes, randomUUID } from "node:crypto";
import type { Request, Response } from "express";
import * as argon2 from "argon2";
import jwt from "jsonwebtoken";
type UserRole = "user" | "admin";
interface User {
id: string;
username: string;
passwordHash: string;
role: UserRole;
createdAt: Date;
updatedAt: Date;
}
interface AuthSession {
id: string;
userId: string;
refreshTokenHash: string;
expiresAt: Date;
revokedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
const users: User[] = [];
const authSessions: AuthSession[] = [];
2. Token 工具函数
function readRequiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`缺少环境变量:${name}`);
}
return value;
}
const accessTokenSecret = readRequiredEnv("JWT_ACCESS_SECRET");
const refreshTokenLifetimeMs = 7 * 24 * 60 * 60 * 1000;
function signAccessToken(user: User, sessionId: string): string {
return jwt.sign(
{
sessionId,
tokenType: "access",
role: user.role,
},
accessTokenSecret,
{
algorithm: "HS256",
subject: user.id,
issuer: "nloop-api",
audience: "nloop-client",
expiresIn: "15m",
}
);
}
function createRefreshToken(): string {
return randomBytes(32).toString("base64url");
}
function hashRefreshToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
3. 输入校验
interface Credentials {
username: string;
password: string;
}
function readCredentials(body: unknown): Credentials | null {
if (typeof body !== "object" || body === null) {
return null;
}
const record = body as Record<string, unknown>;
const username = record.username;
const password = record.password;
if (
typeof username !== "string" ||
typeof password !== "string"
) {
return null;
}
const normalizedUsername = username.trim().toLowerCase();
if (
normalizedUsername.length < 3 ||
normalizedUsername.length > 64 ||
password.length < 12 ||
password.length > 128
) {
return null;
}
return {
username: normalizedUsername,
password,
};
}
用户名是否区分大小写必须由业务明确规定。这里统一转为小写,数据库也应采用一致的唯一性规则。
4. 注册
export async function register(
request: Request,
response: Response
): Promise<void> {
const credentials = readCredentials(request.body);
if (!credentials) {
response.status(400).json({ message: "账号或密码格式不正确" });
return;
}
const duplicated = users.some(
(user) => user.username === credentials.username
);
if (duplicated) {
response.status(409).json({ message: "账号已存在" });
return;
}
const now = new Date();
const passwordHash = await argon2.hash(credentials.password, {
type: argon2.argon2id,
});
const user: User = {
id: randomUUID(),
username: credentials.username,
passwordHash,
role: "user",
createdAt: now,
updatedAt: now,
};
users.push(user);
response.status(201).json({
user: {
id: user.id,
username: user.username,
role: user.role,
createdAt: user.createdAt,
},
});
}
关键点:
- 哈希发生在写入数据库之前。
role由服务端决定,不能相信注册请求中的role: "admin"。- 响应不返回密码和
passwordHash。 - MySQL 中必须使用唯一约束处理并发重复注册。
5. 登录
export async function login(
request: Request,
response: Response
): Promise<void> {
const credentials = readCredentials(request.body);
if (!credentials) {
response.status(400).json({ message: "账号或密码格式不正确" });
return;
}
const user = users.find(
(item) => item.username === credentials.username
);
if (!user) {
response.status(401).json({ message: "账号或密码错误" });
return;
}
const passwordMatched = await argon2.verify(
user.passwordHash,
credentials.password
);
if (!passwordMatched) {
response.status(401).json({ message: "账号或密码错误" });
return;
}
if (argon2.needsRehash(user.passwordHash)) {
user.passwordHash = await argon2.hash(credentials.password, {
type: argon2.argon2id,
});
user.updatedAt = new Date();
}
const refreshToken = createRefreshToken();
const now = new Date();
const session: AuthSession = {
id: randomUUID(),
userId: user.id,
refreshTokenHash: hashRefreshToken(refreshToken),
expiresAt: new Date(now.getTime() + refreshTokenLifetimeMs),
revokedAt: null,
createdAt: now,
updatedAt: now,
};
authSessions.push(session);
const accessToken = signAccessToken(user, session.id);
response.status(200).json({
tokenType: "Bearer",
accessToken,
expiresIn: 15 * 60,
refreshToken,
user: {
id: user.id,
username: user.username,
role: user.role,
},
});
}
6. 路由注册
import { Router } from "express";
const authRouter = Router();
authRouter.post("/register", register);
authRouter.post("/login", login);
应用必须启用 JSON 请求体解析:
app.use(express.json({ limit: "16kb" }));
合理的请求体限制可以降低异常大请求带来的资源风险。
7. 请求示例
注册:
POST /register
Content-Type: application/json
{
"username": "zhangsan",
"password": "correct horse battery staple"
}
登录:
POST /login
Content-Type: application/json
{
"username": "zhangsan",
"password": "correct horse battery staple"
}
8. 仍需补充的生产措施
- 登录、注册限流。
- 账号锁定或渐进延迟策略。
- 数据库事务和唯一约束。
- 结构化但不记录密码/完整 Token 的日志。
- Refresh Token 改用安全 Cookie。
- 审计登录设备、IP 和 User-Agent(注意隐私与可信代理设置)。
- 统一异常处理中间件,避免原生错误信息泄露。