实战:注册与登录

本章代码展示核心流程,可以整合进 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,
    },
  });
}

关键点:

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. 仍需补充的生产措施