0.11.2: 旗舰版第三轮完善(通用聊天/企微钉钉飞书扫码登录/个人安全策略)
- 门户通用聊天:选择已批准模型直接对话,审批通过后自动开通用户级运行时 API Key(加密落库,限额取批准值),聊天经受管网关统一认证/限流/配额/审计; 会话哈希链完整性 + busy 租约防并发,失败不落库。 - 扫码登录:identity_providers 扩展 wecom/dingtalk/feishu,管理端配置 (AppID/AppSecret/AgentID/回调/自动开户/默认部门),登录页自动展示; one-time state 防 CSRF,provider_uid 全局唯一防多账号绑定,平台端点 固定公网 URL 复用 public-only 拨号。 - 个人安全策略:账号安全页(登录设备管理/吊销非当前会话/登录提醒开关/ 扫码绑定解绑),登录成功发布 security.login_detected 事件按偏好落站内信 (新增 security 类别),会话索引只存令牌摘要并惰性清理。 - 迁移 000038-000041;修复 social update 参数越界/凭据回读/路由挂载缺失; 全量测试 25 包通过,前端 admin/portal 构建通过,端到端验证完成。
This commit is contained in:
@@ -51,10 +51,15 @@ func NewHTTPHandler(service *Service) *HTTPHandler {
|
||||
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/sessions", handler.listSessions)
|
||||
handler.mux.HandleFunc("POST /api/v1/portal/sessions/{id}/revoke", handler.revokeSession)
|
||||
handler.mux.HandleFunc("GET /api/v1/portal/security/prefs", handler.securityPrefs)
|
||||
handler.mux.HandleFunc("PUT /api/v1/portal/security/prefs", handler.setSecurityPrefs)
|
||||
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()
|
||||
handler.registerSocial()
|
||||
return handler
|
||||
}
|
||||
|
||||
@@ -136,7 +141,7 @@ func (h *HTTPHandler) login(kind Kind) http.HandlerFunc {
|
||||
apiresponse.Error(writer, http.StatusBadRequest, "账号或口令格式无效")
|
||||
return
|
||||
}
|
||||
result, err := h.service.Login(request.Context(), kind, login, input.Password)
|
||||
result, err := h.service.LoginWithMeta(request.Context(), kind, login, input.Password, SessionMeta{IP: h.service.ClientIP(request), UserAgent: request.UserAgent()})
|
||||
if err != nil {
|
||||
// 登录失败审计(429 限流在 AllowLogin 阶段已拦截,此处都是真实失败)。
|
||||
_ = h.service.RecordLoginLog(request.Context(), kind, login, false, h.service.ClientIP(request), request.UserAgent(), loginFailureReason(err))
|
||||
@@ -174,6 +179,66 @@ func loginFailureReason(err error) string {
|
||||
}
|
||||
}
|
||||
|
||||
// listSessions 我的登录设备列表(当前会话标记 current)。
|
||||
func (h *HTTPHandler) listSessions(w http.ResponseWriter, r *http.Request) {
|
||||
account, ok := h.requireAccount(w, r, KindPortal)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListSessions(r.Context(), KindPortal, account.ID, r.Header.Get("Authorization"))
|
||||
if err != nil {
|
||||
h.writeIdentityError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, items)
|
||||
}
|
||||
|
||||
// revokeSession 吊销指定登录设备(不允许吊销当前会话)。
|
||||
func (h *HTTPHandler) revokeSession(w http.ResponseWriter, r *http.Request) {
|
||||
account, ok := h.requireAccount(w, r, KindPortal)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.RevokeSession(r.Context(), KindPortal, account.ID, r.PathValue("id"), r.Header.Get("Authorization")); err != nil {
|
||||
h.writeIdentityError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"revoked": true})
|
||||
}
|
||||
|
||||
// securityPrefs 我的安全偏好。
|
||||
func (h *HTTPHandler) securityPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
account, ok := h.requireAccount(w, r, KindPortal)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
loginNotify, err := h.service.SecurityPrefs(r.Context(), account.ID)
|
||||
if err != nil {
|
||||
h.writeIdentityError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"login_notify": loginNotify})
|
||||
}
|
||||
|
||||
// setSecurityPrefs 更新安全偏好。
|
||||
func (h *HTTPHandler) setSecurityPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
account, ok := h.requireAccount(w, r, KindPortal)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
LoginNotify bool `json:"login_notify"`
|
||||
}
|
||||
if !decodeJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
if err := h.service.SetSecurityPrefs(r.Context(), account.ID, input.LoginNotify); err != nil {
|
||||
h.writeIdentityError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"login_notify": input.LoginNotify})
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -195,7 +260,7 @@ func (h *HTTPHandler) completeTOTPLogin(kind Kind) http.HandlerFunc {
|
||||
apiresponse.Error(writer, http.StatusBadRequest, "请输入动态验证码或备用码")
|
||||
return
|
||||
}
|
||||
result, err := h.service.CompleteTOTPLogin(request.Context(), kind, input.TempToken, input.Code, input.BackupCode)
|
||||
result, err := h.service.CompleteTOTPLoginWithMeta(request.Context(), kind, input.TempToken, input.Code, input.BackupCode, SessionMeta{IP: h.service.ClientIP(request), UserAgent: request.UserAgent()})
|
||||
if err != nil {
|
||||
_ = h.service.RecordLoginLog(request.Context(), kind, "", false, h.service.ClientIP(request), request.UserAgent(), loginFailureReason(err))
|
||||
h.writeIdentityError(writer, err)
|
||||
@@ -374,6 +439,8 @@ func (h *HTTPHandler) writeIdentityError(writer http.ResponseWriter, err error)
|
||||
apiresponse.Error(writer, http.StatusConflict, "两步验证已经启用")
|
||||
case errors.Is(err, ErrTOTPNotEnabled), errors.Is(err, ErrTOTPSetupRequired):
|
||||
apiresponse.Error(writer, http.StatusConflict, "两步验证尚未完成配置")
|
||||
case errors.Is(err, ErrRevokeCurrentSession):
|
||||
apiresponse.Error(writer, http.StatusBadRequest, "不能吊销当前登录的会话")
|
||||
case errors.Is(err, cryptox.ErrKeyUnavailable):
|
||||
apiresponse.Error(writer, http.StatusServiceUnavailable, "两步验证加密密钥不可用")
|
||||
case errors.Is(err, ErrUnavailable):
|
||||
@@ -506,7 +573,8 @@ func adminMenus(account Account) []map[string]any {
|
||||
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": "PortalChat", "path": "chat", "component": "/portal/chat", "meta": map[string]any{"title": "通用聊天", "fixedTab": true}},
|
||||
{"name": "PortalCatalog", "path": "catalog", "component": "/portal/catalog", "meta": map[string]any{"title": "资产目录"}},
|
||||
{"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": "我的用量"}},
|
||||
@@ -517,6 +585,7 @@ func portalMenus() []map[string]any {
|
||||
{"name": "PortalMemories", "path": "memories", "component": "/portal/memories", "meta": map[string]any{"title": "记忆管理"}},
|
||||
{"name": "PortalEnvVars", "path": "env-vars", "component": "/portal/env-vars", "meta": map[string]any{"title": "环境变量"}},
|
||||
{"name": "PortalLoginLogs", "path": "login-logs", "component": "/portal/login-logs", "meta": map[string]any{"title": "登录记录"}},
|
||||
{"name": "PortalSecurity", "path": "security", "component": "/portal/security", "meta": map[string]any{"title": "账号安全"}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user