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
注意:
- Secret 不要写进源码、JWT Payload、日志或 Git;
- 生产和开发使用不同 Secret;
- 本练习把有效期配置为整数秒:配置层必须检查非空、整数且大于 0,再转换为
number; jsonwebtoken也支持30m这样的字符串,但新版类型定义会要求进一步收窄类型,而且无单位字符串容易产生单位误解,新人阶段先使用整数秒;- Secret 泄漏后,仅修改密码或
tokenVersion不够,应轮换 Secret。
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 的原子自增、条件更新或事务行锁。
建议业务规则:
- 用户不存在也返回
INVALID_CREDENTIALS,但没有记录可累加; - 密码错误时原子增加失败次数;
- 达到阈值时设置
lockedUntil; - 自动临时锁定只使用
lockedUntil,此时accountStatus仍为 Active;accountStatus = Locked专门表示人工长期锁定,不能与临时锁定混用; - 成功登录后清零计数并清除已过期锁定;
lockedUntil未到时不要执行昂贵的密码校验;- 除账号锁定外,登录接口仍应配合 API 限流。
账号锁定不能替代限流,否则攻击者可以故意输错密码,把合法管理员锁死。
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
验收清单:
- [ ] HTTP 状态是项目约定的 200;
- [ ] 错误密码返回
INVALID_CREDENTIALS; - [ ] 不存在的用户名也返回相同对外信息;
- [ ] Token 可以被解码查看,但修改 Payload 后无法通过验证;
- [ ] Token Payload 不包含密码和摘要;
- [ ] 连续失败会累计并按规则锁定;
- [ ] 成功登录会清零失败次数;
- [ ] 日志不出现密码、摘要和完整 Token。
13. 练习题
- 为什么同一个密码不能通过“重新 Hash 后比较字符串”验证?
- 为什么用户名不存在和密码错误应返回相同信息?
jwt.decode()和jwt.verify()的安全边界是什么?- JWT 已有签名,为什么 Payload 仍不能存放秘密?
- 两个登录失败请求并发执行时,失败计数为什么可能丢失?
- 账号锁定为什么不能替代 IP/账号维度的登录限流?
issuer和audience分别避免了什么误用?- HTTP 全部返回 200 后,日志和前端需要做哪些补偿?