From 703b2fafb02b33173ec97001d152ce56dba252ea Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 04:05:31 +0000 Subject: [PATCH] feat: add env-based configuration and token-in-cookie auth Implement 12-factor configuration via environment variables and token-in-cookie authentication for Gitea API access. - internal/config/config.go: reads GITEA_URL, GITEA_TOKEN, LISTEN_ADDR, SESSION_SECRET from environment with validation - internal/auth/cookie.go: HMAC-signed HTTP-only cookie for storing Gitea API tokens (Secure, SameSite=Strict) - internal/middleware/auth.go: extracts token from cookie, injects into request context, redirects unauthenticated users to /settings - internal/middleware/logging.go: structured JSON request logging - internal/handlers/settings.go: settings page for entering/removing Gitea API token with mobile-first dark UI - cmd/server/main.go: integrated config, auth middleware, and settings Includes unit tests for config loading, cookie signing/verification, and auth middleware bypass/redirect logic. Closes leeworks-agents/gitea-mobile#2 Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/server/main.go | 44 ++++++-- internal/auth/cookie.go | 104 ++++++++++++++++++ internal/auth/cookie_test.go | 89 ++++++++++++++++ internal/config/.gitkeep | 0 internal/config/config.go | 50 +++++++++ internal/config/config_test.go | 89 ++++++++++++++++ internal/handlers/.gitkeep | 0 internal/handlers/settings.go | 177 +++++++++++++++++++++++++++++++ internal/middleware/.gitkeep | 0 internal/middleware/auth.go | 54 ++++++++++ internal/middleware/auth_test.go | 85 +++++++++++++++ internal/middleware/logging.go | 38 +++++++ 12 files changed, 724 insertions(+), 6 deletions(-) create mode 100644 internal/auth/cookie.go create mode 100644 internal/auth/cookie_test.go delete mode 100644 internal/config/.gitkeep create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go delete mode 100644 internal/handlers/.gitkeep create mode 100644 internal/handlers/settings.go delete mode 100644 internal/middleware/.gitkeep create mode 100644 internal/middleware/auth.go create mode 100644 internal/middleware/auth_test.go create mode 100644 internal/middleware/logging.go diff --git a/cmd/server/main.go b/cmd/server/main.go index d863276..82b2554 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,30 +3,62 @@ package main import ( "fmt" "log" + "log/slog" "net/http" "os" + "strings" + + "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/config" + "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/handlers" + "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware" ) func main() { - addr := os.Getenv("LISTEN_ADDR") - if addr == "" { - addr = ":8080" + // Set up structured logging. + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }))) + + cfg, err := config.Load() + if err != nil { + log.Fatalf("configuration error: %v", err) } + // Determine if cookies should be marked Secure (disable for local dev). + secureCookies := !strings.HasPrefix(cfg.ListenAddr, "localhost") && + !strings.HasPrefix(cfg.ListenAddr, "127.0.0.1") + mux := http.NewServeMux() + // Health endpoint (no auth required). mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprintln(w, "ok") }) + // Settings handler. + settingsHandler := &handlers.SettingsHandler{ + SessionSecret: cfg.SessionSecret, + SecureCookies: secureCookies, + } + mux.HandleFunc("/settings", settingsHandler.ServeHTTP) + + // Static file server. + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + + // Placeholder dashboard (will be replaced in issue #4/#5). mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintln(w, "

Gitea Mobile

Coming soon.

") + fmt.Fprintln(w, "

Gitea Mobile

Dashboard coming soon.

") }) - log.Printf("listening on %s", addr) - if err := http.ListenAndServe(addr, mux); err != nil { + // Apply middleware chain: logging -> auth. + var handler http.Handler = mux + handler = middleware.Auth(cfg.SessionSecret)(handler) + handler = middleware.Logging()(handler) + + slog.Info("server starting", "addr", cfg.ListenAddr, "gitea_url", cfg.GiteaURL) + if err := http.ListenAndServe(cfg.ListenAddr, handler); err != nil { log.Fatalf("server error: %v", err) } } diff --git a/internal/auth/cookie.go b/internal/auth/cookie.go new file mode 100644 index 0000000..f6c3e22 --- /dev/null +++ b/internal/auth/cookie.go @@ -0,0 +1,104 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net/http" + "strings" + "time" +) + +const ( + cookieName = "gitea_token" + cookieMaxAge = 30 * 24 * 60 * 60 // 30 days in seconds +) + +var ( + ErrInvalidSignature = errors.New("invalid cookie signature") + ErrMalformedCookie = errors.New("malformed cookie value") +) + +// SetTokenCookie stores a Gitea API token in a signed HTTP-only cookie. +func SetTokenCookie(w http.ResponseWriter, token string, secret string, secure bool) { + signed := sign(token, secret) + + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: signed, + Path: "/", + MaxAge: cookieMaxAge, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteStrictMode, + Expires: time.Now().Add(30 * 24 * time.Hour), + }) +} + +// ClearTokenCookie removes the token cookie. +func ClearTokenCookie(w http.ResponseWriter, secure bool) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteStrictMode, + }) +} + +// GetToken extracts and verifies the Gitea API token from the request cookie. +// Returns the token string or an error if the cookie is missing or invalid. +func GetToken(r *http.Request, secret string) (string, error) { + cookie, err := r.Cookie(cookieName) + if err != nil { + return "", err + } + + token, err := verify(cookie.Value, secret) + if err != nil { + return "", err + } + + return token, nil +} + +// sign creates a signed cookie value: base64(token).base64(hmac-sha256(token)) +func sign(token string, secret string) string { + encodedToken := base64.URLEncoding.EncodeToString([]byte(token)) + mac := computeHMAC(encodedToken, secret) + return fmt.Sprintf("%s.%s", encodedToken, mac) +} + +// verify checks the HMAC signature and returns the original token. +func verify(signed string, secret string) (string, error) { + parts := strings.SplitN(signed, ".", 2) + if len(parts) != 2 { + return "", ErrMalformedCookie + } + + encodedToken := parts[0] + providedMAC := parts[1] + expectedMAC := computeHMAC(encodedToken, secret) + + if !hmac.Equal([]byte(providedMAC), []byte(expectedMAC)) { + return "", ErrInvalidSignature + } + + tokenBytes, err := base64.URLEncoding.DecodeString(encodedToken) + if err != nil { + return "", ErrMalformedCookie + } + + return string(tokenBytes), nil +} + +// computeHMAC generates a base64-encoded HMAC-SHA256 of the given data. +func computeHMAC(data string, secret string) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(data)) + return base64.URLEncoding.EncodeToString(h.Sum(nil)) +} diff --git a/internal/auth/cookie_test.go b/internal/auth/cookie_test.go new file mode 100644 index 0000000..158d532 --- /dev/null +++ b/internal/auth/cookie_test.go @@ -0,0 +1,89 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +const testSecret = "test-secret-that-is-at-least-32-chars-long" + +func TestSignAndVerify(t *testing.T) { + token := "abc123-gitea-token" + signed := sign(token, testSecret) + + got, err := verify(signed, testSecret) + if err != nil { + t.Fatalf("verify failed: %v", err) + } + if got != token { + t.Errorf("got %q, want %q", got, token) + } +} + +func TestVerify_InvalidSignature(t *testing.T) { + token := "abc123-gitea-token" + signed := sign(token, testSecret) + + _, err := verify(signed, "wrong-secret-that-is-at-least-32-chars") + if err != ErrInvalidSignature { + t.Errorf("expected ErrInvalidSignature, got %v", err) + } +} + +func TestVerify_MalformedCookie(t *testing.T) { + _, err := verify("no-dot-separator", testSecret) + if err != ErrMalformedCookie { + t.Errorf("expected ErrMalformedCookie, got %v", err) + } +} + +func TestSetAndGetToken(t *testing.T) { + token := "my-gitea-api-token" + + // Create a response recorder to capture the Set-Cookie header. + w := httptest.NewRecorder() + SetTokenCookie(w, token, testSecret, false) + + // Extract the cookie from the response. + resp := w.Result() + cookies := resp.Cookies() + if len(cookies) == 0 { + t.Fatal("expected a cookie to be set") + } + + // Create a new request with the cookie. + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookies[0]) + + got, err := GetToken(req, testSecret) + if err != nil { + t.Fatalf("GetToken failed: %v", err) + } + if got != token { + t.Errorf("got %q, want %q", got, token) + } +} + +func TestGetToken_NoCookie(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + + _, err := GetToken(req, testSecret) + if err == nil { + t.Fatal("expected error for missing cookie") + } +} + +func TestClearTokenCookie(t *testing.T) { + w := httptest.NewRecorder() + ClearTokenCookie(w, false) + + resp := w.Result() + cookies := resp.Cookies() + if len(cookies) == 0 { + t.Fatal("expected a cookie to be set") + } + if cookies[0].MaxAge != -1 { + t.Errorf("MaxAge = %d, want -1", cookies[0].MaxAge) + } +} diff --git a/internal/config/.gitkeep b/internal/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8b13e59 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,50 @@ +package config + +import ( + "fmt" + "os" +) + +// Config holds application configuration loaded from environment variables. +type Config struct { + // GiteaURL is the base URL of the Gitea instance. + GiteaURL string + + // GiteaToken is the default API token (optional; users can set their own via cookie). + GiteaToken string + + // ListenAddr is the server listen address. + ListenAddr string + + // SessionSecret is the HMAC key for signing session cookies. + SessionSecret string +} + +// Load reads configuration from environment variables. +// Returns an error if required variables are missing. +func Load() (*Config, error) { + cfg := &Config{ + GiteaURL: os.Getenv("GITEA_URL"), + GiteaToken: os.Getenv("GITEA_TOKEN"), + ListenAddr: os.Getenv("LISTEN_ADDR"), + SessionSecret: os.Getenv("SESSION_SECRET"), + } + + if cfg.ListenAddr == "" { + cfg.ListenAddr = ":8080" + } + + if cfg.GiteaURL == "" { + return nil, fmt.Errorf("GITEA_URL environment variable is required") + } + + if cfg.SessionSecret == "" { + return nil, fmt.Errorf("SESSION_SECRET environment variable is required") + } + + if len(cfg.SessionSecret) < 32 { + return nil, fmt.Errorf("SESSION_SECRET must be at least 32 characters") + } + + return cfg, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..22b9e1f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,89 @@ +package config + +import ( + "os" + "testing" +) + +func TestLoad_Success(t *testing.T) { + os.Setenv("GITEA_URL", "https://gitea.example.com") + os.Setenv("SESSION_SECRET", "test-secret-that-is-at-least-32-chars-long") + os.Setenv("LISTEN_ADDR", ":9090") + os.Setenv("GITEA_TOKEN", "test-token") + defer func() { + os.Unsetenv("GITEA_URL") + os.Unsetenv("SESSION_SECRET") + os.Unsetenv("LISTEN_ADDR") + os.Unsetenv("GITEA_TOKEN") + }() + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.GiteaURL != "https://gitea.example.com" { + t.Errorf("GiteaURL = %q, want %q", cfg.GiteaURL, "https://gitea.example.com") + } + if cfg.ListenAddr != ":9090" { + t.Errorf("ListenAddr = %q, want %q", cfg.ListenAddr, ":9090") + } + if cfg.GiteaToken != "test-token" { + t.Errorf("GiteaToken = %q, want %q", cfg.GiteaToken, "test-token") + } +} + +func TestLoad_DefaultListenAddr(t *testing.T) { + os.Setenv("GITEA_URL", "https://gitea.example.com") + os.Setenv("SESSION_SECRET", "test-secret-that-is-at-least-32-chars-long") + os.Unsetenv("LISTEN_ADDR") + defer func() { + os.Unsetenv("GITEA_URL") + os.Unsetenv("SESSION_SECRET") + }() + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.ListenAddr != ":8080" { + t.Errorf("ListenAddr = %q, want %q", cfg.ListenAddr, ":8080") + } +} + +func TestLoad_MissingGiteaURL(t *testing.T) { + os.Unsetenv("GITEA_URL") + os.Setenv("SESSION_SECRET", "test-secret-that-is-at-least-32-chars-long") + defer os.Unsetenv("SESSION_SECRET") + + _, err := Load() + if err == nil { + t.Fatal("expected error for missing GITEA_URL") + } +} + +func TestLoad_MissingSessionSecret(t *testing.T) { + os.Setenv("GITEA_URL", "https://gitea.example.com") + os.Unsetenv("SESSION_SECRET") + defer os.Unsetenv("GITEA_URL") + + _, err := Load() + if err == nil { + t.Fatal("expected error for missing SESSION_SECRET") + } +} + +func TestLoad_ShortSessionSecret(t *testing.T) { + os.Setenv("GITEA_URL", "https://gitea.example.com") + os.Setenv("SESSION_SECRET", "tooshort") + defer func() { + os.Unsetenv("GITEA_URL") + os.Unsetenv("SESSION_SECRET") + }() + + _, err := Load() + if err == nil { + t.Fatal("expected error for short SESSION_SECRET") + } +} diff --git a/internal/handlers/.gitkeep b/internal/handlers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go new file mode 100644 index 0000000..0c7f721 --- /dev/null +++ b/internal/handlers/settings.go @@ -0,0 +1,177 @@ +package handlers + +import ( + "html/template" + "net/http" + "strings" + + "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/auth" + "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware" +) + +var settingsTemplate = template.Must(template.New("settings").Parse(` + + + + + Settings — Gitea Mobile + + + +

Settings

+ + {{if .Message}} +
{{.Message}}
+ {{end}} + + {{if .HasToken}} +
+

Status: Connected

+

A Gitea API token is configured.

+
+ + +
+
+ {{end}} + +
+
+ + + +

Generate a token at your Gitea instance under Settings → Applications.

+ +
+
+ + {{if .HasToken}} +

+ Back to Dashboard +

+ {{end}} + +`)) + +// SettingsHandler handles GET and POST requests for the settings page. +type SettingsHandler struct { + SessionSecret string + SecureCookies bool +} + +type settingsData struct { + HasToken bool + Message string + MessageType string // "success", "error", "info" +} + +// ServeHTTP handles the settings page. +func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + h.handleGet(w, r) + case http.MethodPost: + h.handlePost(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *SettingsHandler) handleGet(w http.ResponseWriter, r *http.Request) { + hasToken := false + if token := middleware.TokenFromContext(r.Context()); token != "" { + hasToken = true + } else if _, err := auth.GetToken(r, h.SessionSecret); err == nil { + hasToken = true + } + + data := settingsData{HasToken: hasToken} + w.Header().Set("Content-Type", "text/html; charset=utf-8") + settingsTemplate.Execute(w, data) +} + +func (h *SettingsHandler) handlePost(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + h.renderWithMessage(w, r, "Failed to parse form.", "error") + return + } + + action := r.FormValue("action") + + switch action { + case "logout": + auth.ClearTokenCookie(w, h.SecureCookies) + h.renderWithMessage(w, r, "Token removed successfully.", "success") + return + + case "save": + token := strings.TrimSpace(r.FormValue("token")) + if token == "" { + h.renderWithMessage(w, r, "Token cannot be empty.", "error") + return + } + + auth.SetTokenCookie(w, token, h.SessionSecret, h.SecureCookies) + // After saving, redirect to dashboard. + http.Redirect(w, r, "/", http.StatusSeeOther) + return + + default: + h.renderWithMessage(w, r, "Unknown action.", "error") + } +} + +func (h *SettingsHandler) renderWithMessage(w http.ResponseWriter, r *http.Request, msg, msgType string) { + hasToken := false + if _, err := auth.GetToken(r, h.SessionSecret); err == nil { + hasToken = true + } + + data := settingsData{ + HasToken: hasToken, + Message: msg, + MessageType: msgType, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + settingsTemplate.Execute(w, data) +} diff --git a/internal/middleware/.gitkeep b/internal/middleware/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..3d09f2c --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,54 @@ +package middleware + +import ( + "context" + "log/slog" + "net/http" + + "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/auth" +) + +// contextKey is a private type for context keys in this package. +type contextKey string + +const ( + // TokenContextKey is the context key for the Gitea API token. + TokenContextKey contextKey = "gitea_token" +) + +// TokenFromContext extracts the Gitea API token from the request context. +func TokenFromContext(ctx context.Context) string { + token, _ := ctx.Value(TokenContextKey).(string) + return token +} + +// Auth returns middleware that checks for a valid token cookie. +// Unauthenticated requests are redirected to the settings page. +// The /health, /settings, and /static/ paths are exempt from auth. +func Auth(sessionSecret string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip auth for exempt paths. + path := r.URL.Path + if path == "/health" || path == "/settings" || hasPrefix(path, "/static/") { + next.ServeHTTP(w, r) + return + } + + token, err := auth.GetToken(r, sessionSecret) + if err != nil || token == "" { + slog.Debug("unauthenticated request, redirecting to settings", "path", path, "error", err) + http.Redirect(w, r, "/settings", http.StatusSeeOther) + return + } + + // Inject token into request context. + ctx := context.WithValue(r.Context(), TokenContextKey, token) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func hasPrefix(s, prefix string) bool { + return len(s) >= len(prefix) && s[:len(prefix)] == prefix +} diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go new file mode 100644 index 0000000..d4cb61b --- /dev/null +++ b/internal/middleware/auth_test.go @@ -0,0 +1,85 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/auth" +) + +const testSecret = "test-secret-that-is-at-least-32-chars-long" + +func TestAuth_HealthBypass(t *testing.T) { + handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } +} + +func TestAuth_SettingsBypass(t *testing.T) { + handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/settings", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } +} + +func TestAuth_RedirectWithoutToken(t *testing.T) { + handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want %d", w.Code, http.StatusSeeOther) + } + if loc := w.Header().Get("Location"); loc != "/settings" { + t.Errorf("Location = %q, want %q", loc, "/settings") + } +} + +func TestAuth_PassWithToken(t *testing.T) { + called := false + handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + token := TokenFromContext(r.Context()) + if token != "my-token" { + t.Errorf("token = %q, want %q", token, "my-token") + } + w.WriteHeader(http.StatusOK) + })) + + // Set a token cookie. + cookieW := httptest.NewRecorder() + auth.SetTokenCookie(cookieW, "my-token", testSecret, false) + cookie := cookieW.Result().Cookies()[0] + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookie) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if !called { + t.Error("next handler was not called") + } + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } +} diff --git a/internal/middleware/logging.go b/internal/middleware/logging.go new file mode 100644 index 0000000..7a1043d --- /dev/null +++ b/internal/middleware/logging.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "log/slog" + "net/http" + "time" +) + +// responseWriter wraps http.ResponseWriter to capture the status code. +type responseWriter struct { + http.ResponseWriter + statusCode int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +// Logging returns middleware that logs each HTTP request with structured logging. +func Logging() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + + next.ServeHTTP(rw, r) + + slog.Info("http request", + "method", r.Method, + "path", r.URL.Path, + "status", rw.statusCode, + "duration", time.Since(start).String(), + "remote", r.RemoteAddr, + ) + }) + } +}