Services: Admin-Compliance, Backend-Compliance, AI-Compliance-SDK, Consent-SDK, Developer-Portal, PCA-Platform, DSMS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
286 lines
7.9 KiB
Go
286 lines
7.9 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/breakpilot/pca-platform/heuristic-service/internal/config"
|
|
"github.com/breakpilot/pca-platform/heuristic-service/internal/heuristics"
|
|
"github.com/breakpilot/pca-platform/heuristic-service/internal/stepup"
|
|
)
|
|
|
|
// Handler holds all API handlers
|
|
type Handler struct {
|
|
config *config.Config
|
|
scorer *heuristics.Scorer
|
|
webauthn *stepup.WebAuthnService
|
|
pow *stepup.PoWService
|
|
}
|
|
|
|
// NewHandler creates a new API handler
|
|
func NewHandler(cfg *config.Config) *Handler {
|
|
return &Handler{
|
|
config: cfg,
|
|
scorer: heuristics.NewScorer(cfg),
|
|
webauthn: stepup.NewWebAuthnService(&cfg.StepUp.WebAuthn),
|
|
pow: stepup.NewPoWService(&cfg.StepUp.PoW),
|
|
}
|
|
}
|
|
|
|
// TickRequest represents metrics sent from client SDK
|
|
type TickRequest struct {
|
|
SessionID string `json:"session_id"`
|
|
Score float64 `json:"score,omitempty"`
|
|
DwellRatio float64 `json:"dwell_ratio"`
|
|
ScrollDepth float64 `json:"scroll_depth"`
|
|
Clicks int `json:"clicks"`
|
|
MouseMoves int `json:"mouse_moves"`
|
|
KeyStrokes int `json:"key_strokes,omitempty"`
|
|
TouchEvents int `json:"touch_events,omitempty"`
|
|
MouseVelocities []float64 `json:"mouse_velocities,omitempty"`
|
|
ScrollVelocities []float64 `json:"scroll_velocities,omitempty"`
|
|
ClickIntervals []float64 `json:"click_intervals,omitempty"`
|
|
Timestamp int64 `json:"ts"`
|
|
}
|
|
|
|
// TickResponse returns the computed score and action
|
|
type TickResponse struct {
|
|
SessionID string `json:"session_id"`
|
|
Score float64 `json:"score"`
|
|
Action string `json:"action"`
|
|
StepUpMethod string `json:"step_up_method,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
// HandleTick receives tick data from client SDK
|
|
func (h *Handler) HandleTick(c *gin.Context) {
|
|
var req TickRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
// Generate session ID if not provided
|
|
if req.SessionID == "" {
|
|
req.SessionID = uuid.New().String()
|
|
}
|
|
|
|
// Get or create session
|
|
session := h.scorer.GetOrCreateSession(req.SessionID)
|
|
|
|
// Update metrics
|
|
totalTime := time.Since(session.StartTime).Seconds()
|
|
session.VisibleTime = req.DwellRatio * totalTime
|
|
session.MaxScrollPercent = req.ScrollDepth / 100.0 // Convert from percentage
|
|
session.ClickCount = req.Clicks
|
|
session.MouseMoves = req.MouseMoves
|
|
session.KeyStrokes = req.KeyStrokes
|
|
session.TouchEvents = req.TouchEvents
|
|
|
|
if len(req.MouseVelocities) > 0 {
|
|
session.MouseVelocities = append(session.MouseVelocities, req.MouseVelocities...)
|
|
}
|
|
if len(req.ScrollVelocities) > 0 {
|
|
session.ScrollVelocities = append(session.ScrollVelocities, req.ScrollVelocities...)
|
|
}
|
|
if len(req.ClickIntervals) > 0 {
|
|
session.ClickIntervals = append(session.ClickIntervals, req.ClickIntervals...)
|
|
}
|
|
|
|
// Calculate score
|
|
score := h.scorer.CalculateScore(req.SessionID)
|
|
|
|
// Determine action
|
|
var action, stepUpMethod, message string
|
|
if score >= h.config.Thresholds.ScorePass {
|
|
action = "allow"
|
|
message = "Human behavior detected"
|
|
} else if score >= h.config.Thresholds.ScoreChallenge {
|
|
action = "allow"
|
|
message = "Acceptable behavior"
|
|
} else {
|
|
action = "challenge"
|
|
stepUpMethod = h.config.StepUp.Primary
|
|
message = "Additional verification required"
|
|
}
|
|
|
|
c.JSON(http.StatusOK, TickResponse{
|
|
SessionID: req.SessionID,
|
|
Score: score,
|
|
Action: action,
|
|
StepUpMethod: stepUpMethod,
|
|
Message: message,
|
|
})
|
|
}
|
|
|
|
// HandleEvaluate evaluates a session for a specific path
|
|
func (h *Handler) HandleEvaluate(c *gin.Context) {
|
|
sessionID := c.Query("session_id")
|
|
path := c.Query("path")
|
|
|
|
if sessionID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "session_id required"})
|
|
return
|
|
}
|
|
|
|
if path == "" {
|
|
path = "default"
|
|
}
|
|
|
|
// TODO: Load path configs from ai-access.json
|
|
pathConfigs := map[string]config.PathConfig{}
|
|
|
|
result := h.scorer.EvaluateRequest(sessionID, path, pathConfigs)
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// HandleWebAuthnChallenge creates a WebAuthn challenge
|
|
func (h *Handler) HandleWebAuthnChallenge(c *gin.Context) {
|
|
if !h.webauthn.IsEnabled() {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "WebAuthn not enabled"})
|
|
return
|
|
}
|
|
|
|
sessionID := c.Query("session_id")
|
|
if sessionID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "session_id required"})
|
|
return
|
|
}
|
|
|
|
challenge, err := h.webauthn.CreateChallenge(sessionID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create challenge"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, challenge)
|
|
}
|
|
|
|
// HandleWebAuthnVerify verifies a WebAuthn assertion
|
|
func (h *Handler) HandleWebAuthnVerify(c *gin.Context) {
|
|
if !h.webauthn.IsEnabled() {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "WebAuthn not enabled"})
|
|
return
|
|
}
|
|
|
|
var req stepup.VerifyRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
verified, err := h.webauthn.VerifyChallenge(&req)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Verification failed"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"verified": verified,
|
|
"session_id": req.SessionID,
|
|
})
|
|
}
|
|
|
|
// HandlePoWChallenge creates a Proof-of-Work challenge
|
|
func (h *Handler) HandlePoWChallenge(c *gin.Context) {
|
|
if !h.pow.IsEnabled() {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "PoW not enabled"})
|
|
return
|
|
}
|
|
|
|
sessionID := c.Query("session_id")
|
|
if sessionID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "session_id required"})
|
|
return
|
|
}
|
|
|
|
challenge, err := h.pow.CreateChallenge(sessionID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create challenge"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, challenge)
|
|
}
|
|
|
|
// HandlePoWVerify verifies a Proof-of-Work solution
|
|
func (h *Handler) HandlePoWVerify(c *gin.Context) {
|
|
if !h.pow.IsEnabled() {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "PoW not enabled"})
|
|
return
|
|
}
|
|
|
|
var req stepup.PoWVerifyRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
verified, err := h.pow.VerifyChallenge(&req)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Verification failed"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"verified": verified,
|
|
"session_id": req.SessionID,
|
|
})
|
|
}
|
|
|
|
// HandleGetConfig returns client-safe configuration
|
|
func (h *Handler) HandleGetConfig(c *gin.Context) {
|
|
// Return only non-sensitive config for client SDK
|
|
clientConfig := gin.H{
|
|
"thresholds": h.config.Thresholds,
|
|
"weights": h.config.Weights,
|
|
"tick": gin.H{
|
|
"endpoint": h.config.Tick.Endpoint,
|
|
"interval_ms": h.config.Tick.IntervalMs,
|
|
},
|
|
"step_up": gin.H{
|
|
"methods": h.config.StepUp.Methods,
|
|
"primary": h.config.StepUp.Primary,
|
|
"webauthn": gin.H{
|
|
"enabled": h.config.StepUp.WebAuthn.Enabled,
|
|
"userVerification": h.config.StepUp.WebAuthn.UserVerification,
|
|
"timeout_ms": h.config.StepUp.WebAuthn.TimeoutMs,
|
|
"challenge_endpoint": h.config.StepUp.WebAuthn.ChallengeEndpoint,
|
|
},
|
|
"pow": gin.H{
|
|
"enabled": h.config.StepUp.PoW.Enabled,
|
|
"difficulty": h.config.StepUp.PoW.Difficulty,
|
|
"max_duration_ms": h.config.StepUp.PoW.MaxDurationMs,
|
|
},
|
|
},
|
|
"compliance": h.config.Compliance,
|
|
}
|
|
|
|
c.JSON(http.StatusOK, clientConfig)
|
|
}
|
|
|
|
// HandleHealth returns service health
|
|
func (h *Handler) HandleHealth(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "healthy",
|
|
"service": "pca-heuristic-service",
|
|
"version": "0.1.0",
|
|
})
|
|
}
|
|
|
|
// StartCleanupRoutine starts background cleanup
|
|
func (h *Handler) StartCleanupRoutine() {
|
|
go func() {
|
|
ticker := time.NewTicker(5 * time.Minute)
|
|
for range ticker.C {
|
|
h.scorer.CleanupOldSessions(30 * time.Minute)
|
|
h.webauthn.CleanupExpiredChallenges()
|
|
h.pow.CleanupExpiredChallenges()
|
|
}
|
|
}()
|
|
}
|