From d548f773bc6133d97465a17a20739b8ea5be9ca8 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Tue, 8 Sep 2026 23:19:04 +0800 Subject: [PATCH] feat(api): add auth and admin management endpoints Co-authored-by: Cursor --- internal/auth/service.go | 290 ++++++++++++++++++++++++++ internal/server/auth_handlers.go | 290 ++++++++++++++++++++++++++ internal/server/auth_handlers_test.go | 184 ++++++++++++++++ internal/server/server.go | 25 ++- 4 files changed, 788 insertions(+), 1 deletion(-) create mode 100644 internal/auth/service.go create mode 100644 internal/server/auth_handlers.go create mode 100644 internal/server/auth_handlers_test.go diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 00000000..421c99a3 --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,290 @@ +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 + cfg *config.Config + tokensMu sync.RWMutex + tokens map[string]int64 // token -> userID +} + +// NewService 实例化认证服务 +func NewService(store AuthStore, cfg *config.Config) *Service { + if store == nil { + store = NewMemoryStore(cfg) + } + return &Service{ + store: store, + cfg: cfg, + tokens: make(map[string]int64), + } +} + +// 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() + s.tokensMu.Lock() + s.tokens[token] = user.ID + s.tokensMu.Unlock() + + // 敏感信息脱敏 + 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, + AllowDevSwitch: env == "dev", + }, 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) { + s.tokensMu.RLock() + userID, ok := s.tokens[token] + s.tokensMu.RUnlock() + + if !ok { + return nil, errors.New("会话已过期或未登录") + } + + user, err := s.store.GetUserByID(ctx, userID) + if err != nil { + return nil, err + } + user.PasswordHash = "" + return user, nil +} + +// Logout 注销会话 +func (s *Service) Logout(token string) { + s.tokensMu.Lock() + defer s.tokensMu.Unlock() + delete(s.tokens, 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 + } + u.PasswordHash = "" + return u, nil +} + +// 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) +} diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go new file mode 100644 index 00000000..040f8b84 --- /dev/null +++ b/internal/server/auth_handlers.go @@ -0,0 +1,290 @@ +package server + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "chat-agent-system/internal/auth" +) + +// extractBearerToken 从 Authorization 请求头解析 Bearer Token +func extractBearerToken(r *http.Request) string { + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + return strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + } + return "" +} + +// authenticateUser 鉴权中间件辅助方法 +func (s *Server) authenticateUser(r *http.Request) (*auth.UserAccount, error) { + token := extractBearerToken(r) + if token == "" { + token = r.Header.Get("X-Session-Token") + } + return s.authSvc.GetUserByToken(r.Context(), token) +} + +// handleAuthLogin 用户名密码登录 +func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest) + return + } + + session, err := s.authSvc.Login(r.Context(), req.Username, req.Password) + if err != nil { + errMsg := err.Error() + if strings.Contains(errMsg, "审核") { + http.Error(w, errMsg, http.StatusForbidden) + return + } + if strings.Contains(errMsg, "停用") { + http.Error(w, errMsg, http.StatusForbidden) + return + } + http.Error(w, errMsg, http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(session) +} + +// handleAuthRegister 用户自主注册 (职位严格由系统固定为访客) +func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) { + var req auth.RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest) + return + } + + resp, err := s.authSvc.Register(r.Context(), req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(resp) +} + +// handleAuthLogout 注销登录 +func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { + token := extractBearerToken(r) + if token != "" { + s.authSvc.Logout(token) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]bool{"success": true}) +} + +// handleAuthChangePassword 修改密码 (支持普通用户与首次强制改密) +func (s *Server) handleAuthChangePassword(w http.ResponseWriter, r *http.Request) { + user, err := s.authenticateUser(r) + if err != nil { + http.Error(w, "未登录或登录已过期", http.StatusUnauthorized) + return + } + + var req struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest) + return + } + + if err := s.authSvc.ChangePassword(r.Context(), user.ID, req.OldPassword, req.NewPassword); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]bool{"success": true}) +} + +// handleAuthMe 获取当前登录用户身份 +func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) { + user, err := s.authenticateUser(r) + if err != nil { + http.Error(w, "未登录或登录已过期", http.StatusUnauthorized) + return + } + + env := "dev" + if s.cfg != nil && s.cfg.Env != "" { + env = s.cfg.Env + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "user": user, + "must_change_password": user.MustChangePassword, + "env": env, + "allow_dev_switch": env == "dev", + }) +} + +// handleAdminUsers 管理员查询或新建用户 +func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { + user, err := s.authenticateUser(r) + if err != nil || user.RoleID != "role-admin" { + http.Error(w, "权限不足,仅系统管理员可访问", http.StatusForbidden) + return + } + + if r.Method == http.MethodGet { + users, err := s.authSvc.ListUsers(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"users": users}) + return + } + + if r.Method == http.MethodPost { + var req auth.CreateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest) + return + } + created, err := s.authSvc.CreateUserByAdmin(r.Context(), req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(created) + return + } + + http.Error(w, "不支持的请求方法", http.StatusMethodNotAllowed) +} + +// handleAdminUpdateRole 管理员为用户指定职位与角色 +func (s *Server) handleAdminUpdateRole(w http.ResponseWriter, r *http.Request) { + user, err := s.authenticateUser(r) + if err != nil || user.RoleID != "role-admin" { + http.Error(w, "权限不足,仅系统管理员可访问", http.StatusForbidden) + return + } + + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) < 5 { + http.Error(w, "无效的用户路径参数", http.StatusBadRequest) + return + } + targetID, err := strconv.ParseInt(parts[3], 10, 64) + if err != nil { + http.Error(w, "无效的用户ID", http.StatusBadRequest) + return + } + + var req struct { + RoleID string `json:"role_id"` + Position string `json:"position"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "无效的请求体", http.StatusBadRequest) + return + } + + updated, err := s.authSvc.UpdateUserRole(r.Context(), targetID, req.RoleID, req.Position) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(updated) +} + +// handleAdminUpdateStatus 管理员审批通过/停用账号 +func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request) { + user, err := s.authenticateUser(r) + if err != nil || user.RoleID != "role-admin" { + http.Error(w, "权限不足,仅系统管理员可访问", http.StatusForbidden) + return + } + + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) < 5 { + http.Error(w, "无效的用户路径参数", http.StatusBadRequest) + return + } + targetID, err := strconv.ParseInt(parts[3], 10, 64) + if err != nil { + http.Error(w, "无效的用户ID", http.StatusBadRequest) + return + } + + var req struct { + Status auth.AccountStatus `json:"status"` + RoleID string `json:"role_id,omitempty"` + Position string `json:"position,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "无效的请求体", http.StatusBadRequest) + return + } + + if req.RoleID != "" { + _, _ = s.authSvc.UpdateUserRole(r.Context(), targetID, req.RoleID, req.Position) + } + + updated, err := s.authSvc.UpdateUserStatus(r.Context(), targetID, req.Status) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(updated) +} + +// handleAdminConfig 获取或更新系统准入配置 +func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) { + user, err := s.authenticateUser(r) + if err != nil || user.RoleID != "role-admin" { + http.Error(w, "权限不足,仅系统管理员可访问", http.StatusForbidden) + return + } + + if r.Method == http.MethodGet { + cfg, err := s.authSvc.GetConfig(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(cfg) + return + } + + if r.Method == http.MethodPut || r.Method == http.MethodPost { + var req auth.SystemAuthConfig + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "无效的请求体", http.StatusBadRequest) + return + } + if err := s.authSvc.UpdateConfig(r.Context(), req); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(req) + return + } + + http.Error(w, "不支持的方法", http.StatusMethodNotAllowed) +} diff --git a/internal/server/auth_handlers_test.go b/internal/server/auth_handlers_test.go new file mode 100644 index 00000000..942deb99 --- /dev/null +++ b/internal/server/auth_handlers_test.go @@ -0,0 +1,184 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "chat-agent-system/internal/auth" + "chat-agent-system/internal/config" + "chat-agent-system/internal/server" +) + +func setupTestServer() (*httptest.Server, *auth.Service) { + cfg := &config.Config{ + Env: "dev", + AdminUsername: "admin", + AdminPassword: "admin123", + AuthRegistrationEnabled: true, + AuthRequireApproval: false, + } + srv := server.NewServer(cfg, nil) + ts := httptest.NewServer(srv.Routes()) + return ts, srv.AuthService() +} + +func TestAuthHandlers_LoginAndMe(t *testing.T) { + ts, _ := setupTestServer() + defer ts.Close() + + // 1. Admin login with .env credentials + loginReq := map[string]string{ + "username": "admin", + "password": "admin123", + } + body, _ := json.Marshal(loginReq) + resp, err := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("Login request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("Expected 200 OK, got %d", resp.StatusCode) + } + + var session auth.SessionInfo + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + t.Fatalf("Failed to decode session: %v", err) + } + if session.User == nil || session.User.RoleID != "role-admin" { + t.Errorf("Expected role-admin, got %+v", session.User) + } + if !session.AllowDevSwitch { + t.Errorf("Expected AllowDevSwitch=true in dev env") + } + + // 2. Fetch /api/v1/auth/me with Authorization header + req, _ := http.NewRequest("GET", ts.URL+"/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+session.Token) + client := &http.Client{} + meResp, err := client.Do(req) + if err != nil { + t.Fatalf("Me request failed: %v", err) + } + defer meResp.Body.Close() + + if meResp.StatusCode != http.StatusOK { + t.Fatalf("Expected 200 OK for /auth/me, got %d", meResp.StatusCode) + } +} + +func TestAuthHandlers_RegisterAndApproval(t *testing.T) { + ts, authSvc := setupTestServer() + defer ts.Close() + + // 1. Turn on RequireApproval + _ = authSvc.UpdateConfig(t.Context(), auth.SystemAuthConfig{ + AllowRegistration: true, + RequireApproval: true, + }) + + // 2. Register user + regReq := map[string]string{ + "username": "new_doctor", + "password": "DoctorPass123", + "name": "王医生", + } + body, _ := json.Marshal(regReq) + resp, err := http.Post(ts.URL+"/api/v1/auth/register", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("Register request failed: %v", err) + } + defer resp.Body.Close() + + var regResp map[string]any + _ = json.NewDecoder(resp.Body).Decode(®Resp) + if regResp["status"] != "pending_approval" { + t.Errorf("Expected pending_approval, got %v", regResp["status"]) + } + + // 3. Trying to login should fail because of pending approval + loginBody, _ := json.Marshal(map[string]string{ + "username": "new_doctor", + "password": "DoctorPass123", + }) + loginResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(loginBody)) + if loginResp.StatusCode != http.StatusForbidden { + t.Errorf("Expected 403 Forbidden for pending user, got %d", loginResp.StatusCode) + } +} + +func TestAuthHandlers_AdminCreateUser_MustChangePassword(t *testing.T) { + ts, _ := setupTestServer() + defer ts.Close() + + // 1. Admin login to obtain token + loginBody, _ := json.Marshal(map[string]string{ + "username": "admin", + "password": "admin123", + }) + resp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(loginBody)) + var adminSession auth.SessionInfo + _ = json.NewDecoder(resp.Body).Decode(&adminSession) + + // 2. Admin creates a new user + createUserReq := map[string]string{ + "username": "qa_engineer", + "name": "周质量", + "initial_password": "TempPassword123", + "role_id": "role-director", + "position": "质量总监", + } + reqBody, _ := json.Marshal(createUserReq) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/admin/users", bytes.NewReader(reqBody)) + req.Header.Set("Authorization", "Bearer "+adminSession.Token) + client := &http.Client{} + createResp, err := client.Do(req) + if err != nil { + t.Fatalf("Admin create user request failed: %v", err) + } + defer createResp.Body.Close() + + if createResp.StatusCode != http.StatusOK { + t.Fatalf("Expected 200 OK, got %d", createResp.StatusCode) + } + + // 3. New user logs in with initial password -> must_change_password must be true + userLoginBody, _ := json.Marshal(map[string]string{ + "username": "qa_engineer", + "password": "TempPassword123", + }) + uResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(userLoginBody)) + var uSession auth.SessionInfo + _ = json.NewDecoder(uResp.Body).Decode(&uSession) + if !uSession.MustChangePassword { + t.Errorf("Expected MustChangePassword to be true for admin created user") + } + + // 4. Change password + changeBody, _ := json.Marshal(map[string]string{ + "old_password": "TempPassword123", + "new_password": "NewPermanentPass888", + }) + chReq, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/change-password", bytes.NewReader(changeBody)) + chReq.Header.Set("Authorization", "Bearer "+uSession.Token) + chResp, err := client.Do(chReq) + if err != nil || chResp.StatusCode != http.StatusOK { + t.Fatalf("Change password failed: %v", err) + } + + // 5. Login again with new password -> must_change_password must be false + newLoginBody, _ := json.Marshal(map[string]string{ + "username": "qa_engineer", + "password": "NewPermanentPass888", + }) + nResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(newLoginBody)) + var nSession auth.SessionInfo + _ = json.NewDecoder(nResp.Body).Decode(&nSession) + if nSession.MustChangePassword { + t.Errorf("Expected MustChangePassword to be false after changing password") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index cfcd6ed9..ea1ba02a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "chat-agent-system/internal/auth" "chat-agent-system/internal/bizdev" "chat-agent-system/internal/chat" "chat-agent-system/internal/config" @@ -23,6 +24,7 @@ type Server struct { svc *bizdev.Service rec intent.Recognizer engine *coordinator.CoordinatorEngine + authSvc *auth.Service sessions map[string]*chat.Session sessionsMu sync.RWMutex httpServer *http.Server @@ -39,17 +41,25 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server { rec := intent.NewRecognizer() coordEngine := coordinator.NewCoordinatorEngine(svc) + authStore := auth.NewMemoryStore(cfg) + authSvc := auth.NewService(authStore, cfg) s := &Server{ cfg: cfg, svc: svc, rec: rec, engine: coordEngine, + authSvc: authSvc, sessions: make(map[string]*chat.Session), } return s } +// AuthService 获取认证服务 +func (s *Server) AuthService() *auth.Service { + return s.authSvc +} + // Routes 构建 HTTP 路由分发器 func (s *Server) Routes() http.Handler { mux := http.NewServeMux() @@ -94,7 +104,20 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /api/v1/docgov/comments", s.handleDocGovAddComment) mux.HandleFunc("GET /api/v1/docgov/audit-logs", s.handleDocGovListAuditLogs) - // 9. 现代 SPA 前端页面与静态资产托管 + // 9. 用户认证、安全改密与超级管理员后台接口 + mux.HandleFunc("POST /api/v1/auth/login", s.handleAuthLogin) + mux.HandleFunc("POST /api/v1/auth/register", s.handleAuthRegister) + mux.HandleFunc("POST /api/v1/auth/logout", s.handleAuthLogout) + mux.HandleFunc("POST /api/v1/auth/change-password", s.handleAuthChangePassword) + mux.HandleFunc("GET /api/v1/auth/me", s.handleAuthMe) + mux.HandleFunc("GET /api/v1/admin/users", s.handleAdminUsers) + mux.HandleFunc("POST /api/v1/admin/users", s.handleAdminUsers) + mux.HandleFunc("PUT /api/v1/admin/users/{id}/role", s.handleAdminUpdateRole) + mux.HandleFunc("PUT /api/v1/admin/users/{id}/status", s.handleAdminUpdateStatus) + mux.HandleFunc("GET /api/v1/admin/config", s.handleAdminConfig) + mux.HandleFunc("PUT /api/v1/admin/config", s.handleAdminConfig) + + // 10. 现代 SPA 前端页面与静态资产托管 mux.HandleFunc("GET /", s.serveSPA) return s.corsMiddleware(mux)