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:
clawbot
2026-02-19 13:47:39 -08:00
committed by user
parent 3a4e999382
commit 7387ba6b5c
8 changed files with 809 additions and 12 deletions

View File

@@ -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.
@@ -370,18 +373,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
}
@@ -434,3 +455,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
}