Files
ai-gateway-go/internal/identity/http.go
T
superidou 6708c226a5 feat(m8): P1 MinIO 对象存储与文件管理
- 迁移 000023 gateway.file_objects(personal/system 归属隔离 + 部分索引)
- internal/platform/storage:minio-go 适配(端点 scheme 剥离、流式 PutObject/Open/Delete)
- internal/workbench/files.go:FileService(sha256 校验、PutObject-then-insert 回滚、delete 先删行再删对象)
- admin /api/v1/admin/files + portal /api/v1/portal/files 处理器(流式上传下载、Content-Disposition)
- RBAC file:read/file:manage;菜单加文件管理 + 门户文件仓库
- compose 增 minio 服务(S3_* anchor、不暴露端口);nginx client_max_body_size 32m→256m
- 管理端文件管理页 + 门户个人文件仓;集成测试 TestFileObjectLifecycle 连真 MinIO 通过
- healthz object_storage:true;README/PRODUCTION/进展文档同步

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 14:08:38 +08:00

434 lines
20 KiB
Go

package identity
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"aigateway.local/core/internal/platform/apiresponse"
"aigateway.local/core/internal/platform/cryptox"
)
type HTTPHandler struct {
service *Service
mux *http.ServeMux
}
type loginRequest struct {
UserName string `json:"userName"`
Username string `json:"username"`
Account string `json:"account"`
Password string `json:"password"`
}
type totpLoginRequest struct {
TempToken string `json:"temp_token"`
Code string `json:"code"`
BackupCode string `json:"backup_code"`
}
type passwordRequest struct {
Password string `json:"password"`
}
type factorRequest struct {
Password string `json:"password"`
Code string `json:"code"`
BackupCode string `json:"backup_code"`
}
func NewHTTPHandler(service *Service) *HTTPHandler {
handler := &HTTPHandler{service: service, mux: http.NewServeMux()}
handler.mux.HandleFunc("POST /api/v1/admin/login", handler.login(KindAdmin))
handler.registerTOTP(KindAdmin, "/api/v1/admin")
handler.mux.HandleFunc("GET /api/v1/admin/whoami", handler.whoami(KindAdmin))
handler.mux.HandleFunc("POST /api/v1/admin/password", handler.changePassword(KindAdmin))
handler.mux.HandleFunc("POST /api/v1/admin/logout", handler.logout)
handler.mux.HandleFunc("GET /api/v1/admin/menus", handler.menus(KindAdmin))
handler.mux.HandleFunc("POST /api/v1/portal/login", handler.login(KindPortal))
handler.registerTOTP(KindPortal, "/api/v1/portal")
handler.mux.HandleFunc("GET /api/v1/portal/me", handler.whoami(KindPortal))
handler.mux.HandleFunc("POST /api/v1/portal/logout", handler.logout)
handler.mux.HandleFunc("GET /api/v1/portal/menus", handler.menus(KindPortal))
handler.registerOIDC()
return handler
}
func (h *HTTPHandler) changePassword(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, ok := h.requireAccount(writer, request, kind)
if !ok {
return
}
var input struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
if !decodeJSON(writer, request, &input) {
apiresponse.Error(writer, http.StatusBadRequest, "请求格式无效")
return
}
if err := h.service.ChangePassword(request.Context(), account, input.OldPassword, input.NewPassword); err != nil {
h.writeIdentityError(writer, err)
return
}
apiresponse.OK(writer, map[string]bool{"changed": true})
}
}
func (h *HTTPHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
h.mux.ServeHTTP(writer, request)
}
func (h *HTTPHandler) login(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
// 防爆破:按 IP 的滑动窗口限流,超限返回 429(与账号锁定叠加)。
if !h.service.AllowLogin(request.Context(), ClientIP(request)) {
apiresponse.Error(writer, http.StatusTooManyRequests, "登录尝试过于频繁,请稍后再试")
return
}
var input loginRequest
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
apiresponse.Error(writer, http.StatusBadRequest, "请求格式无效")
return
}
login := strings.TrimSpace(input.UserName)
if login == "" {
login = strings.TrimSpace(input.Username)
}
if login == "" {
login = strings.TrimSpace(input.Account)
}
if login == "" || len(login) > 128 || len(input.Password) < 1 || len(input.Password) > 1024 {
apiresponse.Error(writer, http.StatusBadRequest, "账号或口令格式无效")
return
}
result, err := h.service.Login(request.Context(), kind, login, input.Password)
if err != nil {
h.writeIdentityError(writer, err)
return
}
apiresponse.OK(writer, map[string]any{
"token": result.Token, "refreshToken": "", "require_totp": result.RequireTOTP,
"temp_token": result.TempToken,
})
}
}
func (h *HTTPHandler) registerTOTP(kind Kind, prefix string) {
h.mux.HandleFunc("POST "+prefix+"/login/totp", h.completeTOTPLogin(kind))
h.mux.HandleFunc("GET "+prefix+"/totp/status", h.totpStatus(kind))
h.mux.HandleFunc("POST "+prefix+"/totp/setup", h.setupTOTP(kind))
h.mux.HandleFunc("POST "+prefix+"/totp/confirm", h.confirmTOTP(kind))
h.mux.HandleFunc("POST "+prefix+"/totp/disable", h.disableTOTP(kind))
h.mux.HandleFunc("POST "+prefix+"/totp/backup-codes/regenerate", h.regenerateBackupCodes(kind))
}
func (h *HTTPHandler) completeTOTPLogin(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
// 防爆破:TOTP 完成端点同样按 IP 限流。
if !h.service.AllowLogin(request.Context(), ClientIP(request)) {
apiresponse.Error(writer, http.StatusTooManyRequests, "登录尝试过于频繁,请稍后再试")
return
}
var input totpLoginRequest
if !decodeJSON(writer, request, &input) || strings.TrimSpace(input.TempToken) == "" || (strings.TrimSpace(input.Code) == "" && strings.TrimSpace(input.BackupCode) == "") {
apiresponse.Error(writer, http.StatusBadRequest, "请输入动态验证码或备用码")
return
}
result, err := h.service.CompleteTOTPLogin(request.Context(), kind, input.TempToken, input.Code, input.BackupCode)
if err != nil {
h.writeIdentityError(writer, err)
return
}
apiresponse.OK(writer, map[string]any{"token": result.Token, "refreshToken": "", "require_totp": false})
}
}
func (h *HTTPHandler) totpStatus(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, ok := h.requireAccount(writer, request, kind)
if !ok {
return
}
apiresponse.OK(writer, map[string]bool{"enabled": account.TOTPEnabled, "setup_pending": !account.TOTPEnabled && len(account.EncryptedTOTPSecret) > 0})
}
}
func (h *HTTPHandler) setupTOTP(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, ok := h.requireAccount(writer, request, kind)
if !ok {
return
}
var input passwordRequest
if !decodeJSON(writer, request, &input) || input.Password == "" {
apiresponse.Error(writer, http.StatusBadRequest, "请输入当前口令")
return
}
result, err := h.service.SetupTOTP(request.Context(), account, input.Password)
if err != nil {
h.writeIdentityError(writer, err)
return
}
apiresponse.OK(writer, map[string]string{"secret": result.Secret, "provisioning_uri": result.ProvisioningURI})
}
}
func (h *HTTPHandler) confirmTOTP(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, ok := h.requireAccount(writer, request, kind)
if !ok {
return
}
var input factorRequest
if !decodeJSON(writer, request, &input) || strings.TrimSpace(input.Code) == "" {
apiresponse.Error(writer, http.StatusBadRequest, "请输入动态验证码")
return
}
codes, err := h.service.ConfirmTOTP(request.Context(), account, input.Code)
if err != nil {
h.writeIdentityError(writer, err)
return
}
apiresponse.OK(writer, map[string]any{"enabled": true, "backup_codes": codes})
}
}
func (h *HTTPHandler) disableTOTP(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, ok := h.requireAccount(writer, request, kind)
if !ok {
return
}
var input factorRequest
if !decodeJSON(writer, request, &input) || input.Password == "" {
apiresponse.Error(writer, http.StatusBadRequest, "当前口令和验证因子不能为空")
return
}
if err := h.service.DisableTOTP(request.Context(), account, input.Password, input.Code, input.BackupCode); err != nil {
h.writeIdentityError(writer, err)
return
}
apiresponse.OK(writer, map[string]bool{"enabled": false})
}
}
func (h *HTTPHandler) regenerateBackupCodes(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, ok := h.requireAccount(writer, request, kind)
if !ok {
return
}
var input factorRequest
if !decodeJSON(writer, request, &input) || input.Password == "" {
apiresponse.Error(writer, http.StatusBadRequest, "当前口令和验证因子不能为空")
return
}
codes, err := h.service.RegenerateBackupCodes(request.Context(), account, input.Password, input.Code, input.BackupCode)
if err != nil {
h.writeIdentityError(writer, err)
return
}
apiresponse.OK(writer, map[string]any{"backup_codes": codes})
}
}
func (h *HTTPHandler) requireAccount(writer http.ResponseWriter, request *http.Request, kind Kind) (Account, bool) {
account, err := h.service.Authenticate(request.Context(), kind, request.Header.Get("Authorization"))
if err != nil {
h.writeIdentityError(writer, err)
return Account{}, false
}
return account, true
}
func decodeJSON(writer http.ResponseWriter, request *http.Request, target any) bool {
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20))
decoder.DisallowUnknownFields()
return decoder.Decode(target) == nil
}
func (h *HTTPHandler) whoami(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, err := h.service.Authenticate(request.Context(), kind, request.Header.Get("Authorization"))
if err != nil {
h.writeIdentityError(writer, err)
return
}
roles := []string{"R_USER"}
if kind == KindAdmin {
roles = []string{"R_ADMIN"}
if account.Role == "superadmin" {
roles = []string{"R_SUPER"}
}
}
permissions := EffectivePermissions(account)
apiresponse.OK(writer, map[string]any{
"userId": account.ID, "userName": account.Login,
"displayName": account.DisplayName, "email": "",
"roles": roles, "buttons": permissions, "permissions": permissions, "role": account.Role,
})
}
}
func (h *HTTPHandler) logout(writer http.ResponseWriter, request *http.Request) {
if err := h.service.Logout(request.Context(), request.Header.Get("Authorization")); err != nil && !errors.Is(err, ErrInvalidSession) {
h.writeIdentityError(writer, err)
return
}
apiresponse.OK(writer, map[string]bool{"ok": true})
}
func (h *HTTPHandler) menus(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, err := h.service.Authenticate(request.Context(), kind, request.Header.Get("Authorization"))
if err != nil {
h.writeIdentityError(writer, err)
return
}
if kind == KindAdmin {
apiresponse.OK(writer, adminMenus(account))
return
}
apiresponse.OK(writer, portalMenus())
}
}
func (h *HTTPHandler) writeIdentityError(writer http.ResponseWriter, err error) {
var locked LockedError
switch {
case errors.As(err, &locked):
minutes := int(time.Until(locked.Until).Minutes()) + 1
apiresponse.Error(writer, http.StatusTooManyRequests, "账号已锁定,请在 "+(time.Duration(minutes)*time.Minute).String()+" 后重试")
case errors.Is(err, ErrInvalidCredentials):
apiresponse.Error(writer, http.StatusUnauthorized, "账号或口令错误")
case errors.Is(err, ErrInvalidSession), errors.Is(err, ErrNotFound):
apiresponse.Error(writer, http.StatusUnauthorized, "登录状态无效或已过期")
case errors.Is(err, ErrAccountDisabled):
apiresponse.Error(writer, http.StatusForbidden, "账号已被停用")
case errors.Is(err, ErrInvalidTOTP):
apiresponse.Error(writer, http.StatusUnauthorized, "动态验证码无效、已使用或备用码无效")
case errors.Is(err, ErrTOTPAlreadyEnabled):
apiresponse.Error(writer, http.StatusConflict, "两步验证已经启用")
case errors.Is(err, ErrTOTPNotEnabled), errors.Is(err, ErrTOTPSetupRequired):
apiresponse.Error(writer, http.StatusConflict, "两步验证尚未完成配置")
case errors.Is(err, cryptox.ErrKeyUnavailable):
apiresponse.Error(writer, http.StatusServiceUnavailable, "两步验证加密密钥不可用")
case errors.Is(err, ErrUnavailable):
apiresponse.Error(writer, http.StatusServiceUnavailable, "身份服务暂不可用")
default:
apiresponse.Error(writer, http.StatusInternalServerError, "身份服务处理失败")
}
}
func adminMenus(account Account) []map[string]any {
// 运行概览:首页仪表盘(叶子菜单,避免出现「运行概览>运行概览」同级冗余)。
menus := []map[string]any{
{"name": "Dashboard", "path": "/dashboard/console", "component": "/dashboard/console", "meta": map[string]any{"title": "运行概览", "icon": "ri:pie-chart-line", "fixedTab": true}},
}
// 网关接入:上游供应商、路由与凭据。
gatewayChildren := make([]map[string]any, 0, 4)
if HasPermission(account, PermissionProviderRead) || HasPermission(account, PermissionProviderManage) {
gatewayChildren = append(gatewayChildren, map[string]any{"name": "Providers", "path": "providers", "component": "/gateway/providers", "meta": map[string]any{"title": "模型供应商"}})
gatewayChildren = append(gatewayChildren, map[string]any{"name": "ModelRoutes", "path": "model-routes", "component": "/gateway/model-routes", "meta": map[string]any{"title": "模型路由"}})
}
if HasPermission(account, PermissionAPIKeyRead) || HasPermission(account, PermissionAPIKeyManage) {
gatewayChildren = append(gatewayChildren, map[string]any{"name": "APIKeys", "path": "api-keys", "component": "/gateway/api-keys", "meta": map[string]any{"title": "API Key"}})
}
if HasPermission(account, PermissionPricingRead) || HasPermission(account, PermissionPricingManage) {
gatewayChildren = append(gatewayChildren, map[string]any{"name": "ModelPrices", "path": "model-prices", "component": "/gateway/model-prices", "meta": map[string]any{"title": "模型价格"}})
}
if len(gatewayChildren) > 0 {
menus = append(menus, map[string]any{"name": "Gateway", "path": "/gateway", "component": "/index/index", "meta": map[string]any{"title": "网关接入", "icon": "ri:router-line"}, "children": gatewayChildren})
}
// 安全与审计:审计用量、内容策略与模型治理。
securityChildren := make([]map[string]any, 0, 3)
if HasPermission(account, PermissionAuditRead) || HasPermission(account, PermissionUsageRead) {
securityChildren = append(securityChildren, map[string]any{"name": "AuditUsage", "path": "audit-usage", "component": "/gateway/audit-usage", "meta": map[string]any{"title": "审计与用量"}})
}
if HasPermission(account, PermissionContentPolicyRead) || HasPermission(account, PermissionContentPolicyManage) {
securityChildren = append(securityChildren, map[string]any{"name": "ContentPolicies", "path": "content-policies", "component": "/gateway/content-policies", "meta": map[string]any{"title": "内容策略"}})
}
if HasPermission(account, PermissionKnowledgeRead) || HasPermission(account, PermissionKnowledgeManage) {
securityChildren = append(securityChildren, map[string]any{"name": "Governance", "path": "governance", "component": "/gateway/governance", "meta": map[string]any{"title": "模型治理"}})
}
if len(securityChildren) > 0 {
menus = append(menus, map[string]any{"name": "Security", "path": "/security", "component": "/index/index", "meta": map[string]any{"title": "安全与审计", "icon": "ri:shield-check-line"}, "children": securityChildren})
}
// AI 资产:Prompt、知识库、工具与 AI 应用。
assetsChildren := make([]map[string]any, 0, 4)
if HasPermission(account, PermissionPromptRead) || HasPermission(account, PermissionPromptManage) {
assetsChildren = append(assetsChildren, map[string]any{"name": "Prompts", "path": "prompts", "component": "/gateway/prompts", "meta": map[string]any{"title": "Prompt 资产"}})
}
if HasPermission(account, PermissionKnowledgeRead) || HasPermission(account, PermissionKnowledgeManage) {
assetsChildren = append(assetsChildren, map[string]any{"name": "Knowledge", "path": "knowledge", "component": "/gateway/knowledge", "meta": map[string]any{"title": "知识库"}})
}
if HasPermission(account, PermissionToolRead) || HasPermission(account, PermissionToolManage) {
assetsChildren = append(assetsChildren, map[string]any{"name": "Tools", "path": "tools", "component": "/gateway/tools", "meta": map[string]any{"title": "工具中心"}})
}
if HasPermission(account, PermissionApplicationRead) || HasPermission(account, PermissionApplicationManage) {
assetsChildren = append(assetsChildren, map[string]any{"name": "Applications", "path": "applications", "component": "/gateway/applications", "meta": map[string]any{"title": "AI 应用"}})
}
if HasPermission(account, PermissionFileRead) || HasPermission(account, PermissionFileManage) {
assetsChildren = append(assetsChildren, map[string]any{"name": "Files", "path": "files", "component": "/gateway/files", "meta": map[string]any{"title": "文件管理"}})
}
if len(assetsChildren) > 0 {
menus = append(menus, map[string]any{"name": "Assets", "path": "/assets", "component": "/index/index", "meta": map[string]any{"title": "AI 资产", "icon": "ri:box-3-line"}, "children": assetsChildren})
}
// 资源市场:MCP 服务器、Skills 与数字员工(旗舰版资源市场)。
marketChildren := make([]map[string]any, 0, 4)
if HasPermission(account, PermissionMarketplaceRead) || HasPermission(account, PermissionMarketplaceManage) {
marketChildren = append(marketChildren, map[string]any{"name": "Marketplace", "path": "overview", "component": "/gateway/marketplace", "meta": map[string]any{"title": "市场总览"}})
}
if HasPermission(account, PermissionMCPServerRead) || HasPermission(account, PermissionMCPServerManage) {
marketChildren = append(marketChildren, map[string]any{"name": "MCPServers", "path": "mcp-servers", "component": "/gateway/mcp-servers", "meta": map[string]any{"title": "MCP 服务器"}})
}
if HasPermission(account, PermissionSkillRead) || HasPermission(account, PermissionSkillManage) {
marketChildren = append(marketChildren, map[string]any{"name": "Skills", "path": "skills", "component": "/gateway/skills", "meta": map[string]any{"title": "Skills 技能"}})
}
if HasPermission(account, PermissionDigitalEmployeeRead) || HasPermission(account, PermissionDigitalEmployeeManage) {
marketChildren = append(marketChildren, map[string]any{"name": "DigitalEmployees", "path": "digital-employees", "component": "/gateway/digital-employees", "meta": map[string]any{"title": "数字员工"}})
}
if len(marketChildren) > 0 {
menus = append(menus, map[string]any{"name": "ResourceMarket", "path": "/resource-market", "component": "/index/index", "meta": map[string]any{"title": "资源市场", "icon": "ri:store-3-line"}, "children": marketChildren})
}
// 系统管理:账号权限、事件投递与通知。
systemChildren := make([]map[string]any, 0, 3)
if HasPermission(account, PermissionIdentityManage) {
systemChildren = append(systemChildren, map[string]any{"name": "User", "path": "user", "component": "/system/user", "meta": map[string]any{"title": "账号与权限"}})
}
if HasPermission(account, PermissionOutboxRead) || HasPermission(account, PermissionOutboxManage) {
systemChildren = append(systemChildren, map[string]any{"name": "Outbox", "path": "outbox", "component": "/gateway/outbox", "meta": map[string]any{"title": "事件投递"}})
}
if HasPermission(account, PermissionNotificationRead) || HasPermission(account, PermissionNotificationManage) {
systemChildren = append(systemChildren, map[string]any{"name": "Notifications", "path": "notifications", "component": "/gateway/notifications", "meta": map[string]any{"title": "通知中心"}})
}
if len(systemChildren) > 0 {
menus = append(menus, map[string]any{"name": "System", "path": "/system", "component": "/index/index", "meta": map[string]any{"title": "系统管理", "icon": "ri:user-3-line"}, "children": systemChildren})
}
return menus
}
func portalMenus() []map[string]any {
return []map[string]any{
{"name": "Portal", "path": "/portal", "component": "/index/index", "meta": map[string]any{"title": "AI 工作台", "icon": "ri:sparkling-line"}, "children": []map[string]any{
{"name": "PortalCatalog", "path": "catalog", "component": "/portal/catalog", "meta": map[string]any{"title": "资产目录", "fixedTab": true}},
{"name": "PortalMarketplace", "path": "marketplace", "component": "/portal/marketplace", "meta": map[string]any{"title": "资源市场"}},
{"name": "PortalPrompts", "path": "prompts", "component": "/portal/prompts", "meta": map[string]any{"title": "Prompt 广场"}},
{"name": "PortalUsage", "path": "usage", "component": "/portal/usage", "meta": map[string]any{"title": "我的用量"}},
{"name": "PortalAccess", "path": "access", "component": "/portal/access", "meta": map[string]any{"title": "模型权限"}},
{"name": "PortalFiles", "path": "files", "component": "/portal/files", "meta": map[string]any{"title": "文件仓库"}},
}},
}
}