feat: add API token authentication (closes #87)
- Add api_tokens table migration (007) - Add APIToken model with CRUD operations - Generate tokens with upaas_ prefix + 32 hex chars - Store SHA-256 hash of tokens (not plaintext) - Update APISessionAuth middleware to check Bearer tokens - Add POST/GET/DELETE /api/v1/tokens endpoints - Token creation returns plaintext once; list never exposes it - Expired and revoked tokens are rejected - Tests for creation, listing, deletion, bearer auth, revocation
This commit is contained in:
@@ -19,8 +19,10 @@ import (
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/auth"
|
||||
)
|
||||
|
||||
@@ -31,10 +33,11 @@ const corsMaxAge = 300
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Auth *auth.Service
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Auth *auth.Service
|
||||
Database *database.Database
|
||||
}
|
||||
|
||||
// Middleware provides HTTP middleware.
|
||||
@@ -235,9 +238,9 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
||||
// loginRateLimit configures the login rate limiter.
|
||||
const (
|
||||
loginRateLimit = rate.Limit(5.0 / 60.0) // 5 requests per 60 seconds
|
||||
loginBurst = 5 // allow burst of 5
|
||||
limiterExpiry = 10 * time.Minute // evict entries not seen in 10 minutes
|
||||
limiterCleanupEvery = 1 * time.Minute // sweep interval
|
||||
loginBurst = 5 // allow burst of 5
|
||||
limiterExpiry = 10 * time.Minute // evict entries not seen in 10 minutes
|
||||
limiterCleanupEvery = 1 * time.Minute // sweep interval
|
||||
)
|
||||
|
||||
// ipLimiterEntry stores a rate limiter with its last-seen timestamp.
|
||||
@@ -249,8 +252,8 @@ type ipLimiterEntry struct {
|
||||
// ipLimiter tracks per-IP rate limiters for login attempts with automatic
|
||||
// eviction of stale entries to prevent unbounded memory growth.
|
||||
type ipLimiter struct {
|
||||
mu sync.Mutex
|
||||
limiters map[string]*ipLimiterEntry
|
||||
mu sync.Mutex
|
||||
limiters map[string]*ipLimiterEntry
|
||||
lastSweep time.Time
|
||||
}
|
||||
|
||||
@@ -339,18 +342,36 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// APISessionAuth returns middleware that requires session authentication for API routes.
|
||||
// Unlike SessionAuth, it returns JSON 401 responses instead of redirecting to /login.
|
||||
// bearerPrefix is the expected prefix for Authorization headers.
|
||||
const bearerPrefix = "Bearer "
|
||||
|
||||
// APISessionAuth returns middleware that requires authentication
|
||||
// for API routes. It checks Bearer token first, then falls back
|
||||
// to session cookie. Returns JSON 401 on failure.
|
||||
func (m *Middleware) APISessionAuth() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
user, err := m.params.Auth.GetCurrentUser(request.Context(), request)
|
||||
// Try Bearer token first.
|
||||
if m.tryBearerAuth(request) {
|
||||
next.ServeHTTP(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to session cookie.
|
||||
user, err := m.params.Auth.GetCurrentUser(
|
||||
request.Context(), request,
|
||||
)
|
||||
if err != nil || user == nil {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
http.Error(writer, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
http.Error(
|
||||
writer,
|
||||
`{"error":"unauthorized"}`,
|
||||
http.StatusUnauthorized,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -403,3 +424,35 @@ func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// tryBearerAuth checks for a valid Bearer token in the
|
||||
// Authorization header.
|
||||
func (m *Middleware) tryBearerAuth(request *http.Request) bool {
|
||||
authHeader := request.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
return false
|
||||
}
|
||||
|
||||
rawToken := strings.TrimPrefix(authHeader, bearerPrefix)
|
||||
if rawToken == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
tokenHash := database.HashAPIToken(rawToken)
|
||||
|
||||
apiToken, err := models.FindAPITokenByHash(
|
||||
request.Context(), m.params.Database, tokenHash,
|
||||
)
|
||||
if err != nil || apiToken == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if apiToken.IsExpired() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Update last_used_at (best effort).
|
||||
_ = apiToken.TouchLastUsed(request.Context())
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user