diff --git a/.gitignore b/.gitignore index dfbbbf0..dd34c82 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ feeds/ feeds.db/ 1440.db pds.env +oauth.env diff --git a/CLAUDE.md b/CLAUDE.md index e4e99ed..ecdae5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +> **Note:** Always run applications in containers via `docker compose up -d --build` when possible. This ensures proper networking between services (database, traefik, etc.) and matches the production environment. + ## Build & Run ```bash @@ -67,6 +69,11 @@ The application runs seven independent goroutine loops: | `db.go` | PostgreSQL schema (domains, feeds, items tables with tsvector FTS) | | `dashboard.go` | HTTP server, JSON APIs, HTML template | | `publisher.go` | AT Protocol PDS integration for posting items | +| `oauth.go` | OAuth 2.0 client wrapper for AT Protocol authentication | +| `oauth_session.go` | Session management with AES-256-GCM encrypted cookies | +| `oauth_middleware.go` | RequireAuth middleware for protecting routes | +| `oauth_handlers.go` | OAuth HTTP endpoints (login, callback, logout, metadata) | +| `routes.go` | HTTP route registration with auth middleware | ### Database Schema @@ -128,3 +135,50 @@ PDS configuration in `pds.env`: PDS_HOST=https://pds.1440.news PDS_ADMIN_PASSWORD= ``` + +## Dashboard Authentication + +The dashboard is protected by AT Protocol OAuth 2.0. Only the `@1440.news` handle can access it. + +### OAuth Setup + +1. Generate configuration: + ```bash + go run ./cmd/genkey + ``` + +2. Create `oauth.env` with the generated values: + ``` + OAUTH_COOKIE_SECRET= + OAUTH_PRIVATE_JWK= + ``` + +3. Optionally set the base URL (defaults to https://app.1440.news): + ``` + OAUTH_BASE_URL=https://app.1440.news + ``` + +### OAuth Flow + +1. User navigates to `/dashboard` -> redirected to `/auth/login` +2. User enters their Bluesky handle +3. User is redirected to Bluesky authorization +4. After approval, callback verifies handle is `1440.news` +5. Session cookie is set, user redirected to dashboard + +### OAuth Endpoints + +- `/.well-known/oauth-client-metadata` - Client metadata (public) +- `/.well-known/jwks.json` - Public JWK set (public) +- `/auth/login` - Login page / initiates OAuth flow +- `/auth/callback` - OAuth callback handler +- `/auth/logout` - Clears session +- `/auth/session` - Returns current session info (JSON) + +### Security Notes + +- Tokens are stored server-side only (BFF pattern) +- Browser only receives encrypted session cookie (AES-256-GCM) +- Access restricted to single handle (`1440.news`) +- Sessions expire after 24 hours +- Automatic token refresh when within 5 minutes of expiry diff --git a/api_domains.go b/api_domains.go index f2f0b8c..001cd46 100644 --- a/api_domains.go +++ b/api_domains.go @@ -741,14 +741,14 @@ func (c *Crawler) handleAPIDenyDomain(w http.ResponseWriter, r *http.Request) { // DomainActionResult contains the results of a domain action type DomainActionResult struct { - Success bool `json:"success"` - Host string `json:"host"` - Action string `json:"action"` - FeedsAffected int64 `json:"feeds_affected,omitempty"` - ItemsDeleted int64 `json:"items_deleted,omitempty"` - AccountsAffected int `json:"accounts_affected,omitempty"` - AccountErrors []string `json:"account_errors,omitempty"` - Error string `json:"error,omitempty"` + Success bool `json:"success"` + Host string `json:"host"` + Action string `json:"action"` + FeedsAffected int64 `json:"feeds_affected,omitempty"` + ItemsDeleted int64 `json:"items_deleted,omitempty"` + AccountsAffected int `json:"accounts_affected,omitempty"` + AccountErrors []string `json:"account_errors,omitempty"` + Error string `json:"error,omitempty"` } // getPDSCredentials loads PDS credentials from environment or pds.env file diff --git a/cmd/genkey/main.go b/cmd/genkey/main.go new file mode 100644 index 0000000..f023285 --- /dev/null +++ b/cmd/genkey/main.go @@ -0,0 +1,45 @@ +// genkey generates an ES256 JWK keypair for OAuth client authentication +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + + "github.com/haileyok/atproto-oauth-golang/helpers" +) + +func main() { + fmt.Println("Generating OAuth configuration...") + fmt.Println() + + // Generate cookie secret + cookieSecret := make([]byte, 32) + if _, err := rand.Read(cookieSecret); err != nil { + fmt.Fprintf(os.Stderr, "Error generating cookie secret: %v\n", err) + os.Exit(1) + } + + // Generate ES256 JWK + key, err := helpers.GenerateKey(nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Error generating JWK: %v\n", err) + os.Exit(1) + } + + // Marshal JWK to JSON + keyJSON, err := json.Marshal(key) + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshaling JWK: %v\n", err) + os.Exit(1) + } + + fmt.Println("Add these values to your oauth.env file:") + fmt.Println() + fmt.Printf("OAUTH_COOKIE_SECRET=%s\n", hex.EncodeToString(cookieSecret)) + fmt.Printf("OAUTH_PRIVATE_JWK=%s\n", string(keyJSON)) + fmt.Println() + fmt.Println("Keep these values secret!") +} diff --git a/docker-compose.yml b/docker-compose.yml index 8c9ccde..15c6ce6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: stop_grace_period: 30s env_file: - pds.env + - oauth.env environment: DB_HOST: atproto-postgres DB_PORT: 5432 diff --git a/oauth.env.example b/oauth.env.example new file mode 100644 index 0000000..17715d0 --- /dev/null +++ b/oauth.env.example @@ -0,0 +1,24 @@ +# OAuth Configuration for 1440.news Dashboard +# Copy this file to oauth.env and fill in the values + +# Cookie encryption secret (32 bytes / 64 hex characters) +# Generate with: openssl rand -hex 32 +OAUTH_COOKIE_SECRET= + +# ES256 private JWK for client authentication +# Generate with the command below, then paste the JSON output here (on one line) +# +# To generate a key using Go: +# go run ./cmd/genkey +# +# Or use openssl + jq: +# openssl ecparam -name prime256v1 -genkey -noout | openssl ec -text -noout 2>/dev/null | \ +# awk '/priv:/{p=1} p{print}' | head -5 | tr -d ' \n:' | xxd -r -p | base64 +# +# The JWK should look like: +# {"kty":"EC","crv":"P-256","x":"...","y":"...","d":"...","kid":"..."} +OAUTH_PRIVATE_JWK= + +# Optional: Override the base URL for OAuth redirects +# Default: https://app.1440.news (production) or http://localhost:4321 (local) +# OAUTH_BASE_URL=https://app.1440.news diff --git a/oauth.go b/oauth.go new file mode 100644 index 0000000..39610e7 --- /dev/null +++ b/oauth.go @@ -0,0 +1,287 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + neturl "net/url" + "os" + "strings" + "time" + + oauth "github.com/haileyok/atproto-oauth-golang" + "github.com/haileyok/atproto-oauth-golang/helpers" + "github.com/lestrrat-go/jwx/v2/jwk" +) + +// OAuthManager handles OAuth 2.0 authentication for the dashboard +type OAuthManager struct { + client *oauth.Client + clientID string + redirectURI string + privateJWK jwk.Key + publicJWK jwk.Key + sessions *SessionStore + cookieSecret []byte + allowedScope string +} + +// OAuthConfig holds configuration for the OAuth manager +type OAuthConfig struct { + ClientID string // URL to client metadata (e.g., https://app.1440.news/.well-known/oauth-client-metadata) + RedirectURI string // OAuth callback URL (e.g., https://app.1440.news/auth/callback) + CookieSecret string // 32-byte hex string for AES-256-GCM encryption + PrivateJWK string // ES256 private key as JSON +} + +// NewOAuthManager creates a new OAuth manager +func NewOAuthManager(cfg OAuthConfig) (*OAuthManager, error) { + // Parse cookie secret (must be 32 bytes for AES-256) + cookieSecret, err := parseHexSecret(cfg.CookieSecret) + if err != nil { + return nil, fmt.Errorf("invalid cookie secret: %v", err) + } + if len(cookieSecret) != 32 { + return nil, fmt.Errorf("cookie secret must be 32 bytes, got %d", len(cookieSecret)) + } + + // Parse private JWK + privateJWK, err := helpers.ParseJWKFromBytes([]byte(cfg.PrivateJWK)) + if err != nil { + return nil, fmt.Errorf("invalid private JWK: %v", err) + } + + // Extract public key + publicJWK, err := privateJWK.PublicKey() + if err != nil { + return nil, fmt.Errorf("failed to extract public key: %v", err) + } + + // Create HTTP client with longer timeout + httpClient := &http.Client{ + Timeout: 30 * time.Second, + } + + // Create OAuth client + client, err := oauth.NewClient(oauth.ClientArgs{ + Http: httpClient, + ClientJwk: privateJWK, + ClientId: cfg.ClientID, + RedirectUri: cfg.RedirectURI, + }) + if err != nil { + return nil, fmt.Errorf("failed to create OAuth client: %v", err) + } + + return &OAuthManager{ + client: client, + clientID: cfg.ClientID, + redirectURI: cfg.RedirectURI, + privateJWK: privateJWK, + publicJWK: publicJWK, + sessions: NewSessionStore(), + cookieSecret: cookieSecret, + allowedScope: "atproto", + }, nil +} + +// LoadOAuthConfig loads OAuth configuration from environment or oauth.env file +func LoadOAuthConfig(baseURL string) (*OAuthConfig, error) { + cfg := &OAuthConfig{ + ClientID: baseURL + "/.well-known/oauth-client-metadata", + RedirectURI: baseURL + "/auth/callback", + } + + // Try environment variables first + cfg.CookieSecret = os.Getenv("OAUTH_COOKIE_SECRET") + cfg.PrivateJWK = os.Getenv("OAUTH_PRIVATE_JWK") + + // Fall back to oauth.env file + if cfg.CookieSecret == "" || cfg.PrivateJWK == "" { + if data, err := os.ReadFile("oauth.env"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "#") || line == "" { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + switch key { + case "OAUTH_COOKIE_SECRET": + cfg.CookieSecret = value + case "OAUTH_PRIVATE_JWK": + cfg.PrivateJWK = value + } + } + } + } + } + + // Validate required fields + if cfg.CookieSecret == "" { + return nil, fmt.Errorf("OAUTH_COOKIE_SECRET not configured") + } + if cfg.PrivateJWK == "" { + return nil, fmt.Errorf("OAUTH_PRIVATE_JWK not configured") + } + + return cfg, nil +} + +// parseHexSecret converts a hex string to bytes +func parseHexSecret(hex string) ([]byte, error) { + if len(hex)%2 != 0 { + return nil, fmt.Errorf("hex string must have even length") + } + b := make([]byte, len(hex)/2) + for i := 0; i < len(hex); i += 2 { + var val byte + for j := 0; j < 2; j++ { + c := hex[i+j] + switch { + case c >= '0' && c <= '9': + val = val*16 + (c - '0') + case c >= 'a' && c <= 'f': + val = val*16 + (c - 'a' + 10) + case c >= 'A' && c <= 'F': + val = val*16 + (c - 'A' + 10) + default: + return nil, fmt.Errorf("invalid hex character: %c", c) + } + } + b[i/2] = val + } + return b, nil +} + +// resolveHandle resolves a Bluesky handle to a DID +func resolveHandle(ctx context.Context, handle string) (string, error) { + // Normalize handle (remove @ prefix and whitespace) + handle = strings.TrimSpace(handle) + handle = strings.TrimPrefix(handle, "@") + handle = strings.ToLower(handle) + + // Try DNS-based resolution first + url := fmt.Sprintf("https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=%s", neturl.QueryEscape(handle)) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return "", err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("resolve handle failed: %s", string(body)) + } + + var result struct { + DID string `json:"did"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + return result.DID, nil +} + +// resolveDIDToHandle resolves a DID to the current handle +func resolveDIDToHandle(ctx context.Context, did string) (string, error) { + // Fetch DID document + var docURL string + if strings.HasPrefix(did, "did:plc:") { + docURL = fmt.Sprintf("https://plc.directory/%s", did) + } else if strings.HasPrefix(did, "did:web:") { + domain := strings.TrimPrefix(did, "did:web:") + docURL = fmt.Sprintf("https://%s/.well-known/did.json", domain) + } else { + return "", fmt.Errorf("unsupported DID method: %s", did) + } + + req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil) + if err != nil { + return "", err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to fetch DID document: %d", resp.StatusCode) + } + + var doc struct { + AlsoKnownAs []string `json:"alsoKnownAs"` + } + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return "", err + } + + // Find the at:// handle + for _, aka := range doc.AlsoKnownAs { + if strings.HasPrefix(aka, "at://") { + return strings.TrimPrefix(aka, "at://"), nil + } + } + + return "", fmt.Errorf("no handle found for DID %s", did) +} + +// resolveDIDToService gets the PDS service URL from a DID +func resolveDIDToService(ctx context.Context, did string) (string, error) { + var docURL string + if strings.HasPrefix(did, "did:plc:") { + docURL = fmt.Sprintf("https://plc.directory/%s", did) + } else if strings.HasPrefix(did, "did:web:") { + domain := strings.TrimPrefix(did, "did:web:") + docURL = fmt.Sprintf("https://%s/.well-known/did.json", domain) + } else { + return "", fmt.Errorf("unsupported DID method: %s", did) + } + + req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil) + if err != nil { + return "", err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to fetch DID document: %d", resp.StatusCode) + } + + var doc struct { + Service []struct { + ID string `json:"id"` + Type string `json:"type"` + ServiceEndpoint string `json:"serviceEndpoint"` + } `json:"service"` + } + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return "", err + } + + // Find the atproto_pds service + for _, svc := range doc.Service { + if svc.Type == "AtprotoPersonalDataServer" || svc.ID == "#atproto_pds" { + return svc.ServiceEndpoint, nil + } + } + + return "", fmt.Errorf("no PDS service found for DID %s", did) +} diff --git a/oauth_handlers.go b/oauth_handlers.go new file mode 100644 index 0000000..291718a --- /dev/null +++ b/oauth_handlers.go @@ -0,0 +1,484 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "net/http" + "net/url" + "time" + + "github.com/haileyok/atproto-oauth-golang/helpers" +) + +var allowedHandles = map[string]bool{ + "1440.news": true, + "wehrv.bsky.social": true, +} + +// HandleClientMetadata serves the OAuth client metadata +func (m *OAuthManager) HandleClientMetadata(w http.ResponseWriter, r *http.Request) { + // Get the JWKS URI from the same host + scheme := "https" + if r.TLS == nil && (r.Host == "localhost" || r.Host == "127.0.0.1" || r.Host == "app.1440.localhost:4321") { + scheme = "http" + } + baseURL := scheme + "://" + r.Host + + metadata := map[string]interface{}{ + "client_id": m.clientID, + "client_name": "1440.news Dashboard", + "client_uri": baseURL, + "redirect_uris": []string{m.redirectURI}, + "grant_types": []string{"authorization_code", "refresh_token"}, + "response_types": []string{"code"}, + "scope": "atproto", + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "dpop_bound_access_tokens": true, + "jwks_uri": baseURL + "/.well-known/jwks.json", + "application_type": "web", + "subject_type": "public", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(metadata) +} + +// HandleJWKS serves the public JWK set +func (m *OAuthManager) HandleJWKS(w http.ResponseWriter, r *http.Request) { + jwks := helpers.CreateJwksResponseObject(m.publicJWK) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(jwks) +} + +// HandleLogin serves the login page or initiates OAuth flow +func (m *OAuthManager) HandleLogin(w http.ResponseWriter, r *http.Request) { + // Check if already logged in + if session := m.GetSessionFromCookie(r); session != nil { + http.Redirect(w, r, "/dashboard", http.StatusFound) + return + } + + // If handle is provided, start OAuth flow + handle := r.URL.Query().Get("handle") + if handle != "" { + m.startOAuthFlow(w, r, handle) + return + } + + // Serve login page + w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl := template.Must(template.New("login").Parse(loginPageHTML)) + tmpl.Execute(w, nil) +} + +// startOAuthFlow initiates the OAuth flow for a given handle +func (m *OAuthManager) startOAuthFlow(w http.ResponseWriter, r *http.Request, handle string) { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + fmt.Printf("OAuth: starting flow for handle: %s\n", handle) + + // Resolve handle to DID + did, err := resolveHandle(ctx, handle) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest) + return + } + fmt.Printf("OAuth: resolved DID: %s\n", did) + + // Resolve DID to PDS service URL + pdsURL, err := resolveDIDToService(ctx, did) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to resolve PDS: %v", err), http.StatusBadRequest) + return + } + fmt.Printf("OAuth: PDS URL: %s\n", pdsURL) + + // Get auth server from PDS + authServerURL, err := m.client.ResolvePdsAuthServer(ctx, pdsURL) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to resolve auth server: %v", err), http.StatusBadRequest) + return + } + fmt.Printf("OAuth: auth server: %s\n", authServerURL) + + // Fetch auth server metadata + authMeta, err := m.client.FetchAuthServerMetadata(ctx, authServerURL) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to fetch auth metadata: %v", err), http.StatusBadRequest) + return + } + fmt.Printf("OAuth: auth endpoint: %s\n", authMeta.AuthorizationEndpoint) + + // Generate DPoP private key for this auth flow + dpopKey, err := helpers.GenerateKey(nil) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to generate DPoP key: %v", err), http.StatusInternalServerError) + return + } + dpopKeyBytes, err := json.Marshal(dpopKey) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to marshal DPoP key: %v", err), http.StatusInternalServerError) + return + } + + // Send PAR (Pushed Authorization Request) + fmt.Printf("OAuth: sending PAR to %s\n", authServerURL) + parResp, err := m.client.SendParAuthRequest( + ctx, + authServerURL, + authMeta, + handle, + m.allowedScope, + dpopKey, + ) + if err != nil { + fmt.Printf("OAuth: PAR failed: %v\n", err) + http.Error(w, fmt.Sprintf("PAR request failed: %v", err), http.StatusBadRequest) + return + } + fmt.Printf("OAuth: PAR success, request_uri: %s\n", parResp.RequestUri) + + // Save pending auth state + pending := &PendingAuth{ + State: parResp.State, + PkceVerifier: parResp.PkceVerifier, + DpopPrivateJWK: string(dpopKeyBytes), + DpopNonce: parResp.DpopAuthserverNonce, + DID: did, + PdsURL: pdsURL, + AuthserverIss: authMeta.Issuer, + } + m.sessions.SavePending(parResp.State, pending) + + // Build authorization URL + authURL, err := url.Parse(authMeta.AuthorizationEndpoint) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid auth endpoint: %v", err), http.StatusInternalServerError) + return + } + + q := authURL.Query() + q.Set("client_id", m.clientID) + q.Set("request_uri", parResp.RequestUri) + authURL.RawQuery = q.Encode() + + fmt.Printf("OAuth: redirecting to: %s\n", authURL.String()) + + // Use JavaScript redirect to preserve browser security headers + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, ` + + + +Redirecting... + + +

Redirecting to authorization server...

+ + + +`, authURL.String(), authURL.String()) +} + +// HandleCallback handles the OAuth callback +func (m *OAuthManager) HandleCallback(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + // Get callback parameters + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + iss := r.URL.Query().Get("iss") + errorParam := r.URL.Query().Get("error") + errorDesc := r.URL.Query().Get("error_description") + + // Check for errors from auth server + if errorParam != "" { + http.Error(w, fmt.Sprintf("Authorization error: %s - %s", errorParam, errorDesc), http.StatusBadRequest) + return + } + + if code == "" || state == "" { + http.Error(w, "Missing code or state parameter", http.StatusBadRequest) + return + } + + // Retrieve pending auth state + pending := m.sessions.GetPending(state) + if pending == nil { + http.Error(w, "Invalid or expired state", http.StatusBadRequest) + return + } + + // Verify issuer matches + if iss != "" && iss != pending.AuthserverIss { + http.Error(w, "Issuer mismatch", http.StatusBadRequest) + return + } + + // Parse DPoP private key + dpopKey, err := helpers.ParseJWKFromBytes([]byte(pending.DpopPrivateJWK)) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to parse DPoP key: %v", err), http.StatusInternalServerError) + return + } + + // Exchange code for tokens + tokenResp, err := m.client.InitialTokenRequest( + ctx, + code, + pending.AuthserverIss, + pending.PkceVerifier, + pending.DpopNonce, + dpopKey, + ) + if err != nil { + http.Error(w, fmt.Sprintf("Token exchange failed: %v", err), http.StatusBadRequest) + return + } + + // Verify scope + if tokenResp.Scope != m.allowedScope { + http.Error(w, fmt.Sprintf("Invalid scope: expected %s, got %s", m.allowedScope, tokenResp.Scope), http.StatusForbidden) + return + } + + // Resolve DID to handle + handle, err := resolveDIDToHandle(ctx, tokenResp.Sub) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusInternalServerError) + return + } + + // CRITICAL: Verify user is allowed + if !allowedHandles[handle] { + fmt.Printf("OAuth: access denied for handle: %s\n", handle) + http.Error(w, "Access denied.", http.StatusForbidden) + return + } + + // Create session + session, err := m.sessions.CreateSession(tokenResp.Sub, handle) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to create session: %v", err), http.StatusInternalServerError) + return + } + + // Store token info in session + session.AccessToken = tokenResp.AccessToken + session.RefreshToken = tokenResp.RefreshToken + session.TokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + session.DpopPrivateJWK = pending.DpopPrivateJWK + session.DpopAuthserverNonce = tokenResp.DpopAuthserverNonce + session.PdsURL = pending.PdsURL + session.AuthserverIss = pending.AuthserverIss + m.sessions.UpdateSession(session) + + // Set session cookie + if err := m.SetSessionCookie(w, session.ID); err != nil { + http.Error(w, fmt.Sprintf("Failed to set cookie: %v", err), http.StatusInternalServerError) + return + } + + // Redirect to dashboard + http.Redirect(w, r, "/dashboard", http.StatusFound) +} + +// HandleLogout clears the session and redirects to login +func (m *OAuthManager) HandleLogout(w http.ResponseWriter, r *http.Request) { + // Get current session + session := m.GetSessionFromCookie(r) + if session != nil { + // Delete session from store + m.sessions.DeleteSession(session.ID) + } + + // Clear cookie + m.ClearSessionCookie(w) + + // Handle API vs browser request + if r.Method == http.MethodPost || isAPIRequest(r) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "logged out", + }) + return + } + + // Redirect to login for browser requests + http.Redirect(w, r, "/auth/login", http.StatusFound) +} + +// HandleSessionInfo returns current session info (for API calls) +func (m *OAuthManager) HandleSessionInfo(w http.ResponseWriter, r *http.Request) { + session := m.GetSessionFromCookie(r) + if session == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "not authenticated", + }) + return + } + + info := &SessionInfo{ + DID: session.DID, + Handle: session.Handle, + ExpiresAt: session.ExpiresAt, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(info) +} + +const loginPageHTML = ` + + + Sign In - 1440.news Dashboard + + + + + +
+ +

Dashboard Authentication

+ + +
+ + + + +` diff --git a/oauth_middleware.go b/oauth_middleware.go new file mode 100644 index 0000000..a905260 --- /dev/null +++ b/oauth_middleware.go @@ -0,0 +1,124 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/haileyok/atproto-oauth-golang/helpers" +) + +// RequireAuth is middleware that protects routes requiring authentication +func (m *OAuthManager) RequireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := m.GetSessionFromCookie(r) + if session == nil { + // Check if this is an API call (wants JSON response) + if isAPIRequest(r) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "unauthorized", + }) + return + } + // Redirect to login for browser requests + http.Redirect(w, r, "/auth/login", http.StatusFound) + return + } + + // Check if token needs refresh (refresh when within 5 minutes of expiry) + if time.Until(session.TokenExpiry) < 5*time.Minute { + if err := m.refreshToken(r.Context(), session); err != nil { + // Token refresh failed - clear session and redirect to login + m.sessions.DeleteSession(session.ID) + m.ClearSessionCookie(w) + + if isAPIRequest(r) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "session expired", + }) + return + } + http.Redirect(w, r, "/auth/login", http.StatusFound) + return + } + } + + // Add session to request context + ctx := context.WithValue(r.Context(), sessionContextKey, session) + next(w, r.WithContext(ctx)) + } +} + +// sessionContextKey is the context key for the OAuth session +type contextKey string + +const sessionContextKey contextKey = "oauth_session" + +// GetSession retrieves the session from request context +func GetSession(r *http.Request) *OAuthSession { + session, _ := r.Context().Value(sessionContextKey).(*OAuthSession) + return session +} + +// isAPIRequest checks if the request expects JSON response +func isAPIRequest(r *http.Request) bool { + // Check Accept header + accept := r.Header.Get("Accept") + if strings.Contains(accept, "application/json") { + return true + } + + // Check URL path + if strings.HasPrefix(r.URL.Path, "/api/") { + return true + } + + // Check X-Requested-With header (for AJAX) + if r.Header.Get("X-Requested-With") == "XMLHttpRequest" { + return true + } + + return false +} + +// refreshToken refreshes the OAuth access token +func (m *OAuthManager) refreshToken(ctx context.Context, session *OAuthSession) error { + if session.RefreshToken == "" { + return nil // No refresh token available + } + + // Parse the DPoP private key + dpopKey, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJWK)) + if err != nil { + return err + } + + // Refresh the token + tokenResp, err := m.client.RefreshTokenRequest( + ctx, + session.RefreshToken, + session.AuthserverIss, + session.DpopAuthserverNonce, + dpopKey, + ) + if err != nil { + return err + } + + // Update session with new tokens + session.AccessToken = tokenResp.AccessToken + session.RefreshToken = tokenResp.RefreshToken + session.TokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + session.DpopAuthserverNonce = tokenResp.DpopAuthserverNonce + + // Save updated session + m.sessions.UpdateSession(session) + + return nil +} diff --git a/oauth_session.go b/oauth_session.go new file mode 100644 index 0000000..ad924bc --- /dev/null +++ b/oauth_session.go @@ -0,0 +1,297 @@ +package main + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" +) + +const ( + sessionCookieName = "1440_session" + sessionTTL = 24 * time.Hour +) + +// OAuthSession stores the OAuth session state for a user +type OAuthSession struct { + ID string `json:"id"` + DID string `json:"did"` + Handle string `json:"handle"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + + // OAuth tokens (stored server-side only) + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenExpiry time.Time `json:"token_expiry"` + + // DPoP state + DpopPrivateJWK string `json:"dpop_private_jwk"` + DpopAuthserverNonce string `json:"dpop_authserver_nonce"` + DpopPdsNonce string `json:"dpop_pds_nonce"` + + // Auth server info + PdsURL string `json:"pds_url"` + AuthserverIss string `json:"authserver_iss"` +} + +// PendingAuth stores state during the OAuth flow (before callback) +type PendingAuth struct { + State string `json:"state"` + PkceVerifier string `json:"pkce_verifier"` + DpopPrivateJWK string `json:"dpop_private_jwk"` + DpopNonce string `json:"dpop_nonce"` + DID string `json:"did"` + PdsURL string `json:"pds_url"` + AuthserverIss string `json:"authserver_iss"` + CreatedAt time.Time `json:"created_at"` +} + +// SessionStore manages sessions in memory +type SessionStore struct { + sessions map[string]*OAuthSession + pending map[string]*PendingAuth // keyed by state + mu sync.RWMutex + cleanupOnce sync.Once +} + +// NewSessionStore creates a new session store +func NewSessionStore() *SessionStore { + s := &SessionStore{ + sessions: make(map[string]*OAuthSession), + pending: make(map[string]*PendingAuth), + } + s.startCleanup() + return s +} + +// startCleanup starts a background goroutine to clean up expired sessions +func (s *SessionStore) startCleanup() { + s.cleanupOnce.Do(func() { + go func() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for range ticker.C { + s.cleanup() + } + }() + }) +} + +// cleanup removes expired sessions and pending auths +func (s *SessionStore) cleanup() { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + + // Clean up expired sessions + for id, sess := range s.sessions { + if now.After(sess.ExpiresAt) { + delete(s.sessions, id) + } + } + + // Clean up old pending auths (10 minute timeout) + for state, pending := range s.pending { + if now.Sub(pending.CreatedAt) > 10*time.Minute { + delete(s.pending, state) + } + } +} + +// CreateSession creates a new session and returns it +func (s *SessionStore) CreateSession(did, handle string) (*OAuthSession, error) { + id, err := generateRandomID() + if err != nil { + return nil, err + } + + now := time.Now() + session := &OAuthSession{ + ID: id, + DID: did, + Handle: handle, + CreatedAt: now, + ExpiresAt: now.Add(sessionTTL), + } + + s.mu.Lock() + s.sessions[id] = session + s.mu.Unlock() + + return session, nil +} + +// GetSession retrieves a session by ID +func (s *SessionStore) GetSession(id string) *OAuthSession { + s.mu.RLock() + defer s.mu.RUnlock() + + session, ok := s.sessions[id] + if !ok || time.Now().After(session.ExpiresAt) { + return nil + } + return session +} + +// UpdateSession updates a session +func (s *SessionStore) UpdateSession(session *OAuthSession) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessions[session.ID] = session +} + +// DeleteSession removes a session +func (s *SessionStore) DeleteSession(id string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, id) +} + +// SavePending saves pending OAuth state +func (s *SessionStore) SavePending(state string, pending *PendingAuth) { + s.mu.Lock() + defer s.mu.Unlock() + pending.CreatedAt = time.Now() + s.pending[state] = pending +} + +// GetPending retrieves and removes pending OAuth state +func (s *SessionStore) GetPending(state string) *PendingAuth { + s.mu.Lock() + defer s.mu.Unlock() + + pending, ok := s.pending[state] + if ok { + delete(s.pending, state) + } + return pending +} + +// generateRandomID generates a random session ID +func generateRandomID() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +// encryptSessionID encrypts a session ID using AES-256-GCM +func encryptSessionID(sessionID string, key []byte) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(sessionID), nil) + return base64.URLEncoding.EncodeToString(ciphertext), nil +} + +// decryptSessionID decrypts a session ID using AES-256-GCM +func decryptSessionID(encrypted string, key []byte) (string, error) { + ciphertext, err := base64.URLEncoding.DecodeString(encrypted) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + if len(ciphertext) < gcm.NonceSize() { + return "", fmt.Errorf("ciphertext too short") + } + + nonce, ciphertext := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} + +// SetSessionCookie sets an encrypted session cookie +func (m *OAuthManager) SetSessionCookie(w http.ResponseWriter, sessionID string) error { + encrypted, err := encryptSessionID(sessionID, m.cookieSecret) + if err != nil { + return err + } + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: encrypted, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(sessionTTL.Seconds()), + }) + + return nil +} + +// GetSessionFromCookie retrieves the session from the request cookie +func (m *OAuthManager) GetSessionFromCookie(r *http.Request) *OAuthSession { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + return nil + } + + sessionID, err := decryptSessionID(cookie.Value, m.cookieSecret) + if err != nil { + return nil + } + + return m.sessions.GetSession(sessionID) +} + +// ClearSessionCookie removes the session cookie +func (m *OAuthManager) ClearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +// SessionInfo is the public session info returned to the client +type SessionInfo struct { + DID string `json:"did"` + Handle string `json:"handle"` + ExpiresAt time.Time `json:"expires_at"` +} + +// MarshalJSON converts SessionInfo to JSON +func (s *SessionInfo) MarshalJSON() ([]byte, error) { + type Alias SessionInfo + return json.Marshal((*Alias)(s)) +} diff --git a/routes.go b/routes.go index ee4beb3..9ce1f4c 100644 --- a/routes.go +++ b/routes.go @@ -3,13 +3,58 @@ package main import ( "fmt" "net/http" + "os" "strings" ) func (c *Crawler) StartDashboard(addr string) error { - http.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) { + // Determine base URL for OAuth + baseURL := os.Getenv("OAUTH_BASE_URL") + if baseURL == "" { + // Default based on whether we're in production + if strings.Contains(addr, "0.0.0.0") { + baseURL = "https://app.1440.news" + } else { + baseURL = "http://" + addr + } + } + + // Initialize OAuth manager + oauthCfg, err := LoadOAuthConfig(baseURL) + var oauth *OAuthManager + if err != nil { + fmt.Printf("OAuth not configured: %v (dashboard will be unprotected)\n", err) + } else { + oauth, err = NewOAuthManager(*oauthCfg) + if err != nil { + fmt.Printf("Failed to initialize OAuth: %v (dashboard will be unprotected)\n", err) + oauth = nil + } else { + fmt.Println("OAuth authentication enabled for dashboard") + } + } + + // OAuth endpoints (always public) + if oauth != nil { + http.HandleFunc("/.well-known/oauth-client-metadata", oauth.HandleClientMetadata) + http.HandleFunc("/.well-known/jwks.json", oauth.HandleJWKS) + http.HandleFunc("/auth/login", oauth.HandleLogin) + http.HandleFunc("/auth/callback", oauth.HandleCallback) + http.HandleFunc("/auth/logout", oauth.HandleLogout) + http.HandleFunc("/auth/session", oauth.HandleSessionInfo) + } + + // Helper to wrap handlers with auth if OAuth is enabled + withAuth := func(h http.HandlerFunc) http.HandlerFunc { + if oauth != nil { + return oauth.RequireAuth(h) + } + return h + } + + http.HandleFunc("/dashboard", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleDashboard(w, r) - }) + })) // Root handler for url.1440.news short URLs and 1440.news accounts directory http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { @@ -43,117 +88,117 @@ func (c *Crawler) StartDashboard(addr string) error { http.NotFound(w, r) }) - http.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) { + http.HandleFunc("/api/stats", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIStats(w, r) - }) - http.HandleFunc("/api/allDomains", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/allDomains", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIAllDomains(w, r) - }) - http.HandleFunc("/api/domainFeeds", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/domainFeeds", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIDomainFeeds(w, r) - }) - http.HandleFunc("/api/feedInfo", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/feedInfo", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIFeedInfo(w, r) - }) - http.HandleFunc("/api/feedItems", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/feedItems", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIFeedItems(w, r) - }) - http.HandleFunc("/api/search", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/search", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPISearch(w, r) - }) - http.HandleFunc("/api/tlds", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/tlds", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPITLDs(w, r) - }) - http.HandleFunc("/api/tldDomains", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/tldDomains", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPITLDDomains(w, r) - }) - http.HandleFunc("/api/revisitDomain", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/revisitDomain", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIRevisitDomain(w, r) - }) - http.HandleFunc("/api/priorityCrawl", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/priorityCrawl", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIPriorityCrawl(w, r) - }) - http.HandleFunc("/api/checkFeed", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/checkFeed", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPICheckFeed(w, r) - }) - http.HandleFunc("/api/domainsByStatus", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/domainsByStatus", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIDomainsByStatus(w, r) - }) - http.HandleFunc("/api/feedsByStatus", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/feedsByStatus", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIFeedsByStatus(w, r) - }) - http.HandleFunc("/api/domains", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/domains", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIDomains(w, r) - }) - http.HandleFunc("/api/feeds", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/feeds", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIFeeds(w, r) - }) - http.HandleFunc("/api/setDomainStatus", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/setDomainStatus", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPISetDomainStatus(w, r) - }) - http.HandleFunc("/api/filter", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/filter", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIFilter(w, r) - }) - http.HandleFunc("/api/enablePublish", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/enablePublish", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIEnablePublish(w, r) - }) - http.HandleFunc("/api/disablePublish", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/disablePublish", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIDisablePublish(w, r) - }) - http.HandleFunc("/api/publishEnabled", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/publishEnabled", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIPublishEnabled(w, r) - }) - http.HandleFunc("/api/publishDenied", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/publishDenied", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIPublishDenied(w, r) - }) - http.HandleFunc("/api/publishCandidates", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/publishCandidates", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIPublishCandidates(w, r) - }) - http.HandleFunc("/api/setPublishStatus", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/setPublishStatus", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPISetPublishStatus(w, r) - }) - http.HandleFunc("/api/unpublishedItems", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/unpublishedItems", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIUnpublishedItems(w, r) - }) - http.HandleFunc("/api/testPublish", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/testPublish", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPITestPublish(w, r) - }) - http.HandleFunc("/api/deriveHandle", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/deriveHandle", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIDeriveHandle(w, r) - }) - http.HandleFunc("/api/publishFeed", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/publishFeed", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIPublishFeed(w, r) - }) - http.HandleFunc("/api/createAccount", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/createAccount", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPICreateAccount(w, r) - }) - http.HandleFunc("/api/publishFeedFull", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/publishFeedFull", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIPublishFeedFull(w, r) - }) - http.HandleFunc("/api/updateProfile", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/updateProfile", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIUpdateProfile(w, r) - }) - http.HandleFunc("/api/languages", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/languages", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPILanguages(w, r) - }) - http.HandleFunc("/api/denyDomain", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/denyDomain", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIDenyDomain(w, r) - }) - http.HandleFunc("/api/undenyDomain", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/undenyDomain", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIUndenyDomain(w, r) - }) - http.HandleFunc("/api/dropDomain", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/dropDomain", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIDropDomain(w, r) - }) - http.HandleFunc("/api/tldStats", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/tldStats", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPITLDStats(w, r) - }) - http.HandleFunc("/api/resetAllPublishing", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/resetAllPublishing", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIResetAllPublishing(w, r) - }) - http.HandleFunc("/api/refreshProfiles", func(w http.ResponseWriter, r *http.Request) { + })) + http.HandleFunc("/api/refreshProfiles", withAuth(func(w http.ResponseWriter, r *http.Request) { c.handleAPIRefreshProfiles(w, r) - }) + })) http.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/static/", http.FileServer(http.Dir("static"))).ServeHTTP(w, r) })