Add bearer token API authentication
- Auto-generate random 32-char bearer token on first startup, stored in secrets.yaml as api.bearerToken - BearerAuthMiddleware checks Authorization: Bearer <token> on all /api/ endpoints except /health, /health/reconcile, /events (SSE), and non-API paths (frontend static files) - Development mode (WILD_CENTRAL_ENV=development) skips auth entirely - Web app ApiClient: add setToken/clearToken/hasToken methods, persist token in localStorage, automatically include Authorization header on all API requests - Token can be found in secrets.yaml for CLI/automation use
This commit is contained in:
@@ -256,10 +256,16 @@ func (api *API) StartConvergenceLoop(ctx gocontext.Context, interval time.Durati
|
|||||||
slog.Info("convergence loop started", "component", "reconcile", "interval", interval)
|
slog.Info("convergence loop started", "component", "reconcile", "interval", interval)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) RegisterRoutes(r *mux.Router) {
|
func (api *API) RegisterRoutes(r *mux.Router, bearerToken string, devMode bool) {
|
||||||
r.Use(RequestLoggingMiddleware)
|
r.Use(RequestLoggingMiddleware)
|
||||||
|
|
||||||
// Health
|
// Bearer token authentication — protects all /api/ routes.
|
||||||
|
// Dev mode skips auth for ease of development.
|
||||||
|
if !devMode && bearerToken != "" {
|
||||||
|
r.Use(BearerAuthMiddleware(bearerToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health (accessible even with auth — middleware exempts /health)
|
||||||
r.HandleFunc("/api/v1/health/reconcile", api.ReconcileHealth).Methods("GET")
|
r.HandleFunc("/api/v1/health/reconcile", api.ReconcileHealth).Methods("GET")
|
||||||
|
|
||||||
// Resource-oriented settings (persisted in state.yaml)
|
// Resource-oriented settings (persisted in state.yaml)
|
||||||
|
|||||||
@@ -37,6 +37,43 @@ func (w *statusResponseWriter) Flush() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BearerAuthMiddleware returns middleware that requires a valid Bearer token
|
||||||
|
// on all API endpoints except health checks and the SSE event stream.
|
||||||
|
func BearerAuthMiddleware(token string) mux.MiddlewareFunc {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
|
||||||
|
// Public endpoints — no auth required
|
||||||
|
if path == "/api/v1/health" || path == "/api/v1/health/reconcile" ||
|
||||||
|
strings.HasSuffix(path, "/events") {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-API paths (frontend static files) — no auth required
|
||||||
|
if !strings.HasPrefix(path, "/api/") {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth == "" {
|
||||||
|
http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if !strings.HasPrefix(auth, prefix) || auth[len(prefix):] != token {
|
||||||
|
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RequestLoggingMiddleware logs method, path, status, and duration for each request.
|
// RequestLoggingMiddleware logs method, path, status, and duration for each request.
|
||||||
// Long-lived connections (SSE, WebSocket) are excluded.
|
// Long-lived connections (SSE, WebSocket) are excluded.
|
||||||
func RequestLoggingMiddleware(next http.Handler) http.Handler {
|
func RequestLoggingMiddleware(next http.Handler) http.Handler {
|
||||||
|
|||||||
15
main.go
15
main.go
@@ -119,6 +119,19 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load or generate API bearer token
|
||||||
|
apiToken, _ := secretsMgr.GetSecret("api.bearerToken")
|
||||||
|
if apiToken == "" {
|
||||||
|
apiToken, _ = secrets.GenerateSecret(32)
|
||||||
|
_ = secretsMgr.SetSecret("api.bearerToken", apiToken)
|
||||||
|
slog.Info("generated API bearer token", "component", "startup")
|
||||||
|
}
|
||||||
|
|
||||||
|
isDev := os.Getenv("WILD_CENTRAL_ENV") == "development"
|
||||||
|
if isDev {
|
||||||
|
slog.Info("development mode: API authentication disabled", "component", "startup")
|
||||||
|
}
|
||||||
|
|
||||||
allowedOrigins := buildAllowedOrigins(dataDir)
|
allowedOrigins := buildAllowedOrigins(dataDir)
|
||||||
|
|
||||||
api, err := v1.NewAPI(dataDir, Version, allowedOrigins)
|
api, err := v1.NewAPI(dataDir, Version, allowedOrigins)
|
||||||
@@ -137,7 +150,7 @@ func main() {
|
|||||||
api.StartDNSFilter(ctx)
|
api.StartDNSFilter(ctx)
|
||||||
|
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
api.RegisterRoutes(router)
|
api.RegisterRoutes(router, apiToken, isDev)
|
||||||
|
|
||||||
router.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
|
router.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -21,17 +21,43 @@ interface ErrorResponseBody {
|
|||||||
|
|
||||||
import { getApiBaseUrl } from './config';
|
import { getApiBaseUrl } from './config';
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'wild-central:token';
|
||||||
|
|
||||||
export class ApiClient {
|
export class ApiClient {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
|
private token: string | null = null;
|
||||||
|
|
||||||
constructor(baseUrl: string = getApiBaseUrl()) {
|
constructor(baseUrl: string = getApiBaseUrl()) {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
|
// Load token from localStorage on init
|
||||||
|
try { this.token = localStorage.getItem(TOKEN_KEY); } catch { /* noop */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
getBaseURL(): string {
|
getBaseURL(): string {
|
||||||
return this.baseUrl;
|
return this.baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setToken(token: string) {
|
||||||
|
this.token = token;
|
||||||
|
try { localStorage.setItem(TOKEN_KEY, token); } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
clearToken() {
|
||||||
|
this.token = null;
|
||||||
|
try { localStorage.removeItem(TOKEN_KEY); } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
hasToken(): boolean {
|
||||||
|
return this.token !== null && this.token !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private authHeaders(): Record<string, string> {
|
||||||
|
if (this.token) {
|
||||||
|
return { 'Authorization': `Bearer ${this.token}` };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
private async request<T>(
|
private async request<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options?: RequestInit
|
options?: RequestInit
|
||||||
@@ -43,6 +69,7 @@ export class ApiClient {
|
|||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...this.authHeaders(),
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -104,7 +131,7 @@ export class ApiClient {
|
|||||||
|
|
||||||
async getText(endpoint: string): Promise<string> {
|
async getText(endpoint: string): Promise<string> {
|
||||||
const url = `${this.baseUrl}${endpoint}`;
|
const url = `${this.baseUrl}${endpoint}`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url, { headers: this.authHeaders() });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
@@ -122,7 +149,7 @@ export class ApiClient {
|
|||||||
const url = `${this.baseUrl}${endpoint}`;
|
const url = `${this.baseUrl}${endpoint}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'text/plain' },
|
headers: { 'Content-Type': 'text/plain', ...this.authHeaders() },
|
||||||
body: text,
|
body: text,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user