feat: complete unified plugin admin v1.1.0
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user