diff --git a/internal/user/service.go b/internal/user/service.go new file mode 100644 index 0000000..cdd2d9b --- /dev/null +++ b/internal/user/service.go @@ -0,0 +1,137 @@ +// Package user 内的 service.go 实装 Service 接口:把 auth 工具(密码哈希、 +// session token)和 Repository(持久层)粘合为一组面向 transport 层的应用 +// 服务方法。 +// +// 设计取舍: +// - 不区分 “邮箱不存在” 与 “密码错误”:两条路径都返回同一个 CodeUnauthorized +// 错误,避免登录入口被用作邮箱存在性探测。 +// - ResolveSession 把 “未授权” 与 “未找到” 都翻译成 (uuid.Nil, false, nil), +// 符合 transport/http/middleware.SessionResolver 接口约定(token 无效不算 +// 系统错误,由中间件统一返回 401)。 +// - Bootstrap 仅在 DB 中尚无任何用户时落地新管理员;已有用户直接返回 +// (nil, nil),使重复执行 bootstrap 流程不会覆盖现网管理员。 +// - Login 的 ip 参数当前未用,等 G2 任务接审计日志时再消费;保留在签名里 +// 避免后续接审计时再改一遍接口。 +// - now 字段以 func() time.Time 而非直接调用 time.Now 持有,方便后续测试 +// 冻结时间(首版 5 个测试还用不到,但接口先留)。 +package user + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// sessionTTL 是单条会话的有效期。7 天与多数 Web 控制台默认值一致;上层未 +// 暴露续期能力时,到期后客户端需重新登录。 +const sessionTTL = 7 * 24 * time.Hour + +// service 是 Service 接口的默认实现。字段保持小写以禁止外部直接构造, +// 调用方必须通过 NewService 注入 Repository。 +type service struct { + repo Repository + now func() time.Time +} + +// NewService 用给定的 Repository 构造 Service 实现。返回 Service 接口而非 +// *service,以便日后扩展实现(如增加缓存层装饰器)时不破坏调用方代码。 +func NewService(repo Repository) Service { + return &service{repo: repo, now: time.Now} +} + +// Login 校验邮箱与密码,成功后签发新的会话 token。 +// 任何与凭据相关的失败(用户不存在、密码不匹配)都统一返回 CodeUnauthorized, +// 不向调用方泄露 “邮箱是否存在” 这一侧信道;只有底层故障(哈希解析、 +// token 生成、写库失败等)会返回带详细 cause 的非授权类错误。 +func (s *service) Login(ctx context.Context, email, password, _ string) (string, *User, error) { + u, err := s.repo.GetUserByEmail(ctx, email) + if err != nil { + // 不区分 “邮箱不存在” 与 “密码错误”,统一报 unauthorized。 + return "", nil, errs.New(errs.CodeUnauthorized, "邮箱或密码错误") + } + ok, err := VerifyPassword(password, u.PasswordHash) + if err != nil { + return "", nil, errs.Wrap(err, errs.CodeInternal, "verify password") + } + if !ok { + return "", nil, errs.New(errs.CodeUnauthorized, "邮箱或密码错误") + } + + tok, hash, err := NewSessionToken() + if err != nil { + return "", nil, errs.Wrap(err, errs.CodeInternal, "new token") + } + sess := &Session{ + ID: uuid.New(), + UserID: u.ID, + TokenHash: hash, + ExpiresAt: s.now().Add(sessionTTL), + } + if err := s.repo.CreateSession(ctx, sess); err != nil { + return "", nil, err + } + return tok, u, nil +} + +// Logout 删除给定 token 对应的会话。pgRepo.DeleteSessionByTokenHash 对不存在 +// 的行不返错,因此本方法天然幂等:客户端重复登出或登出未知 token 都不会 +// 报错。 +func (s *service) Logout(ctx context.Context, token string) error { + return s.repo.DeleteSessionByTokenHash(ctx, HashSessionToken(token)) +} + +// ResolveSession 把 bearer token 翻译为已认证用户 ID。空 token 直接视作匿名 +// 请求;存在但未知/过期的 token 也归一为 (uuid.Nil, false, nil),由调用方 +// (通常是 Auth 中间件)按 401 处理。只有真正的底层故障才会返回非 nil +// error,从而在系统异常时不掩盖问题。 +func (s *service) ResolveSession(ctx context.Context, token string) (uuid.UUID, bool, error) { + if token == "" { + return uuid.Nil, false, nil + } + sess, err := s.repo.GetSessionByTokenHash(ctx, HashSessionToken(token)) + if err != nil { + ae, ok := errs.As(err) + if ok && (ae.Code == errs.CodeUnauthorized || ae.Code == errs.CodeNotFound) { + return uuid.Nil, false, nil + } + return uuid.Nil, false, err + } + // touch 是 best-effort:失败也不影响本次解析结果,留 _ 显式忽略。 + _ = s.repo.TouchSession(ctx, sess.TokenHash) + return sess.UserID, true, nil +} + +// Get 按主键加载用户。错误透传 Repository 层(NotFound 已在那里翻译完成)。 +func (s *service) Get(ctx context.Context, id uuid.UUID) (*User, error) { + return s.repo.GetUserByID(ctx, id) +} + +// Bootstrap 在系统首次启动时创建初始管理员账号。已有任意用户时返回 +// (nil, nil) 表示 “已无需 bootstrap”,调用方据此判断是否要继续打印初始 +// 凭据。密码长度由 HashPassword 校验;若传入太短的密码,错误码归一为 +// CodeInvalidInput。 +func (s *service) Bootstrap(ctx context.Context, email, password string) (*User, error) { + count, err := s.repo.CountUsers(ctx) + if err != nil { + return nil, err + } + if count > 0 { + // 已有用户,bootstrap 仅在空 DB 时生效。 + return nil, nil + } + hash, err := HashPassword(password) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInvalidInput, "bootstrap password") + } + u := &User{ + ID: uuid.New(), + Email: email, + PasswordHash: hash, + DisplayName: "Admin", + IsAdmin: true, + } + return s.repo.CreateUser(ctx, u) +} diff --git a/internal/user/service_test.go b/internal/user/service_test.go new file mode 100644 index 0000000..fd89375 --- /dev/null +++ b/internal/user/service_test.go @@ -0,0 +1,132 @@ +package user + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// fakeRepo is an in-memory Repository implementation used by the service +// unit tests. It deliberately stores users keyed by email and sessions keyed +// by the string form of TokenHash so lookups stay O(1) and the test setup +// doesn't need to reach into pgx-specific machinery. +type fakeRepo struct { + users map[string]*User + sessions map[string]*Session +} + +func newFakeRepo() *fakeRepo { + return &fakeRepo{users: map[string]*User{}, sessions: map[string]*Session{}} +} + +func (r *fakeRepo) CreateUser(_ context.Context, u *User) (*User, error) { + r.users[u.Email] = u + return u, nil +} + +func (r *fakeRepo) GetUserByEmail(_ context.Context, email string) (*User, error) { + u, ok := r.users[email] + if !ok { + return nil, errs.New(errs.CodeNotFound, "no user") + } + return u, nil +} + +func (r *fakeRepo) GetUserByID(_ context.Context, id uuid.UUID) (*User, error) { + for _, u := range r.users { + if u.ID == id { + return u, nil + } + } + return nil, errs.New(errs.CodeNotFound, "no user") +} + +func (r *fakeRepo) CountUsers(_ context.Context) (int, error) { return len(r.users), nil } + +func (r *fakeRepo) CreateSession(_ context.Context, s *Session) error { + r.sessions[string(s.TokenHash)] = s + return nil +} + +func (r *fakeRepo) GetSessionByTokenHash(_ context.Context, h []byte) (*Session, error) { + s, ok := r.sessions[string(h)] + if !ok || s.ExpiresAt.Before(time.Now()) { + return nil, errs.New(errs.CodeUnauthorized, "no session") + } + return s, nil +} + +func (r *fakeRepo) TouchSession(_ context.Context, _ []byte) error { return nil } + +func (r *fakeRepo) DeleteSessionByTokenHash(_ context.Context, h []byte) error { + delete(r.sessions, string(h)) + return nil +} + +// newServiceWithUser wires a *service backed by a fakeRepo that already +// contains a single user with the given credentials. The returned *service +// is the concrete type (not the Service interface) so individual tests can +// poke at internal hooks like `now` if a future case needs it. +func newServiceWithUser(t *testing.T, email, password string) (*service, *User) { + t.Helper() + repo := newFakeRepo() + hash, err := HashPassword(password) + require.NoError(t, err) + u := &User{ID: uuid.New(), Email: email, DisplayName: "Test", PasswordHash: hash} + repo.users[email] = u + svc := NewService(repo).(*service) + return svc, u +} + +func TestLogin_Success(t *testing.T) { + svc, u := newServiceWithUser(t, "a@b.c", "password123") + tok, gotUser, err := svc.Login(context.Background(), "a@b.c", "password123", "127.0.0.1") + require.NoError(t, err) + require.NotEmpty(t, tok) + require.Equal(t, u.ID, gotUser.ID) +} + +func TestLogin_BadPassword(t *testing.T) { + svc, _ := newServiceWithUser(t, "a@b.c", "password123") + _, _, err := svc.Login(context.Background(), "a@b.c", "wrong-pass", "ip") + require.Error(t, err) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeUnauthorized, ae.Code) +} + +func TestLogin_UnknownEmail(t *testing.T) { + svc, _ := newServiceWithUser(t, "a@b.c", "password123") + _, _, err := svc.Login(context.Background(), "x@b.c", "x", "ip") + require.Error(t, err) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeUnauthorized, ae.Code, "should not leak whether email exists") +} + +func TestResolveSession_Success(t *testing.T) { + svc, u := newServiceWithUser(t, "a@b.c", "password123") + tok, _, err := svc.Login(context.Background(), "a@b.c", "password123", "ip") + require.NoError(t, err) + + uid, ok, err := svc.ResolveSession(context.Background(), tok) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, u.ID, uid) +} + +func TestLogout_RemovesSession(t *testing.T) { + svc, _ := newServiceWithUser(t, "a@b.c", "password123") + tok, _, err := svc.Login(context.Background(), "a@b.c", "password123", "ip") + require.NoError(t, err) + require.NoError(t, svc.Logout(context.Background(), tok)) + + _, ok, err := svc.ResolveSession(context.Background(), tok) + require.NoError(t, err) + require.False(t, ok) +}