第18章:认证与账号服务
本章分析登录链路的 service 侧(auth_service.go)、账号管理(account_service.go 与 handler),以及 pkg/utils/password.go 的密码工具与角色解析。登录是唯一不返回错误码细节的接口(防账号枚举),也是 JWT 生产线与 response 信封的第一次汇合。
流程
登录输入 username + password,处理分五步:查账号 → 比密码 → 查禁用状态 → 签发 JWT → 组装 LoginResult。其中前两步的失败信息刻意统一为「用户名或密码错误」——不管是用户不存在还是密码不对,客户端看到的同一句话,这是防账号枚举的标准做法(错误信息差异会泄露「哪些用户名存在」)。
flowchart TD
A[POST /auth/login] --> B{用户名存在?}
B -->|否| X[用户名或密码错误]
B -->|是| C{bcrypt 比对通过?}
C -->|否| X
C -->|是| D{IsActive == 1?}
D -->|否| Y[账号已被禁用]
D -->|是| E[ParseRoles 解析角色]
E --> F[GenerateToken 签发 JWT]
F --> G[LoginResult: token+expires_in+用户信息]
LoginResult 除了 token 与 expires_in,还携带完整用户档案(ID/Username/Nickname/Avatar/Roles)——前端登录后立刻渲染用户菜单与权限,无需再发一次「获取当前用户」请求。
账号管理(accounts CRUD)是标准资源接口:创建/列表/详情/更新/删除,handler 从 AuthUser 取 created_by(created_by.go 统一处理这个横切关注点)。密码修改走 envinit 的 reset-password 或更新接口(更新时同样经 HashPassword 处理)。
实现
实现要点:
- 错误分流:
GetByUsername返回gorm.ErrRecordNotFound时转换为统一错误;其他 DB 错误原样上抛(500)。仓储错误码不外泄到客户端。 - 密码存储:
utils.HashPassword/utils.ComparePassword(bcrypt 家族实现,password_test.go验证往返);Account.Password 标签json:"-"序列化永远隐藏。比对用恒定时间比较防时序侧信道(bcrypt 库内建)。 - 角色模型:Account.Roles 是逗号分隔字符串(如
admin,operator),utils.ParseRoles切成[]string进 JWT Claims。字符串存储简单直接,权限判断目前只在 UI 层(前端按 Roles 渲染菜单),后端接口未做细粒度 RBAC——单体小团队的合理起点。 - IsActive int8 而非 bool:0/1 整型与 DB default 1 配合,GORM 零值语义明确(
IsActive != 1即禁用)。 auth_test.go/account_repo_stub_test.go:用 stub 仓储覆盖登录成功、密码错、禁用、不存在四个分支——service 层测试不碰数据库的范例。
v2 路由还有一个 handler/v2/account.go——账号接口的 v2 版本(当前与 v1 行为相近,为后续接口演进预留的平行空间,第 19 章路由注册可见)。
📌 设计决策
- 无注册接口:账号由管理员创建,闭合的用户面配企业内部使用定位;开放注册需补邮箱验证、防灌水等一整套机制,目前不值得。
- 登录失败统一文案 + 禁用单独文案:禁用状态不算敏感信息(需要先知道账号存在才可能被禁用提示),且对真实用户排障价值大。
- expires_in 随 token 返回:前端可做「临期静默续登」的体验优化(快过期时后台重新登录)。
代码示例
登录主流程(注释保留原意):
// internal/service/auth_service.go(节选)
func (s *authService) Login(ctx context.Context, username, password string) (*LoginResult, error) {
account, err := s.accountRepo.GetByUsername(ctx, username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("用户名或密码错误") // 防枚举:与密码错误同文案
}
return nil, err
}
if !utils.ComparePassword(account.Password, password) {
return nil, errors.New("用户名或密码错误")
}
if account.IsActive != 1 {
return nil, errors.New("账号已被禁用")
}
roles := utils.ParseRoles(account.Roles)
// 签发 JWT,将常用用户信息写入 Claims,后续请求可直接从 Token 读取
token, err := jwtpkg.GenerateToken(s.jwtSecret, s.expiresIn, jwtpkg.UserClaims{
UserID: account.ID, Username: account.Username,
Nickname: account.Nickname, Avatar: account.Avatar, Roles: roles,
})
if err != nil {
return nil, fmt.Errorf("生成 Token 失败: %w", err)
}
return &LoginResult{
Token: token, ExpiresIn: s.expiresIn,
ID: strconv.FormatUint(uint64(account.ID), 10), /* ... */}, nil
}
小结
- 登录五步:查→比→禁→签→回;失败文案统一防枚举。
- bcrypt 工具 + json:"-" + 恒定时间比较构成密码安全三件套。
- 角色是逗号串,RBAC 留在 UI 层;账号由管理员创建,无开放注册。
思考题
- 若要加登录失败次数限制(防爆破),计数状态放内存、数据库还是 Redis?各自的重启语义如何?
- Roles 从字符串升级为角色表时,JWT Claims 与 GetAuthUser 的契约如何平滑迁移?
项目信息
- GitHub仓库:Chyona/live-mixer