feat: add JSON API with token auth (closes #69)
- Add API token model with SHA-256 hashed tokens
- Add migration 006_add_api_tokens.sql
- Add Bearer token auth middleware
- Add API endpoints under /api/v1/:
- GET /whoami
- POST /tokens (create new API token)
- GET /apps (list all apps)
- POST /apps (create app)
- GET /apps/{id} (get app)
- DELETE /apps/{id} (delete app)
- POST /apps/{id}/deploy (trigger deployment)
- GET /apps/{id}/deployments (list deployments)
- Add comprehensive tests for all API endpoints
- All tests pass, zero lint issues
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
@@ -19,22 +20,28 @@ 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"
|
||||
)
|
||||
|
||||
// corsMaxAge is the maximum age for CORS preflight responses in seconds.
|
||||
const corsMaxAge = 300
|
||||
|
||||
// apiUserContextKey is the context key for the authenticated API user.
|
||||
type apiUserContextKey struct{}
|
||||
|
||||
// Params contains dependencies for Middleware.
|
||||
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.
|
||||
@@ -339,6 +346,74 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// APITokenAuth returns middleware that authenticates requests via Bearer token.
|
||||
// It looks up the token hash in the database and stores the user in context.
|
||||
func (m *Middleware) APITokenAuth() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
authHeader := request.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(writer, `{"error":"missing Authorization header"}`, http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const bearerPrefix = "Bearer "
|
||||
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
http.Error(writer, `{"error":"invalid Authorization header"}`, http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rawToken := strings.TrimPrefix(authHeader, bearerPrefix)
|
||||
if rawToken == "" {
|
||||
http.Error(writer, `{"error":"empty token"}`, http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hash := models.HashAPIToken(rawToken)
|
||||
|
||||
apiToken, err := models.FindAPITokenByHash(request.Context(), m.params.Database, hash)
|
||||
if err != nil {
|
||||
m.log.Error("api token lookup error", "error", err)
|
||||
http.Error(writer, `{"error":"internal server error"}`, http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if apiToken == nil {
|
||||
http.Error(writer, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Touch last used (best-effort, don't block on error)
|
||||
_ = apiToken.TouchLastUsed(request.Context())
|
||||
|
||||
user, userErr := models.FindUser(request.Context(), m.params.Database, apiToken.UserID)
|
||||
if userErr != nil || user == nil {
|
||||
http.Error(writer, `{"error":"token user not found"}`, http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(request.Context(), apiUserContextKey{}, user)
|
||||
next.ServeHTTP(writer, request.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// APIUserFromContext extracts the authenticated API user from the context.
|
||||
func APIUserFromContext(ctx context.Context) *models.User {
|
||||
user, _ := ctx.Value(apiUserContextKey{}).(*models.User)
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// SetupRequired returns middleware that redirects to setup if no user exists.
|
||||
func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
|
||||
Reference in New Issue
Block a user