package httpserver import ( "context" "embed" "encoding/json" "errors" "io/fs" "math" "net" "net/http" "net/url" "os" "strconv" "strings" "time" "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" "git.yuntuo.hk/k2safe/orbit/internal/fault" core "git.yuntuo.hk/k2safe/orbit/model" "git.yuntuo.hk/k2safe/orbit/platform/auth" ) const consoleSessionCookie = "orbit_console_session" // consoleAssets 将控制台静态资源编译进 Orbit,避免运行容器依赖 Node.js、外部 CDN // 或额外的文件挂载。业务页面需要登录;登录页和静态资源保持公开。 // //go:embed console var consoleAssets embed.FS // RegisterConsole 注册本地及生产可复用的 Orbit 控制台入口。 // 页面本身不直接读取数据库,登录后的 API 请求仍由已有服务层做权限校验。 func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) { if server == nil { return } var consoleAuth consoleauth.Store if len(authStores) > 0 { consoleAuth = authStores[0] } mux, ok := server.Handler.(*http.ServeMux) if !ok { return } assets, err := fs.Sub(consoleAssets, "console") if err != nil { return } files := http.StripPrefix("/console/", http.FileServer(http.FS(assets))) mux.HandleFunc("GET /console", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/console/", http.StatusPermanentRedirect) }) mux.HandleFunc("GET /console/login", func(w http.ResponseWriter, r *http.Request) { serveConsoleAsset(w, r, assets, "login.html") }) mux.HandleFunc("GET /console/auth/session", func(w http.ResponseWriter, r *http.Request) { if consoleAuth == nil { writeConsoleJSON(w, http.StatusOK, map[string]any{"authenticated": false}) return } token, ok := consoleCookieToken(r) if !ok || consoleAuth.ValidateSession(r.Context(), token) != nil { writeConsoleJSON(w, http.StatusOK, map[string]any{"authenticated": false}) return } writeConsoleJSON(w, http.StatusOK, map[string]any{"authenticated": true}) }) mux.HandleFunc("POST /console/auth/login", func(w http.ResponseWriter, r *http.Request) { if err := http.NewCrossOriginProtection().Check(r); err != nil { writeConsoleJSON(w, http.StatusForbidden, map[string]any{"error": map[string]string{"code": "PERMISSION_DENIED", "message": "不允许跨站登录请求"}}) return } r.Body = http.MaxBytesReader(w, r.Body, 8<<10) if consoleAuth == nil { writeConsoleJSON(w, http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "UNAVAILABLE", "message": "console authentication is not configured"}}) return } var in struct { Username string `json:"username"` Password string `json:"password"` } if err := decodeBody(r, &in); err != nil { writeConsoleJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "INVALID_ARGUMENT", "message": "invalid login request"}}) return } ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) defer cancel() login, err := consoleAuth.Login(ctx, in.Username, in.Password, r.UserAgent(), loginRemoteAddress(r, consoleAuth)) if err != nil { var blocked *consoleauth.LoginBlocked if errors.As(err, &blocked) { seconds := max(1, int(math.Ceil(blocked.RetryAfter.Seconds()))) code, message := "LOGIN_RATE_LIMITED", "登录尝试过于频繁,请稍后重试" if blocked.Locked { code, message = "LOGIN_LOCKED", "连续登录失败,已临时锁定,请稍后重试" } w.Header().Set("Retry-After", strconv.Itoa(seconds)) writeConsoleJSON(w, http.StatusTooManyRequests, map[string]any{"error": map[string]any{"code": code, "message": message, "retry_after_seconds": seconds}}) } else if errors.Is(err, fault.Unauthorized) { writeConsoleJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]string{"code": "UNAUTHENTICATED", "message": "用户名或密码错误"}}) } else { // 存储或审计不可用时不发 Cookie,不将基础设施故障伪装成密码错误。 writeConsoleJSON(w, http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "UNAVAILABLE", "message": "登录服务暂不可用,请稍后重试"}}) } return } http.SetCookie(w, &http.Cookie{Name: consoleSessionCookie, Value: login.Token, Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, Expires: login.ExpiresAt, MaxAge: int(time.Until(login.ExpiresAt).Seconds())}) writeConsoleJSON(w, http.StatusOK, map[string]any{"user": login.User, "expires_at": login.ExpiresAt}) }) mux.HandleFunc("POST /console/auth/logout", func(w http.ResponseWriter, r *http.Request) { if consoleAuth != nil { if token, ok := consoleCookieToken(r); ok { _ = consoleAuth.RevokeSession(r.Context(), token) } } http.SetCookie(w, &http.Cookie{Name: consoleSessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(1, 0)}) writeConsoleJSON(w, http.StatusOK, map[string]any{"logged_out": true}) }) mux.HandleFunc("GET /console/config", func(w http.ResponseWriter, r *http.Request) { if consoleAuth != nil { if token, ok := consoleCookieToken(r); !ok || consoleAuth.ValidateSession(r.Context(), token) != nil { consoleLoginRedirect(w, r) return } } // 队列 ID 只是已登录控制台的默认范围,不是凭据,也不返回秘密。 writeConsoleJSON(w, http.StatusOK, map[string]string{"default_queue_id": consoleQueueID()}) }) mux.Handle("GET /console/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/console/" && consoleAuth != nil { if token, ok := consoleCookieToken(r); !ok || consoleAuth.ValidateSession(r.Context(), token) != nil { consoleLoginRedirect(w, r) return } } w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Content-Type-Options", "nosniff") files.ServeHTTP(w, r) })) } // ConsoleSessionMiddleware 将 HttpOnly 会话转换为内部 Bearer 请求头。 // 浏览器脚本永远看不到业务令牌;现有所有 v1 handler 仍走同一个鉴权入口。 func ConsoleSessionMiddleware(next http.Handler, consoleAuth consoleauth.Store, scopes ...consoleauth.Scopes) http.Handler { if next == nil || consoleAuth == nil { return next } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if values := r.Header.Values("X-Orbit-Namespace"); len(values) > 0 { if len(values) != 1 || len(scopes) == 0 || scopes[0] == nil || len(r.Header.Values("Authorization")) > 0 || r.Header.Get("X-Orbit-Console") != "1" || !consoleScopePath(r) { writeError(w, "", fault.Forbidden) return } if err := http.NewCrossOriginProtection().Check(r); err != nil { writeError(w, "", fault.Forbidden) return } token, ok := consoleCookieToken(r) if !ok || consoleAuth.ValidateSession(r.Context(), token) != nil { writeError(w, "", fault.Unauthorized) return } scoped, err := scopes[0].ResolveScope(r.Context(), token, core.ID(values[0])) if err != nil { writeError(w, "", err) return } r = r.WithContext(context.WithValue(r.Context(), consoleScopeKey{}, scoped)) } if len(r.Header.Values("Authorization")) == 0 { if token, ok := consoleCookieToken(r); ok && consoleAuth.ValidateSession(r.Context(), token) == nil { clone := r.Clone(r.Context()) clone.Header = r.Header.Clone() if cookie, err := r.Cookie(consoleSessionCookie); err == nil { clone.Header.Set("Authorization", "Bearer "+cookie.Value) } r = clone } } next.ServeHTTP(w, r) }) } func consoleCookieToken(r *http.Request) (auth.Token, bool) { cookie, err := r.Cookie(consoleSessionCookie) if err != nil || cookie.Value == "" { return auth.Token{}, false } token, err := auth.Parse(cookie.Value) return token, err == nil } func consoleLoginRedirect(w http.ResponseWriter, r *http.Request) { returnTo := r.URL.Path if r.URL.RawQuery != "" { returnTo += "?" + r.URL.RawQuery } if returnTo == "/console/config" || !strings.HasPrefix(returnTo, "/console") || strings.HasPrefix(returnTo, "//") { returnTo = "/console/" } http.Redirect(w, r, "/console/login?return="+url.QueryEscape(returnTo), http.StatusFound) } func serveConsoleAsset(w http.ResponseWriter, r *http.Request, assets fs.FS, name string) { file, err := fs.ReadFile(assets, name) if err != nil { http.NotFound(w, r) return } w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(file) } func writeConsoleJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) } func remoteAddress(r *http.Request) string { if r == nil { return "" } value := r.RemoteAddr if host, _, err := net.SplitHostPort(value); err == nil { return host } return value } func consoleQueueID() string { return strings.TrimSpace(os.Getenv("ORBIT_CONSOLE_QUEUE_ID")) }