felt/internal/server/routes/auth.go
Mikkel Georgsen dd2f9bbfd9 feat(01-03): implement PIN auth routes, JWT HS256 enforcement, and auth tests
- Add auth HTTP handlers (login, me, logout) with proper JSON responses
- Enforce HS256 via jwt.WithValidMethods to prevent algorithm confusion attacks
- Add context helpers for extracting operator ID and role from JWT claims
- Add comprehensive auth test suite (11 unit tests + 6 integration tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:59:05 +01:00

100 lines
3.1 KiB
Go

// Package routes provides HTTP route handlers for the Felt tournament engine.
package routes
import (
"encoding/json"
"net/http"
"github.com/felt-app/felt/internal/auth"
"github.com/felt-app/felt/internal/server/middleware"
)
// AuthHandler handles authentication routes.
type AuthHandler struct {
authService *auth.AuthService
}
// NewAuthHandler creates a new auth route handler.
func NewAuthHandler(authService *auth.AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
// loginRequest is the request body for POST /api/v1/auth/login.
type loginRequest struct {
PIN string `json:"pin"`
}
// loginResponse is the response body for POST /api/v1/auth/login.
type loginResponse struct {
Token string `json:"token"`
Operator auth.Operator `json:"operator"`
}
// HandleLogin handles POST /api/v1/auth/login.
// Authenticates an operator by PIN and returns a JWT token.
func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.PIN == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "pin is required"})
return
}
token, operator, err := h.authService.Login(r.Context(), req.PIN)
if err != nil {
switch err {
case auth.ErrInvalidPIN:
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid PIN"})
case auth.ErrTooManyAttempts:
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "too many failed attempts, please wait"})
case auth.ErrOperatorLocked:
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "account locked, please wait 30 minutes"})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
return
}
writeJSON(w, http.StatusOK, loginResponse{
Token: token,
Operator: operator,
})
}
// meResponse is the response body for GET /api/v1/auth/me.
type meResponse struct {
OperatorID string `json:"operator_id"`
Role string `json:"role"`
}
// HandleMe handles GET /api/v1/auth/me.
// Returns the current operator from JWT claims.
func (h *AuthHandler) HandleMe(w http.ResponseWriter, r *http.Request) {
operatorID := middleware.OperatorID(r)
role := middleware.OperatorRole(r)
if operatorID == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
return
}
writeJSON(w, http.StatusOK, meResponse{
OperatorID: operatorID,
Role: role,
})
}
// HandleLogout handles POST /api/v1/auth/logout.
// JWT is stateless so this is client-side only, but the endpoint exists for
// audit logging purposes.
func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
// Log the logout action for audit trail
// The actual logout happens client-side by discarding the token
writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"})
}
// writeJSON is defined in templates.go (shared helper for the routes package)