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) <noreply@anthropic.com>
This commit is contained in:
agent-company
2026-03-26 04:05:31 +00:00
parent 69a1ab86c2
commit 703b2fafb0
12 changed files with 724 additions and 6 deletions
+104
View File
@@ -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))
}
+89
View File
@@ -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)
}
}