feat(user): wire audit recorder into login/logout

This commit is contained in:
2026-04-28 23:02:09 +08:00
parent f42b88e57d
commit 62cf9ffa0c
3 changed files with 86 additions and 18 deletions
+46 -15
View File
@@ -3,15 +3,14 @@
// 服务方法。
//
// 设计取舍:
// - 不区分 邮箱不存在密码错误:两条路径都返回同一个 CodeUnauthorized
// - 不区分 "邮箱不存在""密码错误":两条路径都返回同一个 CodeUnauthorized
// 错误,避免登录入口被用作邮箱存在性探测。
// - ResolveSession 把 未授权未找到 都翻译成 (uuid.Nil, false, nil),
// - ResolveSession 把 "未授权""未找到" 都翻译成 (uuid.Nil, false, nil),
// 符合 transport/http/middleware.SessionResolver 接口约定(token 无效不算
// 系统错误,由中间件统一返回 401)。
// - Bootstrap 仅在 DB 中尚无任何用户时落地新管理员;已有用户直接返回
// (nil, nil),使重复执行 bootstrap 流程不会覆盖现网管理员。
// - Login ip 参数当前未用,等 G2 任务接审计日志时再消费;保留在签名里
// 避免后续接审计时再改一遍接口。
// - Login ip 透传给 audit recorder(IP 解析在 audit 包内完成)。
// - now 字段以 func() time.Time 而非直接调用 time.Now 持有,方便后续测试
// 冻结时间(首版 5 个测试还用不到,但接口先留)。
package user
@@ -22,6 +21,7 @@ import (
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
@@ -32,24 +32,27 @@ const sessionTTL = 7 * 24 * time.Hour
// service 是 Service 接口的默认实现。字段保持小写以禁止外部直接构造,
// 调用方必须通过 NewService 注入 Repository。
type service struct {
repo Repository
now func() time.Time
repo Repository
audit audit.Recorder
now func() time.Time
}
// NewService 用给定的 Repository 构造 Service 实现。返回 Service 接口而非
// *service,以便日后扩展实现(如增加缓存层装饰器)时不破坏调用方代码
func NewService(repo Repository) Service {
return &service{repo: repo, now: time.Now}
// NewService 用给定的 Repository 和 audit.Recorder 构造 Service 实现。
// recorder 可为 nil(审计是 best-effort,nil 时直接跳过写审计)
// 返回 Service 接口而非 *service,以便日后扩展实现(如增加缓存层装饰器)
// 时不破坏调用方代码。
func NewService(repo Repository, recorder audit.Recorder) Service {
return &service{repo: repo, audit: recorder, now: time.Now}
}
// Login 校验邮箱与密码,成功后签发新的会话 token。
// 任何与凭据相关的失败(用户不存在、密码不匹配)都统一返回 CodeUnauthorized,
// 不向调用方泄露 邮箱是否存在 这一侧信道;只有底层故障(哈希解析、
// 不向调用方泄露 "邮箱是否存在" 这一侧信道;只有底层故障(哈希解析、
// token 生成、写库失败等)会返回带详细 cause 的非授权类错误。
func (s *service) Login(ctx context.Context, email, password, _ string) (string, *User, error) {
func (s *service) Login(ctx context.Context, email, password, ip string) (string, *User, error) {
u, err := s.repo.GetUserByEmail(ctx, email)
if err != nil {
// 不区分 邮箱不存在密码错误,统一报 unauthorized。
// 不区分 "邮箱不存在""密码错误",统一报 unauthorized。
return "", nil, errs.New(errs.CodeUnauthorized, "邮箱或密码错误")
}
ok, err := VerifyPassword(password, u.PasswordHash)
@@ -73,14 +76,42 @@ func (s *service) Login(ctx context.Context, email, password, _ string) (string,
if err := s.repo.CreateSession(ctx, sess); err != nil {
return "", nil, err
}
// 审计是 best-effort:_ = 吞掉错误,审计落库失败不能导致登录失败。
if s.audit != nil {
uid := u.ID
_ = s.audit.Record(ctx, audit.Entry{
UserID: &uid,
Action: "user.login",
TargetType: "user",
TargetID: u.ID.String(),
IP: ip,
})
}
return tok, u, nil
}
// Logout 删除给定 token 对应的会话。pgRepo.DeleteSessionByTokenHash 对不存在
// 的行不返错,因此本方法天然幂等:客户端重复登出或登出未知 token 都不会
// 报错。
// 先 ResolveSession 拿 uid 是为了给 audit Entry 填充 UserID;ResolveSession
// 失败(如 token 已过期/无效)时依然继续删除会话(保持幂等),只是 uid 为
// uuid.Nil,此时跳过写审计。
func (s *service) Logout(ctx context.Context, token string) error {
return s.repo.DeleteSessionByTokenHash(ctx, HashSessionToken(token))
uid, _, _ := s.ResolveSession(ctx, token)
if err := s.repo.DeleteSessionByTokenHash(ctx, HashSessionToken(token)); err != nil {
return err
}
// 审计是 best-effort:_ = 吞掉错误;uid == uuid.Nil 表示 token 无效,跳过。
if s.audit != nil && uid != uuid.Nil {
u := uid
_ = s.audit.Record(ctx, audit.Entry{
UserID: &u,
Action: "user.logout",
TargetType: "user",
TargetID: uid.String(),
})
}
return nil
}
// ResolveSession 把 bearer token 翻译为已认证用户 ID。空 token 直接视作匿名
@@ -110,7 +141,7 @@ func (s *service) Get(ctx context.Context, id uuid.UUID) (*User, error) {
}
// Bootstrap 在系统首次启动时创建初始管理员账号。已有任意用户时返回
// (nil, nil) 表示 已无需 bootstrap,调用方据此判断是否要继续打印初始
// (nil, nil) 表示 "已无需 bootstrap",调用方据此判断是否要继续打印初始
// 凭据。密码长度由 HashPassword 校验;若传入太短的密码,错误码归一为
// CodeInvalidInput。
func (s *service) Bootstrap(ctx context.Context, email, password string) (*User, error) {