diff --git a/internal/user/handler_test.go b/internal/user/handler_test.go index 68b8e88..fe86643 100644 --- a/internal/user/handler_test.go +++ b/internal/user/handler_test.go @@ -36,7 +36,7 @@ func TestHandler_LoginThenMeThenLogout(t *testing.T) { DisplayName: "T", PasswordHash: hash, } - svc := NewService(repo) + svc := NewService(repo, nil) srv := httptest.NewServer(mountHandler(svc)) defer srv.Close() @@ -90,7 +90,7 @@ func TestHandler_LoginThenMeThenLogout(t *testing.T) { // JSON object lacks both email and password and must be rejected with 400 // before the service layer is consulted. func TestHandler_LoginInvalidPayload(t *testing.T) { - svc := NewService(newFakeRepo()) + svc := NewService(newFakeRepo(), nil) srv := httptest.NewServer(mountHandler(svc)) defer srv.Close() diff --git a/internal/user/service.go b/internal/user/service.go index cdd2d9b..30bacc0 100644 --- a/internal/user/service.go +++ b/internal/user/service.go @@ -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) { diff --git a/internal/user/service_test.go b/internal/user/service_test.go index fd89375..ed9fb4d 100644 --- a/internal/user/service_test.go +++ b/internal/user/service_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) @@ -79,7 +80,7 @@ func newServiceWithUser(t *testing.T, email, password string) (*service, *User) require.NoError(t, err) u := &User{ID: uuid.New(), Email: email, DisplayName: "Test", PasswordHash: hash} repo.users[email] = u - svc := NewService(repo).(*service) + svc := NewService(repo, nil).(*service) return svc, u } @@ -130,3 +131,39 @@ func TestLogout_RemovesSession(t *testing.T) { require.NoError(t, err) require.False(t, ok) } + +type spyAudit struct{ entries []audit.Entry } + +func (s *spyAudit) Record(_ context.Context, e audit.Entry) error { + s.entries = append(s.entries, e) + return nil +} + +func TestLogin_RecordsAudit(t *testing.T) { + repo := newFakeRepo() + hash, err := HashPassword("password123") + require.NoError(t, err) + repo.users["a@b.c"] = &User{ID: uuid.New(), Email: "a@b.c", DisplayName: "T", PasswordHash: hash} + spy := &spyAudit{} + svc := NewService(repo, spy) + + _, _, err = svc.Login(context.Background(), "a@b.c", "password123", "127.0.0.1") + require.NoError(t, err) + require.Len(t, spy.entries, 1) + require.Equal(t, "user.login", spy.entries[0].Action) + require.Equal(t, "127.0.0.1", spy.entries[0].IP) + require.NotNil(t, spy.entries[0].UserID) +} + +func TestLogout_RecordsAudit(t *testing.T) { + svc, _ := newServiceWithUser(t, "a@b.c", "password123") + // 先用 nil audit 走一遍 Login + tok, _, err := svc.Login(context.Background(), "a@b.c", "password123", "127.0.0.1") + require.NoError(t, err) + // 替换 audit recorder 注入 spy 后 Logout + spy := &spyAudit{} + svc.audit = spy + require.NoError(t, svc.Logout(context.Background(), tok)) + require.Len(t, spy.entries, 1) + require.Equal(t, "user.logout", spy.entries[0].Action) +}