Files
Agent-System/internal/auth/service.go
T
weichen 39f8c1d134 feat(auth): admin password reset API and credential guards
Implement T20-F4-B reset-password endpoint with one-time temp password,
audit table, frontend wiring, login error envelope parsing, and
CreateUser fail-closed for empty password hashes. Recovered test chenwei
via env write (password out of band).
2026-09-24 04:18:58 +00:00

473 lines
14 KiB
Go

package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"chat-agent-system/internal/config"
)
// RegisterRequest 注册入参 (仅用户名、密码、姓名为必填)
type RegisterRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Name string `json:"name"`
EmployeeID string `json:"employee_id,omitempty"`
Department string `json:"department,omitempty"`
Email string `json:"email,omitempty"`
}
// RegisterResponse 注册响应
type RegisterResponse struct {
Status AccountStatus `json:"status"`
Message string `json:"message"`
UserID int64 `json:"user_id"`
}
// CreateUserRequest 管理员手动创建账号入参
type CreateUserRequest struct {
Username string `json:"username"`
Name string `json:"name"`
InitialPassword string `json:"initial_password"`
RoleID string `json:"role_id"`
Position string `json:"position"`
Department string `json:"department,omitempty"`
EmployeeID string `json:"employee_id,omitempty"`
}
// Service 认证与账号治理业务逻辑
type Service struct {
store AuthStore
sessions SessionStore
cfg *config.Config
prefMu sync.RWMutex
prefs map[int64]map[string]any
}
// NewService 实例化认证服务;sessionStore 为空时使用进程内 MemorySessionStore。
func NewService(store AuthStore, sessionStore SessionStore, cfg *config.Config) *Service {
if store == nil {
store = NewMemoryStore(cfg)
}
if sessionStore == nil {
sessionStore = NewMemorySessionStore()
}
return &Service{
store: store,
sessions: sessionStore,
cfg: cfg,
}
}
// generateToken 生成随机安全会话 Token
func generateToken() string {
b := make([]byte, 24)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// Login 登录鉴权
func (s *Service) Login(ctx context.Context, username, password string) (*SessionInfo, error) {
username = strings.TrimSpace(username)
if username == "" || password == "" {
return nil, errors.New("用户名或密码不能为空")
}
user, err := s.store.GetUserByUsername(ctx, username)
if err != nil {
return nil, errors.New("用户名或密码错误")
}
if strings.TrimSpace(user.PasswordHash) == "" {
slog.Warn("auth_login_missing_password_hash", "user_id", user.ID)
return nil, errors.New("用户名或密码错误")
}
if !CheckPassword(user.PasswordHash, password) {
return nil, errors.New("用户名或密码错误")
}
if user.Status == StatusPendingApproval {
return nil, errors.New("账号正在等待管理员审核中,暂无法登录")
}
if user.Status == StatusDisabled {
return nil, errors.New("账号已被停用,禁止登录")
}
token := generateToken()
tokenHash := HashToken(token)
if err := s.sessions.CreateSession(ctx, user.ID, tokenHash, SessionMeta{}); err != nil {
return nil, fmt.Errorf("创建会话失败: %w", err)
}
// 敏感信息脱敏
user.PasswordHash = ""
env := "dev"
if s.cfg != nil && s.cfg.Env != "" {
env = s.cfg.Env
}
return &SessionInfo{
Token: token,
User: user,
MustChangePassword: user.MustChangePassword,
Env: env,
}, nil
}
// Register 用户自主注册 (职位严格由系统固定为访客待分配)
func (s *Service) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) {
req.Username = strings.TrimSpace(req.Username)
req.Name = strings.TrimSpace(req.Name)
if req.Username == "" || req.Password == "" || req.Name == "" {
return nil, errors.New("用户名、密码与真实姓名均为必填项")
}
sysCfg, err := s.store.GetConfig(ctx)
if err != nil {
return nil, fmt.Errorf("读取系统准入策略失败: %w", err)
}
if !sysCfg.AllowRegistration {
return nil, errors.New("系统当前已暂停自主注册,请联系系统管理员分配账号")
}
passHash, err := HashPassword(req.Password)
if err != nil {
return nil, fmt.Errorf("密码哈希处理失败: %w", err)
}
status := StatusActive
msg := "注册成功,已激活,请返回登录"
if sysCfg.RequireApproval {
status = StatusPendingApproval
msg = "注册成功!您的账号正在等待管理员审核与职位指派,审核通过后方可登录。"
}
account := &UserAccount{
Username: req.Username,
Name: req.Name,
PasswordHash: passHash,
Status: status,
MustChangePassword: false,
RoleID: "role-guest",
Position: "访客/待指派",
EmployeeID: req.EmployeeID,
Department: req.Department,
Email: req.Email,
}
created, err := s.store.CreateUser(ctx, account)
if err != nil {
return nil, err
}
return &RegisterResponse{
Status: status,
Message: msg,
UserID: created.ID,
}, nil
}
// ChangePassword 修改密码 (处理普通改密与首次登录强制改密)
func (s *Service) ChangePassword(ctx context.Context, userID int64, oldPass, newPass string) error {
if strings.TrimSpace(newPass) == "" {
return errors.New("新密码不能为空")
}
user, err := s.store.GetUserByID(ctx, userID)
if err != nil {
return fmt.Errorf("用户不存在: %w", err)
}
if !CheckPassword(user.PasswordHash, oldPass) {
return errors.New("原密码不正确")
}
newHash, err := HashPassword(newPass)
if err != nil {
return fmt.Errorf("新密码加密失败: %w", err)
}
_, err = s.store.UpdateUserPassword(ctx, userID, newHash, false)
return err
}
// AdminResetPasswordResult is returned once to the calling admin; plaintext must not be logged.
type AdminResetPasswordResult struct {
TempPassword string `json:"temp_password"`
MustChangePassword bool `json:"must_change_password"`
UserID int64 `json:"user_id"`
}
// AdminResetPassword generates a temporary password, persists its hash with
// must_change_password=true, revokes existing sessions, and records an audit row.
func (s *Service) AdminResetPassword(ctx context.Context, targetID, actorID int64, requestID string) (*AdminResetPasswordResult, error) {
if targetID <= 0 {
return nil, errors.New("无效的用户ID")
}
if actorID <= 0 {
return nil, errors.New("无效的操作者")
}
if _, err := s.store.GetUserByID(ctx, targetID); err != nil {
return nil, errors.New("用户不存在")
}
temp, err := GenerateTempPassword(16)
if err != nil {
return nil, fmt.Errorf("生成临时口令失败: %w", err)
}
hash, err := HashPassword(temp)
if err != nil {
return nil, fmt.Errorf("临时口令加密失败: %w", err)
}
if _, err := s.store.UpdateUserPassword(ctx, targetID, hash, true); err != nil {
return nil, err
}
_ = s.sessions.RevokeAllForUser(ctx, targetID)
if err := s.store.RecordPasswordResetAudit(ctx, targetID, actorID, requestID); err != nil {
return nil, fmt.Errorf("记录重置审计失败: %w", err)
}
return &AdminResetPasswordResult{
TempPassword: temp,
MustChangePassword: true,
UserID: targetID,
}, nil
}
// GetUserByToken 根据会话 Token 查找登录用户
func (s *Service) GetUserByToken(ctx context.Context, token string) (*UserAccount, error) {
userID, err := s.sessions.ResolveUserID(ctx, HashToken(token))
if err != nil {
return nil, errors.New("会话已过期或未登录")
}
user, err := s.store.GetUserByID(ctx, userID)
if err != nil {
return nil, err
}
if user.Status == StatusDisabled || user.Status == StatusPendingApproval {
_ = s.sessions.RevokeByTokenHash(ctx, HashToken(token))
return nil, errors.New("会话已过期或未登录")
}
user.PasswordHash = ""
return user, nil
}
// GetUserByID 根据用户 ID 直接查询用户信息 (安全过滤密码哈希)
func (s *Service) GetUserByID(ctx context.Context, userID int64) (*UserAccount, error) {
user, err := s.store.GetUserByID(ctx, userID)
if err != nil {
return nil, err
}
cpy := *user
cpy.PasswordHash = ""
return &cpy, nil
}
// Logout 注销会话
func (s *Service) Logout(token string) {
_ = s.sessions.RevokeByTokenHash(context.Background(), HashToken(token))
}
// ListUsers 列出所有用户
func (s *Service) ListUsers(ctx context.Context) ([]*UserAccount, error) {
return s.store.ListUsers(ctx)
}
// CreateUserByAdmin 管理员手动开通账号 (自动标记 mustChangePassword = true)
func (s *Service) CreateUserByAdmin(ctx context.Context, req CreateUserRequest) (*UserAccount, error) {
if req.Username == "" || req.Name == "" || req.InitialPassword == "" {
return nil, errors.New("用户名、姓名与初始密码为必填")
}
hash, err := HashPassword(req.InitialPassword)
if err != nil {
return nil, err
}
roleID := req.RoleID
if roleID == "" {
roleID = "role-engineer"
}
position := req.Position
if position == "" {
position = "方案工程师"
}
account := &UserAccount{
Username: req.Username,
Name: req.Name,
PasswordHash: hash,
Status: StatusActive,
MustChangePassword: true, // 核心约束:管理员创建的账号首次登录必须修改密码
RoleID: roleID,
Position: position,
Department: req.Department,
EmployeeID: req.EmployeeID,
}
created, err := s.store.CreateUser(ctx, account)
if err != nil {
return nil, err
}
created.PasswordHash = ""
return created, nil
}
// UpdateUserRole 管理员为用户指定职位与角色
func (s *Service) UpdateUserRole(ctx context.Context, userID int64, roleID, position string) (*UserAccount, error) {
u, err := s.store.UpdateUserRole(ctx, userID, roleID, position)
if err != nil {
return nil, err
}
u.PasswordHash = ""
return u, nil
}
// UpdateUserStatus 管理员审批通过/停用账号
func (s *Service) UpdateUserStatus(ctx context.Context, userID int64, status AccountStatus) (*UserAccount, error) {
u, err := s.store.UpdateUserStatus(ctx, userID, status)
if err != nil {
return nil, err
}
if status == StatusDisabled {
_ = s.sessions.RevokeAllForUser(ctx, userID)
}
u.PasswordHash = ""
return u, nil
}
// ListUserSessions 列出当前用户活跃会话。
func (s *Service) ListUserSessions(ctx context.Context, userID int64, currentToken string) ([]SessionRecord, error) {
currentHash := ""
if currentToken != "" {
currentHash = HashToken(currentToken)
}
return s.sessions.ListActiveSessions(ctx, userID, currentHash)
}
// RevokeUserSession 撤销指定会话(不可跨用户)。
func (s *Service) RevokeUserSession(ctx context.Context, userID int64, sessionID string) error {
return s.sessions.RevokeSessionByID(ctx, userID, sessionID)
}
// GetConfig 获取准入策略
func (s *Service) GetConfig(ctx context.Context) (SystemAuthConfig, error) {
return s.store.GetConfig(ctx)
}
// UpdateConfig 更新准入策略
func (s *Service) UpdateConfig(ctx context.Context, cfg SystemAuthConfig) error {
return s.store.UpdateConfig(ctx, cfg)
}
// UpdateUserProfile 当前用户自助更新展示资料;清除管理员代改标记。
func (s *Service) UpdateUserProfile(ctx context.Context, userID int64, patch ProfilePatch) (*UserAccount, error) {
if userID <= 0 {
return nil, errors.New("invalid user")
}
existing, err := s.store.GetUserByID(ctx, userID)
if err != nil {
return nil, err
}
if err := applyProfilePatch(existing, patch, false); err != nil {
return nil, err
}
updated, err := s.store.UpdateUser(ctx, existing, userID)
if err != nil {
return nil, err
}
updated.PasswordHash = ""
return updated, nil
}
// AdminUpdateUserProfile 管理员更新他人展示资料;记录代改人。
func (s *Service) AdminUpdateUserProfile(ctx context.Context, targetID int64, patch ProfilePatch, actorID int64) (*UserAccount, error) {
if targetID <= 0 || actorID <= 0 {
return nil, errors.New("invalid user")
}
if patch.Name == nil && patch.Department == nil && patch.Email == nil && patch.EmployeeID == nil {
return nil, errors.New("至少提供一个可更新字段")
}
existing, err := s.store.GetUserByID(ctx, targetID)
if err != nil {
return nil, err
}
if err := applyProfilePatch(existing, patch, true); err != nil {
return nil, err
}
updated, err := s.store.UpdateUser(ctx, existing, actorID)
if err != nil {
return nil, err
}
updated.PasswordHash = ""
return updated, nil
}
func applyProfilePatch(user *UserAccount, patch ProfilePatch, adminMode bool) error {
if patch.Name != nil {
name := strings.TrimSpace(*patch.Name)
if name == "" {
return errors.New("姓名不能为空")
}
user.Name = name
}
if patch.Email != nil {
user.Email = strings.TrimSpace(*patch.Email)
}
if patch.Department != nil {
user.Department = strings.TrimSpace(*patch.Department)
}
if adminMode && patch.EmployeeID != nil {
user.EmployeeID = strings.TrimSpace(*patch.EmployeeID)
}
if !adminMode {
if patch.Locale != nil {
user.Locale = strings.TrimSpace(*patch.Locale)
}
if patch.Timezone != nil {
user.Timezone = strings.TrimSpace(*patch.Timezone)
}
}
return nil
}
// ListUserDirectory 返回 active 用户轻量目录。
func (s *Service) ListUserDirectory(ctx context.Context) ([]UserDirectoryEntry, error) {
return s.store.ListActiveUserDirectory(ctx)
}
// GetUserPreferences 读取用户偏好(内存/后续 PG user_preferences)。
func (s *Service) GetUserPreferences(ctx context.Context, userID int64) (map[string]any, error) {
s.prefMu.RLock()
defer s.prefMu.RUnlock()
if s.prefs == nil {
return map[string]any{}, nil
}
if p, ok := s.prefs[userID]; ok {
return p, nil
}
return map[string]any{}, nil
}
// SaveUserPreferences 保存用户偏好。
func (s *Service) SaveUserPreferences(ctx context.Context, userID int64, body map[string]any) (map[string]any, error) {
if userID <= 0 {
return nil, errors.New("invalid user id")
}
s.prefMu.Lock()
defer s.prefMu.Unlock()
if s.prefs == nil {
s.prefs = make(map[int64]map[string]any)
}
s.prefs[userID] = body
return body, nil
}