feat(user): service with login/logout/resolve/bootstrap (with unit tests)

This commit is contained in:
2026-04-28 17:51:50 +08:00
parent 2141ddafe1
commit c6c4edcd14
2 changed files with 269 additions and 0 deletions
+137
View File
@@ -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)
}
+132
View File
@@ -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)
}