193 lines
7.0 KiB
Go
193 lines
7.0 KiB
Go
package httpserver
|
||
|
||
import (
|
||
"context"
|
||
"embed"
|
||
"encoding/json"
|
||
"io/fs"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||
"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 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(), remoteAddress(r))
|
||
if err != nil {
|
||
// 账号不存在、密码错误、账号停用和绑定凭据失效统一返回,避免账号枚举。
|
||
writeConsoleJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]string{"code": "UNAUTHENTICATED", "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) http.Handler {
|
||
if next == nil || consoleAuth == nil {
|
||
return next
|
||
}
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
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")) }
|