You've already forked agentic-coding-workflow
修改
This commit is contained in:
@@ -177,3 +177,202 @@ func (s *service) Bootstrap(ctx context.Context, email, password string) (*User,
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user