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.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user