You've already forked agentic-coding-workflow
411 lines
15 KiB
Go
411 lines
15 KiB
Go
// 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 透传给 audit recorder(IP 解析在 audit 包内完成)。
|
|
// - now 字段以 func() time.Time 而非直接调用 time.Now 持有,方便后续测试
|
|
// 冻结时间(首版 5 个测试还用不到,但接口先留)。
|
|
package user
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
|
"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
|
|
audit audit.Recorder
|
|
now func() time.Time
|
|
throttle *LoginThrottle // 登录暴力破解节流;默认 disabled,装配期可回填
|
|
}
|
|
|
|
// NewService 用给定的 Repository 和 audit.Recorder 构造 Service 实现。
|
|
// recorder 可为 nil(审计是 best-effort,nil 时直接跳过写审计)。
|
|
// 返回 Service 接口而非 *service,以便日后扩展实现(如增加缓存层装饰器)
|
|
// 时不破坏调用方代码。默认装一个 disabled 的 LoginThrottle(无节流),
|
|
// 装配期通过 SetLoginThrottle 注入启用的实例。
|
|
func NewService(repo Repository, recorder audit.Recorder) Service {
|
|
return &service{
|
|
repo: repo,
|
|
audit: recorder,
|
|
now: time.Now,
|
|
throttle: NewLoginThrottle(LoginThrottleConfig{}, nil),
|
|
}
|
|
}
|
|
|
|
// SetLoginThrottle 注入登录节流器(装配期回填)。传 nil 时退回 disabled。
|
|
// 因 Service 是接口,调用方需类型断言到该 setter(与项目其它装配期回填一致)。
|
|
func (s *service) SetLoginThrottle(t *LoginThrottle) {
|
|
if t == nil {
|
|
t = NewLoginThrottle(LoginThrottleConfig{}, nil)
|
|
}
|
|
s.throttle = t
|
|
}
|
|
|
|
// LoginThrottleSetter 是装配期回填登录节流器的窄接口(app.go 用类型断言取得)。
|
|
type LoginThrottleSetter interface {
|
|
SetLoginThrottle(t *LoginThrottle)
|
|
}
|
|
|
|
// Login 校验邮箱与密码,成功后签发新的会话 token。
|
|
// 任何与凭据相关的失败(用户不存在、密码不匹配)都统一返回 CodeUnauthorized,
|
|
// 不向调用方泄露 "邮箱是否存在" 这一侧信道;只有底层故障(哈希解析、
|
|
// token 生成、写库失败等)会返回带详细 cause 的非授权类错误。
|
|
func (s *service) Login(ctx context.Context, email, password, ip string) (string, *User, error) {
|
|
// 暴力破解节流:在任何 DB 查询 / 密码哈希之前判定,超限直接 429,避免攻击者
|
|
// 持续消耗 bcrypt CPU。key = ip + lowercase(email),未知邮箱与已知邮箱走同一
|
|
// 计数路径,不引入邮箱枚举侧信道。
|
|
if allowed, _ := s.throttle.Allow(ip, email); !allowed {
|
|
return "", nil, errs.New(errs.CodeRateLimited, "登录尝试过于频繁,请稍后再试")
|
|
}
|
|
|
|
u, err := s.repo.GetUserByEmail(ctx, email)
|
|
if err != nil {
|
|
// 不区分 "邮箱不存在" 与 "密码错误",统一报 unauthorized。失败计数。
|
|
s.throttle.RecordFailure(ip, email)
|
|
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 {
|
|
s.throttle.RecordFailure(ip, email)
|
|
return "", nil, errs.New(errs.CodeUnauthorized, "邮箱或密码错误")
|
|
}
|
|
// 凭据正确:清零该 key 的失败计数。
|
|
s.throttle.Reset(ip, email)
|
|
|
|
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
|
|
}
|
|
// 审计是 best-effort:_ = 吞掉错误,审计落库失败不能导致登录失败。
|
|
// 用 context.WithoutCancel 派生:HTTP handler 在响应返回后 r.Context() 会
|
|
// 被取消,但审计写入必须完成(漏行比写错更危险)。timeout 由调用方在
|
|
// ctx 上不再控制,audit recorder 自身需保证不无限阻塞。
|
|
if s.audit != nil {
|
|
uid := u.ID
|
|
_ = s.audit.Record(context.WithoutCancel(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 {
|
|
uid, _, _ := s.ResolveSession(ctx, token)
|
|
if err := s.repo.DeleteSessionByTokenHash(ctx, HashSessionToken(token)); err != nil {
|
|
return err
|
|
}
|
|
// 审计是 best-effort:_ = 吞掉错误;uid == uuid.Nil 表示 token 无效,跳过。
|
|
// 用 context.WithoutCancel 派生:HTTP handler 在响应返回后 r.Context() 会
|
|
// 被取消,但审计写入必须完成(漏行比写错更危险)。timeout 由调用方在
|
|
// ctx 上不再控制,audit recorder 自身需保证不无限阻塞。
|
|
if s.audit != nil && uid != uuid.Nil {
|
|
u := uid
|
|
_ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{
|
|
UserID: &u,
|
|
Action: "user.logout",
|
|
TargetType: "user",
|
|
TargetID: uid.String(),
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// ListAdmins 返回所有管理员用户,供内部模块(如 jobs runner)使用。
|
|
func (s *service) ListAdmins(ctx context.Context) ([]*User, error) {
|
|
return s.repo.ListAdmins(ctx)
|
|
}
|
|
|
|
// tempPasswordLen 是管理员重置密码时生成的临时明文长度(base64 字符数)。
|
|
// 16 字符远超 minPasswordLen,且使用与会话 token 相同的强随机源。
|
|
const tempPasswordLen = 16
|
|
|
|
// ListUsers 返回全部用户,供管理后台展示。调用方负责 admin 鉴权。
|
|
func (s *service) ListUsers(ctx context.Context) ([]*User, error) {
|
|
return s.repo.ListUsers(ctx)
|
|
}
|
|
|
|
// CreateUser 由管理员创建新账号。密码长度由 HashPassword 校验;email 冲突时
|
|
// Repository 返回 CodeConflict。
|
|
func (s *service) CreateUser(ctx context.Context, in CreateUserInput) (*User, error) {
|
|
hash, err := HashPassword(in.Password)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err, errs.CodeInvalidInput, "password")
|
|
}
|
|
u := &User{
|
|
ID: uuid.New(),
|
|
Email: in.Email,
|
|
PasswordHash: hash,
|
|
DisplayName: in.DisplayName,
|
|
IsAdmin: in.IsAdmin,
|
|
}
|
|
out, err := s.repo.CreateUser(ctx, u)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.record(ctx, &out.ID, "user.create", out.ID.String())
|
|
return out, nil
|
|
}
|
|
|
|
// UpdateProfile 修改用户 display_name。
|
|
func (s *service) UpdateProfile(ctx context.Context, id uuid.UUID, displayName string) (*User, error) {
|
|
out, err := s.repo.UpdateProfile(ctx, id, displayName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.record(ctx, &id, "user.update_profile", id.String())
|
|
return out, nil
|
|
}
|
|
|
|
// SetAdmin 调整用户管理员标志。两条护栏:actor 不能降级自己(防误操作把自己
|
|
// 锁在门外);不能降级最后一个启用管理员(防系统失去管理员)。
|
|
func (s *service) SetAdmin(ctx context.Context, actor, target uuid.UUID, isAdmin bool) (*User, error) {
|
|
if !isAdmin {
|
|
if actor == target {
|
|
return nil, errs.New(errs.CodeConflict, "不能取消自己的管理员权限")
|
|
}
|
|
if err := s.guardLastAdmin(ctx, target); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
out, err := s.repo.SetAdmin(ctx, target, isAdmin)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.record(ctx, &actor, "user.set_admin", target.String())
|
|
return out, nil
|
|
}
|
|
|
|
// SetEnabled 启用/禁用用户。禁用时撤销其全部会话使其立即下线,并施加与
|
|
// SetAdmin 同样的自锁/最后管理员护栏。
|
|
func (s *service) SetEnabled(ctx context.Context, actor, target uuid.UUID, enabled bool) (*User, error) {
|
|
if !enabled {
|
|
if actor == target {
|
|
return nil, errs.New(errs.CodeConflict, "不能禁用自己")
|
|
}
|
|
if err := s.guardLastAdmin(ctx, target); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
out, err := s.repo.SetEnabled(ctx, target, enabled)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !enabled {
|
|
// best-effort:撤销会话失败不应让禁用操作回滚(enabled=false 已生效,
|
|
// ResolveSession 会拒绝该用户)。
|
|
_ = s.repo.DeleteSessionsByUserID(context.WithoutCancel(ctx), target)
|
|
}
|
|
s.record(ctx, &actor, "user.set_enabled", target.String())
|
|
return out, nil
|
|
}
|
|
|
|
// AdminResetPassword 由管理员重置目标密码,生成强随机临时密码、撤销其全部
|
|
// 会话,并返回临时明文(仅此一次)。
|
|
func (s *service) AdminResetPassword(ctx context.Context, id uuid.UUID) (string, error) {
|
|
if _, err := s.repo.GetUserByID(ctx, id); err != nil {
|
|
return "", err
|
|
}
|
|
temp, err := randomPassword()
|
|
if err != nil {
|
|
return "", errs.Wrap(err, errs.CodeInternal, "generate temp password")
|
|
}
|
|
hash, err := HashPassword(temp)
|
|
if err != nil {
|
|
return "", errs.Wrap(err, errs.CodeInternal, "hash temp password")
|
|
}
|
|
if err := s.repo.UpdatePassword(ctx, id, hash); err != nil {
|
|
return "", err
|
|
}
|
|
_ = s.repo.DeleteSessionsByUserID(context.WithoutCancel(ctx), id)
|
|
s.record(ctx, &id, "user.reset_password", id.String())
|
|
return temp, nil
|
|
}
|
|
|
|
// ChangePassword 用户自助改密:校验旧密码后更新,并撤销其全部会话(含当前),
|
|
// 强制用新密码重新登录。
|
|
func (s *service) ChangePassword(ctx context.Context, id uuid.UUID, oldPassword, newPassword string) error {
|
|
u, err := s.repo.GetUserByID(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ok, err := VerifyPassword(oldPassword, u.PasswordHash)
|
|
if err != nil {
|
|
return errs.Wrap(err, errs.CodeInternal, "verify password")
|
|
}
|
|
if !ok {
|
|
return errs.New(errs.CodeUnauthorized, "旧密码错误")
|
|
}
|
|
hash, err := HashPassword(newPassword)
|
|
if err != nil {
|
|
return errs.Wrap(err, errs.CodeInvalidInput, "password")
|
|
}
|
|
if err := s.repo.UpdatePassword(ctx, id, hash); err != nil {
|
|
return err
|
|
}
|
|
_ = s.repo.DeleteSessionsByUserID(context.WithoutCancel(ctx), id)
|
|
s.record(ctx, &id, "user.change_password", id.String())
|
|
return nil
|
|
}
|
|
|
|
// DeleteUser 删除用户。同样施加自锁与最后管理员护栏。其会话经 DB 外键级联清除。
|
|
func (s *service) DeleteUser(ctx context.Context, actor, target uuid.UUID) error {
|
|
if actor == target {
|
|
return errs.New(errs.CodeConflict, "不能删除自己")
|
|
}
|
|
t, err := s.repo.GetUserByID(ctx, target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if t.IsAdmin && t.Enabled {
|
|
if err := s.guardLastAdmin(ctx, target); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := s.repo.DeleteUser(ctx, target); err != nil {
|
|
return err
|
|
}
|
|
s.record(ctx, &actor, "user.delete", target.String())
|
|
return nil
|
|
}
|
|
|
|
// guardLastAdmin 在降级/禁用/删除目标会导致“无启用管理员”时返回 CodeConflict。
|
|
// 仅当目标本身是启用管理员且系统启用管理员数 <= 1 时触发。
|
|
func (s *service) guardLastAdmin(ctx context.Context, target uuid.UUID) error {
|
|
t, err := s.repo.GetUserByID(ctx, target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !t.IsAdmin || !t.Enabled {
|
|
return nil
|
|
}
|
|
n, err := s.repo.CountAdmins(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n <= 1 {
|
|
return errs.New(errs.CodeConflict, "必须保留至少一个启用的管理员")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// record 是审计的薄封装:recorder 为 nil 时跳过;用 WithoutCancel 派生 ctx 保证
|
|
// 响应返回后审计仍能落库。
|
|
func (s *service) record(ctx context.Context, actor *uuid.UUID, action, targetID string) {
|
|
if s.audit == nil {
|
|
return
|
|
}
|
|
_ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{
|
|
UserID: actor,
|
|
Action: action,
|
|
TargetType: "user",
|
|
TargetID: targetID,
|
|
})
|
|
}
|
|
|
|
// randomPassword 生成一段强随机 base64 临时密码,用于管理员重置场景。
|
|
func randomPassword() (string, error) {
|
|
tok, _, err := NewSessionToken()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(tok) > tempPasswordLen {
|
|
tok = tok[:tempPasswordLen]
|
|
}
|
|
return tok, nil
|
|
}
|