feat(admin): add session-based portal login
This commit is contained in:
278
internal/app/admin_auth.go
Normal file
278
internal/app/admin_auth.go
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user