feat(admin): add session-based portal login

This commit is contained in:
phamnazage-jpg
2026-05-28 11:01:29 +08:00
parent 03c4b5236f
commit de33ff3492
15 changed files with 833 additions and 75 deletions

278
internal/app/admin_auth.go Normal file
View File

@@ -0,0 +1,278 @@
package app
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
)
const (
adminSessionCookieName = "sub2api_crm_admin_session"
defaultAdminUsername = "admin"
defaultAdminSessionTTL = 12 * time.Hour
)
type AdminAuthConfig struct {
Token string
Username string
Password string
SessionTTL time.Duration
Now func() time.Time
}
type adminLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type adminSessionInfo struct {
Username string
ExpiresAt time.Time
}
func (c AdminAuthConfig) normalized() AdminAuthConfig {
c.Token = strings.TrimSpace(c.Token)
c.Username = strings.TrimSpace(c.Username)
c.Password = strings.TrimSpace(c.Password)
if c.Username == "" {
c.Username = defaultAdminUsername
}
if c.Password == "" {
c.Password = c.Token
}
if c.SessionTTL <= 0 {
c.SessionTTL = defaultAdminSessionTTL
}
if c.Now == nil {
c.Now = time.Now
}
return c
}
func (c AdminAuthConfig) loginEnabled() bool {
cfg := c.normalized()
return cfg.Token != "" && cfg.Password != ""
}
func (c AdminAuthConfig) now() time.Time {
return c.normalized().Now()
}
func (c AdminAuthConfig) sessionCookie(r *http.Request) (*http.Cookie, *adminSessionInfo, bool) {
cfg := c.normalized()
cookie, err := r.Cookie(adminSessionCookieName)
if err != nil || cookie == nil || strings.TrimSpace(cookie.Value) == "" {
return nil, nil, false
}
info, ok := verifyAdminSessionValue(cfg.Token, cookie.Value, cfg.now())
if !ok {
return cookie, nil, false
}
return cookie, info, true
}
func (c AdminAuthConfig) requestAuthorized(r *http.Request) bool {
cfg := c.normalized()
if secureCompare(bearerToken(r), cfg.Token) {
return true
}
_, _, ok := cfg.sessionCookie(r)
return ok
}
func secureCompare(left, right string) bool {
if left == "" || right == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
}
func signAdminSessionValue(secret, username string, expiresAt time.Time) string {
payload := strings.TrimSpace(username) + "|" + strconv.FormatInt(expiresAt.Unix(), 10)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + signature
}
func verifyAdminSessionValue(secret, raw string, now time.Time) (*adminSessionInfo, bool) {
if strings.TrimSpace(secret) == "" || strings.TrimSpace(raw) == "" {
return nil, false
}
parts := strings.Split(raw, ".")
if len(parts) != 2 {
return nil, false
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, false
}
payload := string(payloadBytes)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
expectedSignature := mac.Sum(nil)
signatureBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, false
}
if subtle.ConstantTimeCompare(signatureBytes, expectedSignature) != 1 {
return nil, false
}
fields := strings.Split(payload, "|")
if len(fields) != 2 {
return nil, false
}
username := strings.TrimSpace(fields[0])
if username == "" {
return nil, false
}
unixSeconds, err := strconv.ParseInt(fields[1], 10, 64)
if err != nil {
return nil, false
}
expiresAt := time.Unix(unixSeconds, 0)
if !expiresAt.After(now) {
return nil, false
}
return &adminSessionInfo{Username: username, ExpiresAt: expiresAt}, true
}
func issueAdminSessionCookie(w http.ResponseWriter, r *http.Request, cfg AdminAuthConfig, username string) *adminSessionInfo {
cfg = cfg.normalized()
expiresAt := cfg.now().Add(cfg.SessionTTL)
http.SetCookie(w, &http.Cookie{
Name: adminSessionCookieName,
Value: signAdminSessionValue(cfg.Token, username, expiresAt),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: requestUsesHTTPS(r),
Expires: expiresAt,
MaxAge: int(cfg.SessionTTL.Seconds()),
})
return &adminSessionInfo{Username: username, ExpiresAt: expiresAt}
}
func clearAdminSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: adminSessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: requestUsesHTTPS(r),
Expires: time.Unix(0, 0),
MaxAge: -1,
})
}
func requestUsesHTTPS(r *http.Request) bool {
if r != nil && r.TLS != nil {
return true
}
return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
}
func handleAdminSessionState(w http.ResponseWriter, r *http.Request, cfg AdminAuthConfig) {
cfg = cfg.normalized()
payload := map[string]any{
"authenticated": false,
"login_enabled": cfg.loginEnabled(),
"username": cfg.Username,
}
if _, session, ok := cfg.sessionCookie(r); ok {
payload["authenticated"] = true
payload["username"] = session.Username
payload["expires_at"] = session.ExpiresAt.Format(time.RFC3339)
}
writeJSON(w, http.StatusOK, payload)
}
func handleAdminSessionLogin(w http.ResponseWriter, r *http.Request, cfg AdminAuthConfig) {
cfg = cfg.normalized()
if cfg.Token == "" {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "admin token is not configured"})
return
}
if !cfg.loginEnabled() {
writeHTTPError(w, &httpError{StatusCode: http.StatusServiceUnavailable, Code: "login_disabled", Message: "admin login is not enabled"})
return
}
var req adminLoginRequest
if err := decodeJSON(r, &req); err != nil {
writeHTTPError(w, err)
return
}
if strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.Password) == "" {
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "username and password are required"})
return
}
if !secureCompare(strings.TrimSpace(req.Username), cfg.Username) || !secureCompare(strings.TrimSpace(req.Password), cfg.Password) {
writeHTTPError(w, &httpError{StatusCode: http.StatusUnauthorized, Code: "unauthorized", Message: "invalid admin credentials"})
return
}
session := issueAdminSessionCookie(w, r, cfg, cfg.Username)
writeJSON(w, http.StatusOK, map[string]any{
"authenticated": true,
"username": session.Username,
"expires_at": session.ExpiresAt.Format(time.RFC3339),
})
}
func handleAdminSessionLogout(w http.ResponseWriter, r *http.Request) {
clearAdminSessionCookie(w, r)
w.WriteHeader(http.StatusNoContent)
}
func requireAdminAccess(cfg AdminAuthConfig, next http.Handler) http.Handler {
cfg = cfg.normalized()
if cfg.Token == "" {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "admin token is not configured"})
})
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !cfg.requestAuthorized(r) {
writeHTTPError(w, &httpError{StatusCode: http.StatusUnauthorized, Code: "unauthorized", Message: "missing or invalid admin credentials"})
return
}
next.ServeHTTP(w, r)
})
}
func adminSessionDebugValue(secret, username string, expiresAt time.Time) string {
return hex.EncodeToString([]byte(signAdminSessionValue(secret, username, expiresAt)))
}
func adminSessionPayload(raw string) map[string]any {
payload := map[string]any{"raw": raw}
parts := strings.Split(raw, ".")
if len(parts) != 2 {
return payload
}
body, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return payload
}
fields := strings.Split(string(body), "|")
payload["payload"] = string(body)
if len(fields) == 2 {
payload["username"] = fields[0]
payload["expires_unix"] = fields[1]
}
return payload
}
func marshalAdminSessionPayload(raw string) string {
body, _ := json.Marshal(adminSessionPayload(raw))
return string(body)
}

View File

@@ -128,6 +128,122 @@ func TestAPIRejectsMissingAdminToken(t *testing.T) {
assertJSONContains(t, response.Body().Bytes(), "error.code", "unauthorized")
}
func TestAPIAdminSessionLoginSetsCookieAndAuthorizesSubsequentRequest(t *testing.T) {
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
Token: "secret-token",
Username: "admin",
Password: "pass-123",
SessionTTL: 2 * time.Hour,
Now: func() time.Time {
return time.Unix(1_717_000_000, 0)
},
}, ActionSet{
ListPacks: func(context.Context) ([]PackInfo, error) {
return []PackInfo{{PackID: "openai-cn-pack", Version: "1.1.6"}}, nil
},
})
loginRequest := httptestRequest(t, http.MethodPost, "/api/admin/session/login", map[string]any{
"username": "admin",
"password": "pass-123",
}, "")
loginResponse := httptestRecorder(handler, loginRequest)
assertStatusCode(t, loginResponse, http.StatusOK)
assertJSONContains(t, loginResponse.Body().Bytes(), "authenticated", true)
assertJSONContains(t, loginResponse.Body().Bytes(), "username", "admin")
cookies := loginResponse.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("login cookies = %d, want 1", len(cookies))
}
if cookies[0].Name != adminSessionCookieName {
t.Fatalf("cookie name = %q, want %q", cookies[0].Name, adminSessionCookieName)
}
if !cookies[0].HttpOnly {
t.Fatal("session cookie HttpOnly = false, want true")
}
authorizedRequest := httptestRequest(t, http.MethodGet, "/api/packs", nil, "")
authorizedRequest.AddCookie(cookies[0])
authorizedResponse := httptestRecorder(handler, authorizedRequest)
assertStatusCode(t, authorizedResponse, http.StatusOK)
if !strings.Contains(authorizedResponse.Body().String(), `"pack_id":"openai-cn-pack"`) {
t.Fatalf("authorized response = %s, want pack_id openai-cn-pack", authorizedResponse.Body().String())
}
}
func TestAPIAdminSessionRejectsInvalidPassword(t *testing.T) {
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
Token: "secret-token",
Username: "admin",
Password: "pass-123",
}, ActionSet{})
request := httptestRequest(t, http.MethodPost, "/api/admin/session/login", map[string]any{
"username": "admin",
"password": "wrong",
}, "")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusUnauthorized)
assertJSONContains(t, response.Body().Bytes(), "error.code", "unauthorized")
}
func TestAPIAdminSessionLogoutClearsCookie(t *testing.T) {
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
Token: "secret-token",
Username: "admin",
Password: "pass-123",
}, ActionSet{})
request := httptestRequest(t, http.MethodPost, "/api/admin/session/logout", nil, "")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusNoContent)
cookies := response.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("logout cookies = %d, want 1", len(cookies))
}
if cookies[0].Name != adminSessionCookieName {
t.Fatalf("cookie name = %q, want %q", cookies[0].Name, adminSessionCookieName)
}
if cookies[0].MaxAge != -1 {
t.Fatalf("cookie MaxAge = %d, want -1", cookies[0].MaxAge)
}
}
func TestAPIAdminSessionMeReportsAuthenticationState(t *testing.T) {
now := time.Unix(1_717_000_000, 0)
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
Token: "secret-token",
Username: "admin",
Password: "pass-123",
SessionTTL: time.Hour,
Now: func() time.Time {
return now
},
}, ActionSet{})
request := httptestRequest(t, http.MethodGet, "/api/admin/session", nil, "")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "authenticated", false)
assertJSONContains(t, response.Body().Bytes(), "login_enabled", true)
loginRequest := httptestRequest(t, http.MethodPost, "/api/admin/session/login", map[string]any{
"username": "admin",
"password": "pass-123",
}, "")
loginResponse := httptestRecorder(handler, loginRequest)
assertStatusCode(t, loginResponse, http.StatusOK)
meRequest := httptestRequest(t, http.MethodGet, "/api/admin/session", nil, "")
for _, cookie := range loginResponse.Result().Cookies() {
meRequest.AddCookie(cookie)
}
meResponse := httptestRecorder(handler, meRequest)
assertStatusCode(t, meResponse, http.StatusOK)
assertJSONContains(t, meResponse.Body().Bytes(), "authenticated", true)
assertJSONContains(t, meResponse.Body().Bytes(), "username", "admin")
}
func TestAPIInstallPackReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
InstallPack: func(context.Context, InstallPackRequest) (provision.PackInstallResult, error) {
@@ -545,10 +661,26 @@ type responseRecorder struct {
code int
}
func (r *responseRecorder) Header() http.Header { return r.header }
func (r *responseRecorder) Write(body []byte) (int, error) { return r.body.Write(body) }
func (r *responseRecorder) WriteHeader(statusCode int) { r.code = statusCode }
func (r *responseRecorder) Body() *bytes.Buffer { return &r.body }
func (r *responseRecorder) Header() http.Header { return r.header }
func (r *responseRecorder) Write(body []byte) (int, error) {
if r.code == 0 {
r.code = http.StatusOK
}
return r.body.Write(body)
}
func (r *responseRecorder) WriteHeader(statusCode int) { r.code = statusCode }
func (r *responseRecorder) Body() *bytes.Buffer { return &r.body }
func (r *responseRecorder) Result() *http.Response {
statusCode := r.code
if statusCode == 0 {
statusCode = http.StatusOK
}
return &http.Response{
StatusCode: statusCode,
Header: r.header.Clone(),
Body: io.NopCloser(bytes.NewReader(r.body.Bytes())),
}
}
func assertStatusCode(t *testing.T, recorder *responseRecorder, want int) {
t.Helper()

View File

@@ -16,8 +16,17 @@ func Bootstrap(ctx context.Context) (*Server, error) {
if err != nil {
return nil, err
}
adminSession, err := config.LoadAdminSessionFromEnv()
if err != nil {
return nil, err
}
startBackgroundSchedulers(ctx, cfg, defaultBackgroundSchedulers())
handler := NewAPIHandler(adminToken, NewActionSet(cfg.Database.SQLiteDSN))
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
Token: adminToken,
Username: adminSession.Username,
Password: adminSession.Password,
SessionTTL: adminSession.SessionTTL,
}, NewActionSet(cfg.Database.SQLiteDSN))
return NewServer(cfg.Server.ListenAddr, handler, nil), nil
}

View File

@@ -276,102 +276,115 @@ func (e *httpError) Error() string {
}
func NewAPIHandler(adminToken string, actions ActionSet) http.Handler {
return NewAPIHandlerWithAuth(AdminAuthConfig{Token: adminToken}, actions)
}
func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz)
mux.Handle("POST /api/batch-import/runs", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("GET /api/admin/session", func(w http.ResponseWriter, r *http.Request) {
handleAdminSessionState(w, r, adminAuth)
})
mux.HandleFunc("POST /api/admin/session/login", func(w http.ResponseWriter, r *http.Request) {
handleAdminSessionLogin(w, r, adminAuth)
})
mux.HandleFunc("POST /api/admin/session/logout", func(w http.ResponseWriter, r *http.Request) {
handleAdminSessionLogout(w, r)
})
mux.Handle("POST /api/batch-import/runs", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleCreateBatchImportRun(w, r, actions.CreateBatchImportRun)
})))
mux.Handle("GET /api/batch-import/runs", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/batch-import/runs", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListBatchImportRuns(w, r, actions.ListBatchImportRuns)
})))
mux.Handle("GET /api/batch-import/runs/{run_id}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/batch-import/runs/{run_id}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleGetBatchImportRun(w, r, actions.GetBatchImportRun)
})))
mux.Handle("GET /api/batch-import/runs/{run_id}/items", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/batch-import/runs/{run_id}/items", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListBatchImportRunItems(w, r, actions.ListBatchImportRunItems)
})))
mux.Handle("GET /api/batch-import/runs/{run_id}/items/{item_id}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/batch-import/runs/{run_id}/items/{item_id}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleGetBatchImportRunItem(w, r, actions.GetBatchImportRunItem)
})))
mux.Handle("POST /api/provider-drafts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/provider-drafts", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleCreateProviderDraft(w, r, actions.CreateProviderDraft)
})))
mux.Handle("GET /api/provider-drafts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/provider-drafts", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListProviderDrafts(w, r, actions.ListProviderDrafts)
})))
mux.Handle("GET /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/provider-drafts/{draftID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleGetProviderDraft(w, r, actions.GetProviderDraft)
})))
mux.Handle("PUT /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("PUT /api/provider-drafts/{draftID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleUpdateProviderDraft(w, r, actions.UpdateProviderDraft)
})))
mux.Handle("DELETE /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("DELETE /api/provider-drafts/{draftID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleDeleteProviderDraft(w, r, actions.DeleteProviderDraft)
})))
mux.Handle("POST /api/provider-drafts/{draftID}/publish", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/provider-drafts/{draftID}/publish", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handlePublishProviderDraft(w, r, actions.PublishProviderDraft)
})))
mux.Handle("GET /api/import-batches/{batchID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/import-batches/{batchID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleBatchDetail(w, r, actions.BatchDetail)
})))
mux.Handle("POST /api/import-batches/{batchID}/rollback", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/import-batches/{batchID}/rollback", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleRollbackBatch(w, r, actions.RollbackBatch)
})))
mux.Handle("GET /api/providers/{providerID}/status", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/providers/{providerID}/status", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleProviderStatus(w, r, actions.GetProviderStatus)
})))
mux.Handle("GET /api/providers/{providerID}/resources", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/providers/{providerID}/resources", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleProviderResources(w, r, actions.GetProviderResources)
})))
mux.Handle("GET /api/providers/{providerID}/access/status", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/providers/{providerID}/access/status", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleProviderAccessStatus(w, r, actions.GetProviderAccessStatus)
})))
mux.Handle("GET /api/providers/{providerID}/import-batches", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/providers/{providerID}/import-batches", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListProviderImportBatches(w, r, actions.ListProviderImportBatches)
})))
mux.Handle("POST /api/providers/{providerID}/access/assign-subscriptions", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/providers/{providerID}/access/assign-subscriptions", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleAssignAccessSubscriptions(w, r, actions.AssignAccessSubscriptions)
})))
mux.Handle("POST /api/providers/{providerID}/access/preview", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/providers/{providerID}/access/preview", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleAccessPreview(w, r, actions.AccessPreview)
})))
mux.Handle("POST /api/packs/install", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/packs/install", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleInstallPack(w, r, actions.InstallPack)
})))
mux.Handle("GET /api/packs", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/packs", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListPacks(w, r, actions.ListPacks)
})))
mux.Handle("GET /api/packs/{packID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/packs/{packID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleGetPack(w, r, actions.GetPack)
})))
mux.Handle("GET /api/packs/{packID}/providers", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/packs/{packID}/providers", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListPackProviders(w, r, actions.ListPackProviders)
})))
mux.Handle("POST /api/providers/{providerID}/preview-import", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/providers/{providerID}/preview-import", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handlePreviewProvider(w, r, actions.PreviewProvider)
})))
mux.Handle("POST /api/providers/{providerID}/import", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/providers/{providerID}/import", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleImportProvider(w, r, actions.ImportProvider)
})))
mux.Handle("POST /api/providers/{providerID}/rollback", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/providers/{providerID}/rollback", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleRollbackProvider(w, r, actions.RollbackProvider)
})))
mux.Handle("POST /api/providers/{providerID}/reconcile", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/providers/{providerID}/reconcile", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleReconcileProvider(w, r, actions.ReconcileProvider)
})))
mux.Handle("GET /api/hosts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/hosts", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListHosts(w, r, actions.ListHosts)
})))
mux.Handle("GET /api/hosts/{hostID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/hosts/{hostID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleGetHost(w, r, actions.GetHost)
})))
mux.Handle("POST /api/hosts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/hosts", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleCreateHost(w, r, actions.CreateHost)
})))
mux.Handle("POST /api/hosts/{hostID}/probe", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/hosts/{hostID}/probe", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleProbeHost(w, r, actions.ProbeHost)
})))
mux.Handle("DELETE /api/hosts/{hostID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("DELETE /api/hosts/{hostID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleDeleteHost(w, r, actions.DeleteHost)
})))
return mux
@@ -501,21 +514,6 @@ func handlePublishProviderDraft(w http.ResponseWriter, r *http.Request, fn func(
writeJSON(w, http.StatusOK, map[string]any{"publish": result})
}
func requireAdminToken(token string, next http.Handler) http.Handler {
if strings.TrimSpace(token) == "" {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "admin token is not configured"})
})
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if bearerToken(r) != token {
writeHTTPError(w, &httpError{StatusCode: http.StatusUnauthorized, Code: "unauthorized", Message: "missing or invalid admin token"})
return
}
next.ServeHTTP(w, r)
})
}
func bearerToken(r *http.Request) string {
header := strings.TrimSpace(r.Header.Get("Authorization"))
if !strings.HasPrefix(strings.ToLower(header), "bearer ") {

View File

@@ -11,12 +11,17 @@ const (
EnvListenAddr = "SUB2API_CRM_LISTEN_ADDR"
EnvSQLiteDSN = "SUB2API_CRM_SQLITE_DSN"
EnvAdminToken = "SUB2API_CRM_ADMIN_TOKEN"
EnvAdminUsername = "SUB2API_CRM_ADMIN_USERNAME"
EnvAdminPassword = "SUB2API_CRM_ADMIN_PASSWORD"
EnvAdminSessionTTL = "SUB2API_CRM_ADMIN_SESSION_TTL"
EnvRepoRoot = "SUB2API_CRM_REPO_ROOT"
EnvReconcileWorkerEnabled = "SUB2API_CRM_RECONCILE_WORKER_ENABLED"
EnvReconcilePollInterval = "SUB2API_CRM_RECONCILE_POLL_INTERVAL"
DefaultListenAddr = ":8080"
DefaultSQLiteDSN = "file:sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000"
DefaultAdminUsername = "admin"
DefaultAdminSessionTTL = 12 * time.Hour
DefaultReconcilePollInterval = 10 * time.Minute
)
@@ -44,6 +49,12 @@ type StartupConfig struct {
Reconcile ReconcileConfig
}
type AdminSessionConfig struct {
Username string
Password string
SessionTTL time.Duration
}
func LoadStartupFromEnv() (StartupConfig, error) {
return loadStartupFromLookupEnv(os.LookupEnv)
}
@@ -76,6 +87,10 @@ func LoadAdminTokenFromEnv() (string, error) {
return loadAdminTokenFromLookupEnv(os.LookupEnv)
}
func LoadAdminSessionFromEnv() (AdminSessionConfig, error) {
return loadAdminSessionFromLookupEnv(os.LookupEnv)
}
func loadAdminTokenFromLookupEnv(lookup func(string) (string, bool)) (string, error) {
token := strings.TrimSpace(readRequiredEnv(lookup, EnvAdminToken))
if token == "" {
@@ -85,6 +100,18 @@ func loadAdminTokenFromLookupEnv(lookup func(string) (string, bool)) (string, er
return token, nil
}
func loadAdminSessionFromLookupEnv(lookup func(string) (string, bool)) (AdminSessionConfig, error) {
ttl, err := readOptionalDurationEnv(lookup, EnvAdminSessionTTL, DefaultAdminSessionTTL)
if err != nil {
return AdminSessionConfig{}, err
}
return AdminSessionConfig{
Username: readOptionalEnv(lookup, EnvAdminUsername, DefaultAdminUsername),
Password: strings.TrimSpace(readOptionalEnv(lookup, EnvAdminPassword, "")),
SessionTTL: ttl,
}, nil
}
func readOptionalEnv(lookup func(string) (string, bool), key string, defaultValue string) string {
value, ok := lookup(key)
if !ok {

View File

@@ -163,6 +163,65 @@ func TestLoadAdminTokenFromLookupEnv(t *testing.T) {
})
}
func TestLoadAdminSessionFromLookupEnv(t *testing.T) {
t.Run("uses defaults", func(t *testing.T) {
cfg, err := loadAdminSessionFromLookupEnv(func(string) (string, bool) {
return "", false
})
if err != nil {
t.Fatal(err)
}
if cfg.Username != DefaultAdminUsername {
t.Fatalf("Username = %q, want %q", cfg.Username, DefaultAdminUsername)
}
if cfg.Password != "" {
t.Fatalf("Password = %q, want empty", cfg.Password)
}
if cfg.SessionTTL != DefaultAdminSessionTTL {
t.Fatalf("SessionTTL = %s, want %s", cfg.SessionTTL, DefaultAdminSessionTTL)
}
})
t.Run("loads custom values", func(t *testing.T) {
cfg, err := loadAdminSessionFromLookupEnv(func(key string) (string, bool) {
switch key {
case EnvAdminUsername:
return " portal-admin ", true
case EnvAdminPassword:
return " super-secret ", true
case EnvAdminSessionTTL:
return "4h", true
default:
return "", false
}
})
if err != nil {
t.Fatal(err)
}
if cfg.Username != "portal-admin" {
t.Fatalf("Username = %q, want portal-admin", cfg.Username)
}
if cfg.Password != "super-secret" {
t.Fatalf("Password = %q, want super-secret", cfg.Password)
}
if cfg.SessionTTL != 4*time.Hour {
t.Fatalf("SessionTTL = %s, want 4h", cfg.SessionTTL)
}
})
t.Run("rejects invalid ttl", func(t *testing.T) {
_, err := loadAdminSessionFromLookupEnv(func(key string) (string, bool) {
if key == EnvAdminSessionTTL {
return "bad", true
}
return "", false
})
if err == nil {
t.Fatal("expected error for invalid session ttl")
}
})
}
// Verify exported wrappers call the lookup versions.
// We can't easily test LoadStartupFromEnv / LoadAdminTokenFromEnv
// since they depend on os.LookupEnv, but we verify they compile and don't panic.