feat: complete unified plugin admin v1.1.0
Business Plugins CI / check (plugin-admin) (push) Successful in 1m42s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m30s

This commit is contained in:
Qiufeng
2026-08-30 12:10:04 +08:00
parent 3c1a17f4d7
commit ada4ab3c21
69 changed files with 6681 additions and 901 deletions
+137 -40
View File
@@ -4,6 +4,7 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"embed"
@@ -32,8 +33,9 @@ const (
sessionTTL = 30 * time.Minute
sessionMaxTTL = 8 * time.Hour
pendingTTL = 5 * time.Minute
maxPendingLogins = 1024
pluginID = "qiu.subscription-admin"
pluginVersion = "0.1.1"
pluginVersion = "0.2.0"
requestIDHeader = "X-Request-ID"
maxRequestIDBytes = 64
)
@@ -436,10 +438,33 @@ func token(n int) string {
}
func decodeJSON(r *http.Request, out any) error {
if r == nil || r.Body == nil {
return errors.New("request body is required")
}
defer r.Body.Close()
dec := json.NewDecoder(io.LimitReader(r.Body, maxBodyBytes))
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
if err != nil {
return err
}
if int64(len(raw)) > maxBodyBytes {
return errors.New("request body exceeds size limit")
}
if len(bytes.TrimSpace(raw)) == 0 {
return errors.New("request body is required")
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
return dec.Decode(out)
if err := dec.Decode(out); err != nil {
return err
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
if err == nil {
return errors.New("request body must contain exactly one JSON value")
}
return err
}
return nil
}
func (a *app) writeJSON(w http.ResponseWriter, status int, value any) {
@@ -480,6 +505,13 @@ func (a *app) allowLoginAttempt(r *http.Request, identity string) bool {
now := a.clock()
a.mu.Lock()
defer a.mu.Unlock()
// Expire buckets opportunistically so a long-lived development compatibility
// process cannot retain one map entry for every attempted identity forever.
for candidate, attempt := range a.loginAttempts {
if attempt.started.IsZero() || now.Sub(attempt.started) >= a.loginWindow {
delete(a.loginAttempts, candidate)
}
}
attempt := a.loginAttempts[key]
if attempt.started.IsZero() || now.Sub(attempt.started) >= a.loginWindow {
attempt = loginAttempt{started: now}
@@ -493,6 +525,32 @@ func (a *app) allowLoginAttempt(r *http.Request, identity string) bool {
return true
}
// addPendingLogin keeps the optional standalone 2FA compatibility mode
// bounded. A client can create a challenge without completing it, so expired
// entries are removed before enforcing the hard cap.
func (a *app) addPendingLogin(value pendingLogin) (string, bool) {
a.mu.Lock()
defer a.mu.Unlock()
now := time.Now()
if a.clock != nil {
now = a.clock()
}
for id, pending := range a.pending {
if !pending.expires.After(now) {
delete(a.pending, id)
}
}
if len(a.pending) >= maxPendingLogins {
return "", false
}
if a.pending == nil {
a.pending = make(map[string]pendingLogin)
}
id := token(24)
a.pending[id] = value
return id, true
}
func (a *app) clientIP(r *http.Request) string {
return trustedClientIPWithConfig(r, a.trustProxy)
}
@@ -526,10 +584,12 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
a.writeJSON(w, http.StatusBadGateway, map[string]string{"error": "core 2fa challenge missing"})
return
}
pendingID := token(24)
a.mu.Lock()
a.pending[pendingID] = pendingLogin{tempToken: temp, expires: a.clock().Add(pendingTTL), clientIP: a.clientIP(r)}
a.mu.Unlock()
pendingID, accepted := a.addPendingLogin(pendingLogin{tempToken: temp, expires: a.clock().Add(pendingTTL), clientIP: a.clientIP(r)})
if !accepted {
w.Header().Set("Retry-After", "60")
a.writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "too many pending login challenges"})
return
}
a.writeJSON(w, http.StatusOK, map[string]any{"requires_2fa": true, "pending_token": pendingID})
return
}
@@ -760,6 +820,13 @@ func (a *app) userProxy(w http.ResponseWriter, r *http.Request) {
}
}
if err != nil {
if ce, unauthorized := err.(*coreError); unauthorized && ce.status == http.StatusUnauthorized {
a.removeSessionIfCurrent(sessionID, s.accessToken)
a.core.logout(r.Context(), s.refreshToken, correlationID)
a.setSessionCookie(w, "", -1)
a.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "core session expired"})
return
}
a.coreError(w, err, "user lookup failed")
return
}
@@ -853,18 +920,25 @@ func (a *app) refreshSession(ctx context.Context, id string, stale session) (ses
return session{}, false
}
data := envelopeData(refreshed)
candidateRefresh := current.refreshToken
if nextRefresh, ok := data["refresh_token"].(string); ok && nextRefresh != "" {
candidateRefresh = nextRefresh
}
access, _ := data["access_token"].(string)
if access == "" {
a.core.logout(ctx, candidateRefresh, correlationID)
return session{}, false
}
// Refresh-token rotation is common. Do not commit the replacement until the
// new access token has passed the Core identity and admin-role checks; if a
// check fails, revoke the replacement instead of leaving it live.
nextMe, err := a.core.me(ctx, access, correlationID)
if err != nil || !isAdmin(envelopeData(nextMe)) {
a.core.logout(ctx, candidateRefresh, correlationID)
return session{}, false
}
current.accessToken = access
if nextRefresh, ok := data["refresh_token"].(string); ok && nextRefresh != "" {
current.refreshToken = nextRefresh
}
current.refreshToken = candidateRefresh
current.user = publicUser(envelopeData(nextMe))
current.lastSeen = a.clock()
a.mu.Lock()
@@ -995,40 +1069,63 @@ func (a *app) static(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}
// standaloneAuthEnabled is intentionally fail-closed. The module's own
// login/API compatibility surface is useful for local contract tests only;
// production deployments must expose the shared Plugin Admin Shell instead.
func standaloneAuthEnabled() bool {
if !strings.EqualFold(strings.TrimSpace(os.Getenv("PLUGIN_STANDALONE_AUTH")), "true") {
return false
}
if !strings.EqualFold(strings.TrimSpace(os.Getenv("PLUGIN_ENV")), "development") {
return false
}
host := strings.Trim(strings.TrimSpace(os.Getenv("PLUGIN_HOST")), "[]")
if host == "" {
host = "127.0.0.1"
}
return isLoopbackHost(host)
}
func (a *app) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", a.health)
mux.HandleFunc("/readyz", a.ready)
mux.HandleFunc("/login", a.login)
mux.HandleFunc("/login/2fa", a.login2FA)
mux.HandleFunc("/logout", a.logout)
mux.HandleFunc("/api/me", a.me)
mux.HandleFunc("/api/status", a.status)
mux.HandleFunc("/api/captcha-config", a.captchaConfig)
mux.HandleFunc("/api/audit", a.auditLog)
mux.Handle("/api/plans", a.readProxy("/api/v1/admin/payment/plans"))
mux.Handle("/api/subscriptions", a.readProxy("/api/v1/admin/subscriptions"))
mux.HandleFunc("/api/subscriptions/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/subscriptions/")
if !positiveID(id) || strings.Contains(id, "/") {
http.NotFound(w, r)
return
}
a.readProxy("/api/v1/admin/subscriptions/"+id)(w, r)
})
mux.HandleFunc("/api/users/", func(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/api/users/")
parts := strings.Split(rest, "/")
if len(parts) == 1 {
a.userProxy(w, r)
return
}
if len(parts) != 2 || !positiveID(parts[0]) || parts[1] != "subscriptions" {
http.NotFound(w, r)
return
}
a.readProxy("/api/v1/admin/users/"+parts[0]+"/subscriptions")(w, r)
})
// The business module is mounted by Plugin Admin. A standalone compatibility
// API is available only when explicitly opted into for local contract tests;
// production deployments leave it disabled so there is no second login,
// session cookie, or Core-data BFF on the module's own port.
if standaloneAuthEnabled() {
mux.HandleFunc("/login", a.login)
mux.HandleFunc("/login/2fa", a.login2FA)
mux.HandleFunc("/logout", a.logout)
mux.HandleFunc("/api/me", a.me)
mux.HandleFunc("/api/status", a.status)
mux.HandleFunc("/api/captcha-config", a.captchaConfig)
mux.HandleFunc("/api/audit", a.auditLog)
mux.Handle("/api/plans", a.readProxy("/api/v1/admin/payment/plans"))
mux.Handle("/api/subscriptions", a.readProxy("/api/v1/admin/subscriptions"))
mux.HandleFunc("/api/subscriptions/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/subscriptions/")
if !positiveID(id) || strings.Contains(id, "/") {
http.NotFound(w, r)
return
}
a.readProxy("/api/v1/admin/subscriptions/"+id)(w, r)
})
mux.HandleFunc("/api/users/", func(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/api/users/")
parts := strings.Split(rest, "/")
if len(parts) == 1 {
a.userProxy(w, r)
return
}
if len(parts) != 2 || !positiveID(parts[0]) || parts[1] != "subscriptions" {
http.NotFound(w, r)
return
}
a.readProxy("/api/v1/admin/users/"+parts[0]+"/subscriptions")(w, r)
})
}
mux.HandleFunc("/", a.static)
return requestIDMiddleware(a.securityHeaders(mux))
}