前言
前面几篇已经完成了项目架构、全局请求链路、TypeORM 数据库设计以及客户 CRUD。接下来要解决的是中后台系统最核心的入口:登录认证。
一个真实项目的登录并不只是"账号密码正确就返回 JWT"。还需要考虑:
- 用户可以使用手机号或邮箱登录
- 密码必须使用哈希进行校验
- 连续登录失败后要限制账号
- 用户状态异常时拒绝登录
- Access Token 和 Refresh Token 如何分工
- Refresh Token 如何防止重复使用
- 退出登录后旧 Token 如何立即失效
- CRM 与 SaaS 系统如何使用不同密钥
- 登录后如何恢复用户、组织和团队上下文
本篇结合真实项目中的 LoginController、LoginService、JwtStrategy、JwtTokenService 和 Redis Token 服务,梳理一条完整的 JWT 登录认证链路。
一、认证和授权先分清楚
认证和授权是两个不同概念:
认证 Authentication
└─ 你是谁?是否已经登录?
授权 Authorization
└─ 你能做什么?是否拥有接口权限?
JWT 主要解决认证问题。用户通过 JWT Guard 后,只能说明身份有效,并不代表可以访问所有客户、订单和系统管理接口。
后续还需要 TeamGuard 和 PermissionGuard 继续完成授权判断。
二、真实登录模块的目录结构
项目的登录能力并不全部写在一个文件中,而是按职责拆分:
modules/crm/auth/login/
├─ dto/
│ └─ login.dto.ts
├─ login.controller.ts
├─ login.service.ts
├─ login-limit.service.ts
└─ login.module.ts
common/jwt/
├─ jwt.guard.ts
├─ jwt.module.ts
├─ jwt.payload.ts
├─ jwt.service.ts
├─ jwt.strategy.ts
└─ public.decorator.ts
主要职责如下:
| 模块 | 职责 |<br>|---|---|<br>| LoginController | 登录、刷新令牌、退出登录 |<br>| LoginService | 查找用户、校验密码和账号状态 |<br>| LoginLimitService | 登录失败计数与账号锁定 |<br>| JwtTokenService | 签发、解析和验证 Token |<br>| JwtStrategy | 从请求中解析 JWT 并恢复用户 |<br>| JwtAuthGuard | 控制接口是否需要登录 |<br>| RedisTokenService | Refresh Token、黑名单和撤销状态 |
三、登录 DTO:限制输入边界
登录接口首先需要明确输入结构:
export class LoginDto {
@IsNotEmpty({ message: '账号不能为空' })
@IsString()
account: string
@IsNotEmpty({ message: '密码不能为空' })
@IsString()
password: string
}
DTO 只负责输入格式校验,不能在 DTO 中查询用户或比较密码。
登录接口应避免返回过于具体的错误:
不推荐:该邮箱不存在
不推荐:密码错误
推荐:账号或密码错误
统一提示可以减少攻击者枚举有效账号的机会。
四、LoginService:查找并校验用户
真实项目允许使用手机号或邮箱作为账号:
async findLoginUser(loginDto: LoginDto) {
const errMsg = '账号或密码错误'
const where = {}
if (isPhoneNumber(loginDto.account, 'CN')) {
where['phone'] = loginDto.account
}
if (isEmail(loginDto.account)) {
where['email'] = loginDto.account
}
const member = await this.memberRepo.findOneBy({
...where,
type: 'user'
})
if (!member) {
throw new BadRequestException(errMsg)
}
return member
}
需要注意:如果账号既不是合法手机号,也不是合法邮箱,应该尽早返回统一错误,避免生成空查询条件。
五、密码不能明文存储
数据库中不应该保存用户明文密码,而应该保存哈希值:
用户输入密码
↓
bcrypt.compare()
↓
和数据库密码哈希比较
↓
返回 true / false
示例:
const isSame = await this.passwordService.compare(
loginDto.password,
member.password || ''
)
if (!isSame) {
throw new BadRequestException('账号或密码错误')
}
密码哈希应该使用 bcrypt、Argon2 等成熟算法,不要自行实现加密算法,也不要使用普通 MD5 保存密码。
六、登录失败锁定
如果接口允许无限尝试密码,攻击者就可以持续进行暴力破解。
项目通过 LoginLimitService 记录失败次数:
const isLocked = await this.loginLimitService
.isAccountLocked(member.uuid)
if (isLocked) {
throw new BadRequestException(
'由于登录失败次数过多,账号已被锁定'
)
}
密码错误时增加失败次数:
if (!isSame) {
await this.loginLimitService
.incrementFailedAttempts(member.uuid)
throw new BadRequestException(errMsg)
}
登录成功后重置:
await this.loginLimitService
.resetFailedAttempts(member.uuid)
失败计数通常适合放在 Redis 中,并设置合理过期时间。
七、账号状态校验
密码正确也不代表一定允许登录:
if (member.status !== 1) {
throw new BadRequestException('账号状态异常')
}
常见账号状态包括:
- 正常
- 禁用
- 待激活
- 已离职
- 已注销
账号状态必须由后端校验,前端隐藏登录入口没有安全意义。
八、登录接口的完整流程
Controller 中的登录接口可以保持简洁:
@Public()
@Post()
async create(@Body() loginDto: LoginDto) {
const member = await this.loginService
.findLoginUser(loginDto)
const { organizations, defaultOrgId } =
await this.organizationContextService
.resolveMemberOrganizations(member.uuid)
const tokens = this.jwtTokenService
.sign(member, defaultOrgId)
return {
...tokens,
organizations
}
}
完整链路是:
接收账号和密码
↓
查找用户
↓
检查登录锁定
↓
比较密码哈希
↓
检查账号状态
↓
解析用户组织
↓
签发 Access Token 与 Refresh Token
九、Access Token 和 Refresh Token
两个 Token 的职责不同:
| Token | 用途 | 建议有效期 |<br>|---|---|---|<br>| Access Token | 访问业务接口 | 较短 |<br>| Refresh Token | 换取新 Token | 较长 |
签发示例:
sign(member: Member, organizationUuid?: string) {
const data = {
uuid: member.uuid,
organizationUuid,
openid: member.openid
}
const accessToken = this.jwtService.sign(data)
const refreshToken = this.jwtService.sign(data, {
secret: this.refreshSecret,
expiresIn: this.refreshExpiresIn
})
return { accessToken, refreshToken }
}
不要把密码、手机号、完整角色权限列表等大量信息塞进 JWT。JWT Payload 应该保持精简。
十、Refresh Token 单次轮换
如果 Refresh Token 被窃取且可以无限重复使用,攻击者就可以持续刷新登录状态。
项目使用 Redis 保证 Refresh Token 只能使用一次:
客户端提交 Refresh Token
↓
Redis 检查是否有效
↓
立即标记旧 Token 已使用
↓
验证签名并读取用户
↓
签发新 Access Token 和 Refresh Token
↓
保存新的 Refresh Token 状态
接口示例:
@Public()
@Post('refresh')
async refresh(
@Headers('x-refresh-token') refreshToken: string
) {
if (!refreshToken) {
throw new UnauthorizedException('缺少刷新token')
}
await this.redisTokenService
.ensureRefreshTokenNotUsed(refreshToken)
const payload = this.jwtTokenService.verify(
refreshToken,
this.jwtRefreshSecret
)
const member = await this.loginService
.findMemberByUuid(payload.uuid)
return this.jwtTokenService.sign(
member,
payload.organizationUuid
)
}
这就是常见的 Refresh Token Rotation。
十一、JwtStrategy 如何恢复当前用户
Passport JWT Strategy 从请求头中读取 Bearer Token:
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
passReqToCallback: true
})
验证成功后,validate() 会查询当前用户:
async validate(req: Request, payload: JwtPayload) {
const type = req.url.split('/').filter(Boolean)[0]
const user = await this.jwtTokenService
.findUser(type as 'saas' | 'crm', payload.uuid)
return {
...user,
organizationUuid: payload.organizationUuid
}
}
返回值会被挂载到:
request.user
后续 Controller、TeamGuard 和 PermissionGuard 都可以读取当前用户。
十二、CRM 与 SaaS 使用不同密钥
项目包含 CRM 和 SaaS 两类接口,因此 JWT Strategy 会根据路径选择密钥:
/crm/... → jwt.crmSecret
/saas/... → jwt.saasSecret
这样可以避免不同系统的 Token 互相通用。
密钥必须来自安全配置,不能直接写在代码仓库中:
jwt:
crmSecret: ${JWT_CRM_SECRET}
saasSecret: ${JWT_SAAS_SECRET}
refreshSecret: ${JWT_REFRESH_SECRET}
十三、退出登录与 Token 黑名单
JWT 的一个特点是签发后可以独立验证。如果不增加额外状态,用户退出登录后,未过期的 Access Token 仍然可以使用。
项目通过 Redis 黑名单解决:
@Public()
@Post('logout')
async logout(
@AccessToken() accessToken: string,
@Headers('x-refresh-token') refreshToken: string
) {
if (accessToken) {
const decoded = this.jwtTokenService.decode(accessToken)
const ttl = decoded?.exp
? decoded.exp - Math.floor(Date.now() / 1000)
: 900
await this.redisTokenService
.blacklistAccessToken(accessToken, ttl)
}
if (refreshToken) {
await this.redisTokenService
.revokeRefreshToken(refreshToken)
}
return '退出成功'
}
JwtStrategy 每次验证时检查 Access Token 是否已撤销。
十四、前端如何配合
前端登录后保存 Token,并在请求中携带:
request.interceptors.request.use(config => {
const token = authStore.accessToken
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
遇到 Access Token 过期时,可以尝试刷新:
业务请求返回 401
↓
调用 refresh 接口
↓
刷新成功:保存新 Token 并重放原请求
↓
刷新失败:清理登录状态并跳转登录页
需要避免多个请求同时触发多次刷新,可以通过 Promise 队列或刷新锁进行合并。
十五、常见安全问题
15.1 JWT 中放入敏感信息
JWT 默认只是编码和签名,并不是加密。Payload 可以被客户端读取。
15.2 Access Token 有效期过长
有效期越长,泄露后的风险窗口越大。
15.3 Refresh Token 可以重复使用
应该通过 Redis 保存状态并执行单次轮换。
15.4 退出登录只清理前端 Token
后端应该撤销 Refresh Token,并将未过期的 Access Token 加入黑名单。
15.5 登录错误信息过于具体
统一使用"账号或密码错误",减少账号枚举风险。
15.6 没有登录失败限制
应该限制连续失败次数,并设置合理锁定时间。
十六、面试聚焦
16.1 JWT 和 Session 有什么区别?
JWT 可以独立验证,适合分布式接口;Session 通常由服务端保存状态。JWT 并不等于完全无状态,黑名单和刷新令牌仍可能依赖 Redis。
16.2 为什么需要 Refresh Token?
让 Access Token 保持较短有效期,同时避免用户频繁重新登录。
16.3 JWT 如何实现退出登录?
撤销 Refresh Token,并将未过期 Access Token 放入 Redis 黑名单。
16.4 为什么要查询数据库恢复用户?
因为用户可能被禁用、删除或修改角色,不能永久相信签发时的旧状态。
16.5 为什么 CRM 和 SaaS 使用不同密钥?
为了隔离不同系统的身份边界,避免一种 Token 被另一个系统接受。
十七、思考与练习
- 如何设计多个请求同时触发 Token 刷新的前端队列?
- Refresh Token 被重复使用时,后端应该如何处理?
- 用户被管理员禁用后,现有 Token 如何立即失效?
- JWT Payload 中应该放哪些字段?
- 为什么不能只在前端实现登录失败次数限制?
总结
- JWT 解决身份认证,不能代替接口授权
- 登录要校验账号、密码哈希、失败次数和账号状态
- Access Token 负责访问接口,Refresh Token 负责续期
- Refresh Token 应该执行单次轮换,防止重复使用
- JwtStrategy 负责验证 Token 并恢复当前用户
- CRM 与 SaaS 应该使用不同签名密钥
- 退出登录需要撤销 Refresh Token,并处理 Access Token 黑名单
- Redis 让 JWT 认证具备撤销、锁定和刷新状态管理能力