feat: add database-backed console login
This commit is contained in:
@@ -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")) }
|
||||
|
||||
Reference in New Issue
Block a user