Files

238 lines
9.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")) }