feat: add database-backed console login

This commit is contained in:
2026-09-21 04:53:54 +08:00
parent 3e1db5e30d
commit a6f4aa229d
17 changed files with 781 additions and 84 deletions

View File

@@ -1,25 +1,39 @@
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
// 或额外的文件挂载。控制台只调用版本化 HTTP API,不读取数据库。
// 或额外的文件挂载。业务页面需要登录;登录页和静态资源保持公开。
//
//go:embed console
var consoleAssets embed.FS
// RegisterConsole 注册本地及生产可复用的 Orbit 控制台入口。
// /console 不改变业务 API 的认证和路由;静态页面自身不包含凭据。
func RegisterConsole(server *http.Server) {
// 页面本身不直接读取数据库,登录后的 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
@@ -32,19 +46,147 @@ func RegisterConsole(server *http.Server) {
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) {
// 队列 ID 只是控制台的默认范围,不是凭据。Token 仍由每个授权 API
// 自己校验;这里不返回任何秘密,也不访问数据库。
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Content-Type-Options", "nosniff")
_ = json.NewEncoder(w).Encode(struct {
DefaultQueueID string `json:"default_queue_id"`
}{DefaultQueueID: os.Getenv("ORBIT_CONSOLE_QUEUE_ID")})
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")) }