Files
gitea-mobile/internal/auth/cookie.go
agent-company 703b2fafb0 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>
2026-03-26 04:05:31 +00:00

105 lines
2.5 KiB
Go

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))
}