478 lines
14 KiB
Go
478 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"chat-agent-system/internal/auth"
|
|
"chat-agent-system/internal/authz"
|
|
"chat-agent-system/internal/httpx"
|
|
)
|
|
|
|
// authIsInfrastructureError classifies DB/driver/tx failures that must not be
|
|
// reported as client VALIDATION (400).
|
|
func authIsInfrastructureError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := strings.ToLower(err.Error())
|
|
for _, needle := range []string{
|
|
"insert user:", "insert credentials:", "insert role binding:",
|
|
"commit unexpectedly", "rollback", "pgx", "pq:", "sqlstate",
|
|
"connection refused", "connection reset", "context deadline",
|
|
} {
|
|
if strings.Contains(msg, needle) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
if token != "" {
|
|
user, err := s.authSvc().GetUserByToken(r.Context(), token)
|
|
if err == nil && user != nil {
|
|
return user, nil
|
|
}
|
|
}
|
|
return nil, errors.New("missing auth token")
|
|
}
|
|
|
|
// handleAuthLogin 用户名密码登录(限流:连续爆破触发 429)
|
|
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 {
|
|
writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
|
|
return
|
|
}
|
|
|
|
if s.loginLimiter != nil {
|
|
key := s.loginLimiter.key(r, req.Username)
|
|
if !s.loginLimiter.allow(key) {
|
|
writeJSONResponse(w, http.StatusTooManyRequests, map[string]string{
|
|
"error_code": "ERR_LOGIN_RATE_LIMITED",
|
|
"message": "登录尝试过于频繁,请稍后再试",
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
session, err := s.authSvc().Login(r.Context(), req.Username, req.Password)
|
|
if err != nil {
|
|
errMsg := err.Error()
|
|
if strings.Contains(errMsg, "审核") {
|
|
writeSafeError(w, r, http.StatusForbidden, errMsg)
|
|
return
|
|
}
|
|
if strings.Contains(errMsg, "停用") {
|
|
writeSafeError(w, r, http.StatusForbidden, errMsg)
|
|
return
|
|
}
|
|
status := http.StatusUnauthorized
|
|
if authIsInfrastructureError(err) {
|
|
status = http.StatusInternalServerError
|
|
slog.Error("auth_login_failed", "request_id", httpx.RequestID(r), "err", err)
|
|
}
|
|
writeSafeError(w, r, status, errMsg)
|
|
return
|
|
}
|
|
|
|
if s.loginLimiter != nil {
|
|
s.loginLimiter.reset(s.loginLimiter.key(r, req.Username))
|
|
}
|
|
|
|
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 {
|
|
writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
|
|
return
|
|
}
|
|
|
|
if s.loginLimiter != nil && !s.loginLimiter.allow(s.loginLimiter.key(r, req.Username)) {
|
|
writeJSONResponse(w, http.StatusTooManyRequests, map[string]string{
|
|
"error_code": "ERR_REGISTER_RATE_LIMITED",
|
|
"message": "注册尝试过于频繁,请稍后再试",
|
|
})
|
|
return
|
|
}
|
|
|
|
resp, err := s.authSvc().Register(r.Context(), req)
|
|
if err != nil {
|
|
status := http.StatusBadRequest
|
|
if authIsInfrastructureError(err) {
|
|
status = http.StatusInternalServerError
|
|
slog.Error("auth_register_failed", "request_id", httpx.RequestID(r), "err", err)
|
|
}
|
|
writeDomainError(w, r, status, err)
|
|
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 {
|
|
writeSafeError(w, r, 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 {
|
|
writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
|
|
return
|
|
}
|
|
|
|
if err := s.authSvc().ChangePassword(r.Context(), user.ID, req.OldPassword, req.NewPassword); err != nil {
|
|
writeDomainError(w, r, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
|
}
|
|
|
|
// handleAuthMe 返回本请求中间件已经核过的用户。
|
|
// 不再第二次 ResolveUserID + GetUserByID。撤销和停用在下一次请求的中间件生效,不把身份缓存在请求之外。
|
|
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
|
|
user := s.principalFromRequest(r)
|
|
if user == nil {
|
|
var err error
|
|
user, err = s.authenticateUser(r)
|
|
if err != nil || user == nil {
|
|
writeSafeError(w, r, http.StatusUnauthorized, "未登录或登录已过期")
|
|
return
|
|
}
|
|
}
|
|
|
|
env := "dev"
|
|
if s.cfg != nil && s.cfg.Env != "" {
|
|
env = s.cfg.Env
|
|
}
|
|
|
|
caps := s.resolveUserCapabilities(r.Context(), user)
|
|
|
|
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,
|
|
"capabilities": caps,
|
|
})
|
|
}
|
|
|
|
func (s *Server) resolveUserCapabilities(ctx context.Context, user *auth.UserAccount) []string {
|
|
caps := []string{}
|
|
if s.authzSvc() != nil {
|
|
if listed, capErr := s.authzSvc().ListCapabilities(ctx, user.ID); capErr == nil && len(listed) > 0 {
|
|
return listed
|
|
}
|
|
}
|
|
if user != nil && user.RoleID != "" {
|
|
if fallback := authz.FallbackCapabilitiesByRoleID(user.RoleID); len(fallback) > 0 {
|
|
caps = fallback
|
|
}
|
|
}
|
|
return caps
|
|
}
|
|
|
|
// handleAuthSessions 列出或撤销当前用户会话。
|
|
func (s *Server) handleAuthSessions(w http.ResponseWriter, r *http.Request) {
|
|
user, err := s.authenticateUser(r)
|
|
if err != nil {
|
|
writeSafeError(w, r, http.StatusUnauthorized, "未登录或登录已过期")
|
|
return
|
|
}
|
|
if r.Method == http.MethodGet {
|
|
token := extractBearerToken(r)
|
|
sessions, listErr := s.authSvc().ListUserSessions(r.Context(), user.ID, token)
|
|
if listErr != nil {
|
|
writeDomainError(w, r, http.StatusInternalServerError, listErr)
|
|
return
|
|
}
|
|
if sessions == nil {
|
|
sessions = []auth.SessionRecord{}
|
|
}
|
|
writeJSONResponse(w, http.StatusOK, map[string]any{"sessions": sessions})
|
|
return
|
|
}
|
|
writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的请求方法")
|
|
}
|
|
|
|
func (s *Server) handleAuthSessionByID(w http.ResponseWriter, r *http.Request) {
|
|
user, err := s.authenticateUser(r)
|
|
if err != nil {
|
|
writeSafeError(w, r, http.StatusUnauthorized, "未登录或登录已过期")
|
|
return
|
|
}
|
|
if r.Method != http.MethodDelete {
|
|
writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的请求方法")
|
|
return
|
|
}
|
|
sessionID := strings.TrimSpace(r.PathValue("id"))
|
|
if sessionID == "" {
|
|
writeSafeError(w, r, http.StatusBadRequest, "无效的会话 ID")
|
|
return
|
|
}
|
|
if err := s.authSvc().RevokeUserSession(r.Context(), user.ID, sessionID); err != nil {
|
|
writeSafeError(w, r, http.StatusNotFound, "会话不存在或已失效")
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusOK, map[string]bool{"success": true})
|
|
}
|
|
|
|
// handleAdminUsers 管理员查询或新建用户
|
|
func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
|
return
|
|
}
|
|
|
|
if r.Method == http.MethodGet {
|
|
users, err := s.authSvc().ListUsers(r.Context())
|
|
if err != nil {
|
|
writeDomainError(w, r, http.StatusInternalServerError, err)
|
|
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 {
|
|
writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
|
|
return
|
|
}
|
|
if roleID := strings.TrimSpace(req.RoleID); roleID != "" && s.authzSvc() != nil && s.authzSvc().Enabled() {
|
|
roles, listErr := s.authzSvc().ListRoles(r.Context())
|
|
if listErr != nil {
|
|
writeDomainError(w, r, http.StatusInternalServerError, listErr)
|
|
return
|
|
}
|
|
found := false
|
|
for _, role := range roles {
|
|
if role.ID == roleID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
writeSafeError(w, r, http.StatusBadRequest, "指定的角色不存在或已停用")
|
|
return
|
|
}
|
|
}
|
|
created, err := s.authSvc().CreateUserByAdmin(r.Context(), req)
|
|
if err != nil {
|
|
status := http.StatusBadRequest
|
|
if authIsInfrastructureError(err) {
|
|
status = http.StatusInternalServerError
|
|
}
|
|
writeDomainError(w, r, status, err)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(created)
|
|
return
|
|
}
|
|
|
|
writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的请求方法")
|
|
}
|
|
|
|
// handleAdminUpdateRole 管理员为用户指定职位与角色
|
|
func (s *Server) handleAdminUpdateRole(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
|
return
|
|
}
|
|
|
|
targetID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil || targetID <= 0 {
|
|
writeSafeError(w, r, http.StatusBadRequest, "无效的用户ID")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
RoleID string `json:"role_id"`
|
|
Position string `json:"position"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeSafeError(w, r, http.StatusBadRequest, "无效的请求体")
|
|
return
|
|
}
|
|
|
|
updated, err := s.authSvc().UpdateUserRole(r.Context(), targetID, req.RoleID, req.Position)
|
|
if err != nil {
|
|
writeDomainError(w, r, http.StatusBadRequest, err)
|
|
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) {
|
|
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
|
return
|
|
}
|
|
|
|
targetID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil || targetID <= 0 {
|
|
writeSafeError(w, r, http.StatusBadRequest, "无效的用户ID")
|
|
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 {
|
|
writeSafeError(w, r, http.StatusBadRequest, "无效的请求体")
|
|
return
|
|
}
|
|
|
|
if req.RoleID != "" {
|
|
if _, roleErr := s.authSvc().UpdateUserRole(r.Context(), targetID, req.RoleID, req.Position); roleErr != nil {
|
|
writeDomainError(w, r, http.StatusBadRequest, roleErr)
|
|
return
|
|
}
|
|
}
|
|
|
|
updated, err := s.authSvc().UpdateUserStatus(r.Context(), targetID, req.Status)
|
|
if err != nil {
|
|
writeDomainError(w, r, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(updated)
|
|
}
|
|
|
|
// handleAdminResetPassword POST /api/v1/admin/users/{id}/reset-password
|
|
// Returns a one-time temporary password; must not be logged.
|
|
func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := s.requireSystemAdmin(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的请求方法")
|
|
return
|
|
}
|
|
|
|
targetID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil || targetID <= 0 {
|
|
writeSafeError(w, r, http.StatusBadRequest, "无效的用户ID")
|
|
return
|
|
}
|
|
|
|
result, err := s.authSvc().AdminResetPassword(r.Context(), targetID, actor.ID, httpx.RequestID(r))
|
|
if err != nil {
|
|
msg := err.Error()
|
|
status := http.StatusBadRequest
|
|
if strings.Contains(msg, "不存在") {
|
|
status = http.StatusNotFound
|
|
} else if authIsInfrastructureError(err) {
|
|
status = http.StatusInternalServerError
|
|
}
|
|
writeSafeError(w, r, status, msg)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(result)
|
|
}
|
|
|
|
// handleAuthPublicConfig 返回登录/注册页所需的公开准入配置(无需鉴权)
|
|
func (s *Server) handleAuthPublicConfig(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的方法")
|
|
return
|
|
}
|
|
cfg, err := s.authSvc().GetConfig(r.Context())
|
|
if err != nil {
|
|
writeDomainError(w, r, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(cfg)
|
|
}
|
|
|
|
// handleAdminConfig 获取或更新系统准入配置
|
|
func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
|
return
|
|
}
|
|
|
|
if r.Method == http.MethodGet {
|
|
cfg, err := s.authSvc().GetConfig(r.Context())
|
|
if err != nil {
|
|
writeDomainError(w, r, http.StatusInternalServerError, err)
|
|
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 {
|
|
writeSafeError(w, r, http.StatusBadRequest, "无效的请求体")
|
|
return
|
|
}
|
|
if err := s.authSvc().UpdateConfig(r.Context(), req); err != nil {
|
|
writeDomainError(w, r, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(req)
|
|
return
|
|
}
|
|
|
|
writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的方法")
|
|
}
|