Add a login-visible user directory API and admin/self profile update paths so project duty selectors stop falling back to "用户 N", and so users can persist display fields with an admin-edit tip.
425 lines
12 KiB
Go
425 lines
12 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"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 !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
|
|
}
|
|
|
|
// 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
|
|
}
|