05 管理员登录与 JWT

本章实现第一个公开接口:管理员登录。重点不是把所有代码一次写完,而是理解密码验证、登录失败处理和 JWT 签发之间的边界。

本练习沿用项目约定:Express 已处理的业务错误也返回 HTTP 200,通过 JSON code 判断成功或失败。这样做会让 Nginx、curl 和许多监控工具无法只看 HTTP 状态识别失败,因此日志必须记录 businessCode,前端也必须检查 code

1. 登录流程

POST /api/auth/login
        ↓
校验 username、password
        ↓
查询管理员(本次需要显式读取 passwordHash)
        ↓
检查账号状态和 lockedUntil
        ↓
argon2.verify(passwordHash, password)
        ↓
失败:增加失败次数,必要时临时锁定
成功:清零失败次数,更新 lastLoginAt
        ↓
jsonwebtoken.sign() 签发 Access Token
        ↓
返回 Token 和不含敏感字段的管理员 DTO

认证与授权不是一回事:

本阶段只有 manager 一种角色,先把认证做正确,后续再扩展角色权限。

2. HTTP 契约

请求

POST /api/auth/login
Content-Type: application/json

{
  "username": "admin",
  "password": "用户输入的密码"
}

成功响应

{
  "code": "OK",
  "message": "登录成功",
  "data": {
    "accessToken": "eyJ...",
    "tokenType": "Bearer",
    "expiresIn": 1800,
    "admin": {
      "id": "1",
      "username": "admin",
      "displayName": "系统管理员",
      "email": "admin@example.com",
      "accountStatus": 1,
      "lastLoginAt": "2026-08-28T02:10:00.000Z",
      "createdAt": "2026-08-20T01:00:00.000Z"
    }
  }
}

用户名不存在或密码错误

两种情况必须使用相同的外部响应,避免攻击者枚举用户名:

{
  "code": "INVALID_CREDENTIALS",
  "message": "用户名或密码错误",
  "data": null
}

服务器内部日志可以区分“用户不存在”和“密码错误”,但不得记录密码、密码摘要或完整 JWT。

其他建议业务码:

场景 JSON code
参数格式错误 VALIDATION_ERROR
用户名或密码错误 INVALID_CREDENTIALS
账号被禁用 ACCOUNT_DISABLED
账号临时锁定 ACCOUNT_LOCKED
未知错误 INTERNAL_SERVER_ERROR

是否把“禁用”和“锁定”明确告诉客户端需要业务决策。若系统面临较高枚举风险,也可以统一为更模糊的登录失败消息。

3. 环境变量

JWT_ACCESS_SECRET=请使用足够长的随机字符串
JWT_ACCESS_EXPIRES_IN_SECONDS=1800
JWT_ISSUER=nloop-sakila-api
JWT_AUDIENCE=nloop-sakila-admin

注意:

4. TypeScript 接口

以下公共类型应优先放在共享类型文件中,本章展示的是期望接口。

export interface LoginBody {
  username: string;
  password: string;
}

export interface AdminUserDto {
  id: string;
  username: string;
  displayName: string;
  email: string | null;
  accountStatus: number;
  lastLoginAt: string | null;
  createdAt: string;
}

export interface LoginResultDto {
  accessToken: string;
  tokenType: "Bearer";
  expiresIn: number;
  admin: AdminUserDto;
}

export interface AdminAccessTokenPayload {
  sub: string;
  role: "manager";
  tokenVersion: number;
}

sub 是 JWT 标准声明,表示 Token 主体。管理员使用 BIGINT 主键时,建议一路使用字符串,避免超过 JavaScript 安全整数范围。

JWT 是“签名后的可读数据”,不是加密数据。任何拿到 Token 的人都能解码 Payload,因此绝不能写入:

password
passwordHash
邮箱以外的隐私数据
数据库连接信息
JWT Secret

5. validator 骨架

import { body } from "express-validator";

export const loginValidation = [
  body("username")
    .exists({ values: "falsy" })
    .withMessage("请输入用户名")
    .bail()
    .isString()
    .withMessage("用户名必须是字符串")
    .bail()
    // TODO:trim 后限制长度,并与数据库字段长度一致
    ,

  body("password")
    .exists({ values: "falsy" })
    .withMessage("请输入密码")
    .bail()
    .isString()
    .withMessage("密码必须是字符串")
    // TODO:限制请求体密码的合理最大长度,避免异常输入消耗资源
    ,
];

登录时通常不要对密码调用 trim():空格可能本来就是密码的一部分。用户名是否转小写,应与建表时的大小写和排序规则保持一致。

6. Router 与 Controller 骨架

import { Router } from "express";

const router = Router();

router.post(
  "/login",
  loginValidation,
  validateRequest,
  loginAdminController,
);

export default router;
import type { Request, Response } from "express";

export async function loginAdminController(
  req: Request<
    Record<string, never>,
    ApiResponse<LoginResultDto>,
    LoginBody
  >,
  res: Response<ApiResponse<LoginResultDto>>,
): Promise<void> {
  // TODO:只提取 username 和 password,不要把整个 req.body 传给 Service
  const result = await loginAdmin({
    username: "",
    password: "",
  });

  res.json({
    code: "OK",
    message: "登录成功",
    data: result,
  });
}

Controller 只处理 HTTP 输入输出,不负责查询管理员、验证密码或生成 JWT。

7. Service 接口与 TODO

export interface LoginInput {
  username: string;
  password: string;
}

export async function loginAdmin(
  input: LoginInput,
): Promise<LoginResultDto> {
  // TODO 1:使用包含 passwordHash 的 scope 查询管理员
  // TODO 2:检查账号状态和 lockedUntil
  // TODO 3:调用 argon2.verify(storedHash, input.password)
  // TODO 4:失败时原子更新 failedLoginCount,达到阈值时设置 lockedUntil
  // TODO 5:成功时清零失败次数并更新 lastLoginAt
  // TODO 6:签发 Access Token
  // TODO 7:转换为 AdminUserDto,不能返回 passwordHash/tokenVersion
  throw new Error("TODO");
}

8. Argon2 验证要点

项目已经安装 argon2,验证接口的参数顺序是:

const matched = await argon2.verify(
  admin.passwordHash,
  input.password,
);

不要反过来,也不要把输入密码再次 hash() 后比较字符串。Argon2 摘要自带随机盐,同一个密码多次 Hash 的结果通常不同。

为了降低用户名是否存在造成的时间差,进阶实现可以在用户不存在时也验证一个固定的、预先生成的 Argon2 假摘要。但不要每次请求临时生成假摘要,那会放大 CPU 消耗。

9. 登录失败计数与锁定

假设策略是:连续失败 5 次锁定 15 分钟。需要考虑:

请求 A 与请求 B 同时读取 failedLoginCount = 3
请求 A 写入 4
请求 B 也写入 4

因此不建议用“读取后在 Node.js 中加 1 再保存”的朴素写法。练习目标是研究 Sequelize 的原子自增、条件更新或事务行锁。

建议业务规则:

账号锁定不能替代限流,否则攻击者可以故意输错密码,把合法管理员锁死。

10. 正确签发 JWT

使用 sign(),而不是自己拼接字符串:

import jwt from "jsonwebtoken";

const payload: AdminAccessTokenPayload = {
  sub: admin.id,
  role: "manager",
  tokenVersion: admin.tokenVersion,
};

const accessToken = jwt.sign(
  payload,
  jwtSecret,
  {
    algorithm: "HS256",
    expiresIn: jwtExpiresInSeconds,
    issuer: jwtIssuer,
    audience: jwtAudience,
  },
);

这是调用形状提示,不是完整答案。jwtExpiresInSeconds 应由配置层校验后得到明确的 number,成功响应中的 expiresIn 也必须使用同一个值,避免前端倒计时与 Token 实际过期时间不一致。

JWT 中的重要声明:

声明 作用
sub 管理员 ID
exp 过期时间,由 expiresIn 产生
iat 签发时间,通常自动产生
iss 签发者,对应 issuer
aud 使用者,对应 audience

显式固定 algorithm,避免验证端接受超出预期的算法。

11. 为什么不能使用 jwt.decode() 验证登录

const payload = jwt.decode(token);

decode() 只是解码 Base64URL 内容,不验证签名。攻击者可以伪造:

{
  "sub": "1",
  "role": "manager",
  "tokenVersion": 999
}

验证必须使用:

jwt.verify(token, secret, options);

verify() 需要同时检查签名、过期时间、算法、issuer 和 audience。完整验证留到下一章实现。

12. curl 验收

curl -i \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"正确密码"}' \
  http://127.0.0.1:8080/api/auth/login

错误密码:

curl -i \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"wrong"}' \
  http://127.0.0.1:8080/api/auth/login

验收清单:

13. 练习题

  1. 为什么同一个密码不能通过“重新 Hash 后比较字符串”验证?
  2. 为什么用户名不存在和密码错误应返回相同信息?
  3. jwt.decode()jwt.verify() 的安全边界是什么?
  4. JWT 已有签名,为什么 Payload 仍不能存放秘密?
  5. 两个登录失败请求并发执行时,失败计数为什么可能丢失?
  6. 账号锁定为什么不能替代 IP/账号维度的登录限流?
  7. issueraudience 分别避免了什么误用?
  8. HTTP 全部返回 200 后,日志和前端需要做哪些补偿?