feat(api): add auth and admin management endpoints
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user