This commit is contained in:
2025-08-26 19:16:41 -04:00
parent 7ca61eb712
commit 6725529b01
113 changed files with 0 additions and 337 deletions

View File

@ -0,0 +1,283 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/kms/api-key-service/internal/authorization"
"github.com/kms/api-key-service/internal/domain"
"github.com/kms/api-key-service/internal/errors"
"github.com/kms/api-key-service/internal/services"
"github.com/kms/api-key-service/internal/validation"
)
// ApplicationHandler handles application-related HTTP requests
type ApplicationHandler struct {
appService services.ApplicationService
authService services.AuthenticationService
authzService *authorization.AuthorizationService
validator *validation.Validator
errorHandler *errors.ErrorHandler
logger *zap.Logger
}
// NewApplicationHandler creates a new application handler
func NewApplicationHandler(
appService services.ApplicationService,
authService services.AuthenticationService,
logger *zap.Logger,
) *ApplicationHandler {
return &ApplicationHandler{
appService: appService,
authService: authService,
authzService: authorization.NewAuthorizationService(logger),
validator: validation.NewValidator(logger),
errorHandler: errors.NewErrorHandler(logger),
logger: logger,
}
}
// Create handles POST /applications
func (h *ApplicationHandler) Create(c *gin.Context) {
var req domain.CreateApplicationRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.errorHandler.HandleValidationError(c, "request_body", "Invalid application request format")
return
}
// Get user ID from authenticated context
userID := h.getUserIDFromContext(c)
if userID == "" {
h.errorHandler.HandleAuthenticationError(c, errors.NewUnauthorizedError("User authentication required"))
return
}
// Validate input (skip permissions validation for application creation)
var validationErrors []validation.ValidationError
// Validate app ID
if result := h.validator.ValidateAppID(req.AppID); !result.Valid {
validationErrors = append(validationErrors, result.Errors...)
}
// Validate app link URL
if result := h.validator.ValidateURL(req.AppLink, "app_link"); !result.Valid {
validationErrors = append(validationErrors, result.Errors...)
}
// Validate callback URL
if result := h.validator.ValidateURL(req.CallbackURL, "callback_url"); !result.Valid {
validationErrors = append(validationErrors, result.Errors...)
}
// Validate token prefix if provided
if result := h.validator.ValidateTokenPrefix(req.TokenPrefix); !result.Valid {
validationErrors = append(validationErrors, result.Errors...)
}
if len(validationErrors) > 0 {
h.logger.Warn("Application validation failed",
zap.String("user_id", userID),
zap.Any("errors", validationErrors))
h.errorHandler.HandleValidationError(c, "validation", "Invalid application data")
return
}
// Check authorization for creating applications
authCtx := &authorization.AuthorizationContext{
UserID: userID,
ResourceType: authorization.ResourceTypeApplication,
Action: authorization.ActionCreate,
}
if err := h.authzService.AuthorizeResourceAccess(c.Request.Context(), authCtx); err != nil {
h.errorHandler.HandleAuthorizationError(c, "application creation")
return
}
// Create the application
app, err := h.appService.Create(c.Request.Context(), &req, userID)
if err != nil {
h.errorHandler.HandleInternalError(c, err)
return
}
h.logger.Info("Application created successfully",
zap.String("app_id", app.AppID),
zap.String("user_id", userID))
c.JSON(http.StatusCreated, app)
}
// getUserIDFromContext extracts user ID from Gin context
func (h *ApplicationHandler) getUserIDFromContext(c *gin.Context) string {
// Try to get from Gin context first (set by middleware)
if userID, exists := c.Get("user_id"); exists {
if id, ok := userID.(string); ok {
return id
}
}
// Fallback to header (for compatibility)
userEmail := c.GetHeader("X-User-Email")
if userEmail != "" {
return userEmail
}
return ""
}
// GetByID handles GET /applications/:id
func (h *ApplicationHandler) GetByID(c *gin.Context) {
appID := c.Param("id")
// Get user ID from context
userID := h.getUserIDFromContext(c)
if userID == "" {
h.errorHandler.HandleAuthenticationError(c, errors.NewUnauthorizedError("User authentication required"))
return
}
// Validate app ID
if result := h.validator.ValidateAppID(appID); !result.Valid {
h.errorHandler.HandleValidationError(c, "app_id", "Invalid application ID")
return
}
// Get the application first
app, err := h.appService.GetByID(c.Request.Context(), appID)
if err != nil {
h.logger.Error("Failed to get application", zap.Error(err), zap.String("app_id", appID))
h.errorHandler.HandleError(c, err, "Application not found")
return
}
// Check authorization for reading this application
if err := h.authzService.AuthorizeApplicationOwnership(userID, app); err != nil {
h.errorHandler.HandleAuthorizationError(c, "application access")
return
}
c.JSON(http.StatusOK, app)
}
// List handles GET /applications
func (h *ApplicationHandler) List(c *gin.Context) {
// Parse pagination parameters
limit := 50
offset := 0
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
if o := c.Query("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
offset = parsed
}
}
apps, err := h.appService.List(c.Request.Context(), limit, offset)
if err != nil {
h.logger.Error("Failed to list applications", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"message": "Failed to list applications",
})
return
}
c.JSON(http.StatusOK, gin.H{
"data": apps,
"limit": limit,
"offset": offset,
"count": len(apps),
})
}
// Update handles PUT /applications/:id
func (h *ApplicationHandler) Update(c *gin.Context) {
appID := c.Param("id")
if appID == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Bad Request",
"message": "Application ID is required",
})
return
}
var req domain.UpdateApplicationRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Warn("Invalid request body", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Bad Request",
"message": "Invalid request body: " + err.Error(),
})
return
}
// Get user ID from context
userID, exists := c.Get("user_id")
if !exists {
h.logger.Error("User ID not found in context")
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"message": "Authentication context not found",
})
return
}
app, err := h.appService.Update(c.Request.Context(), appID, &req, userID.(string))
if err != nil {
h.logger.Error("Failed to update application", zap.Error(err), zap.String("app_id", appID))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"message": "Failed to update application",
})
return
}
h.logger.Info("Application updated", zap.String("app_id", appID))
c.JSON(http.StatusOK, app)
}
// Delete handles DELETE /applications/:id
func (h *ApplicationHandler) Delete(c *gin.Context) {
appID := c.Param("id")
if appID == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Bad Request",
"message": "Application ID is required",
})
return
}
// Get user ID from context
userID, exists := c.Get("user_id")
if !exists {
h.logger.Error("User ID not found in context")
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"message": "Authentication context not found",
})
return
}
err := h.appService.Delete(c.Request.Context(), appID, userID.(string))
if err != nil {
h.logger.Error("Failed to delete application", zap.Error(err), zap.String("app_id", appID))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"message": "Failed to delete application",
})
return
}
h.logger.Info("Application deleted", zap.String("app_id", appID))
c.JSON(http.StatusNoContent, nil)
}

View File

@ -0,0 +1,282 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kms/api-key-service/internal/audit"
"github.com/kms/api-key-service/internal/errors"
"github.com/kms/api-key-service/internal/services"
"github.com/kms/api-key-service/internal/validation"
)
// AuditHandler handles audit-related HTTP requests
type AuditHandler struct {
auditLogger audit.AuditLogger
authService services.AuthenticationService
validator *validation.Validator
errorHandler *errors.ErrorHandler
logger *zap.Logger
}
// NewAuditHandler creates a new audit handler
func NewAuditHandler(
auditLogger audit.AuditLogger,
authService services.AuthenticationService,
logger *zap.Logger,
) *AuditHandler {
return &AuditHandler{
auditLogger: auditLogger,
authService: authService,
validator: validation.NewValidator(logger),
errorHandler: errors.NewErrorHandler(logger),
logger: logger,
}
}
// AuditQueryRequest represents the request for querying audit events
type AuditQueryRequest struct {
EventTypes []string `json:"event_types,omitempty" form:"event_types"`
Statuses []string `json:"statuses,omitempty" form:"statuses"`
ActorID string `json:"actor_id,omitempty" form:"actor_id"`
ResourceID string `json:"resource_id,omitempty" form:"resource_id"`
ResourceType string `json:"resource_type,omitempty" form:"resource_type"`
StartTime *string `json:"start_time,omitempty" form:"start_time"`
EndTime *string `json:"end_time,omitempty" form:"end_time"`
Limit int `json:"limit,omitempty" form:"limit"`
Offset int `json:"offset,omitempty" form:"offset"`
OrderBy string `json:"order_by,omitempty" form:"order_by"`
OrderDesc *bool `json:"order_desc,omitempty" form:"order_desc"`
}
// AuditStatsRequest represents the request for audit statistics
type AuditStatsRequest struct {
EventTypes []string `json:"event_types,omitempty" form:"event_types"`
StartTime *string `json:"start_time,omitempty" form:"start_time"`
EndTime *string `json:"end_time,omitempty" form:"end_time"`
GroupBy string `json:"group_by,omitempty" form:"group_by"`
}
// AuditResponse represents the response structure for audit queries
type AuditResponse struct {
Events []AuditEventResponse `json:"events"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// AuditEventResponse represents a single audit event in API responses
type AuditEventResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Timestamp string `json:"timestamp"`
ActorID string `json:"actor_id,omitempty"`
ActorIP string `json:"actor_ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
ResourceID string `json:"resource_id,omitempty"`
ResourceType string `json:"resource_type,omitempty"`
Action string `json:"action"`
Description string `json:"description"`
Details map[string]interface{} `json:"details,omitempty"`
RequestID string `json:"request_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
}
// ListEvents handles GET /audit/events
func (h *AuditHandler) ListEvents(c *gin.Context) {
// Parse query parameters
var req AuditQueryRequest
if err := c.ShouldBindQuery(&req); err != nil {
h.errorHandler.HandleValidationError(c, "query_params", "Invalid query parameters")
return
}
// Set defaults
if req.Limit <= 0 || req.Limit > 1000 {
req.Limit = 100
}
if req.Offset < 0 {
req.Offset = 0
}
if req.OrderBy == "" {
req.OrderBy = "timestamp"
}
if req.OrderDesc == nil {
orderDesc := true
req.OrderDesc = &orderDesc
}
// Convert request to audit filter
filter := &audit.AuditFilter{
ActorID: req.ActorID,
ResourceID: req.ResourceID,
ResourceType: req.ResourceType,
Limit: req.Limit,
Offset: req.Offset,
OrderBy: req.OrderBy,
OrderDesc: *req.OrderDesc,
}
// Convert event types
for _, et := range req.EventTypes {
filter.EventTypes = append(filter.EventTypes, audit.EventType(et))
}
// Convert statuses
for _, st := range req.Statuses {
filter.Statuses = append(filter.Statuses, audit.EventStatus(st))
}
// Parse time filters
if req.StartTime != nil && *req.StartTime != "" {
if startTime, err := time.Parse(time.RFC3339, *req.StartTime); err == nil {
filter.StartTime = &startTime
} else {
h.errorHandler.HandleValidationError(c, "start_time", "Invalid start_time format, use RFC3339")
return
}
}
if req.EndTime != nil && *req.EndTime != "" {
if endTime, err := time.Parse(time.RFC3339, *req.EndTime); err == nil {
filter.EndTime = &endTime
} else {
h.errorHandler.HandleValidationError(c, "end_time", "Invalid end_time format, use RFC3339")
return
}
}
// Query audit events
events, err := h.auditLogger.QueryEvents(c.Request.Context(), filter)
if err != nil {
h.logger.Error("Failed to query audit events", zap.Error(err))
h.errorHandler.HandleInternalError(c, err)
return
}
// Convert to response format
response := &AuditResponse{
Events: make([]AuditEventResponse, len(events)),
Total: len(events), // Note: This is just the count of returned events, not total matching
Limit: req.Limit,
Offset: req.Offset,
}
for i, event := range events {
response.Events[i] = AuditEventResponse{
ID: event.ID.String(),
Type: string(event.Type),
Status: string(event.Status),
Timestamp: event.Timestamp.Format(time.RFC3339),
ActorID: event.ActorID,
ActorIP: event.ActorIP,
UserAgent: event.UserAgent,
ResourceID: event.ResourceID,
ResourceType: event.ResourceType,
Action: event.Action,
Description: event.Description,
Details: event.Details,
RequestID: event.RequestID,
SessionID: event.SessionID,
}
}
c.JSON(http.StatusOK, response)
}
// GetEvent handles GET /audit/events/:id
func (h *AuditHandler) GetEvent(c *gin.Context) {
eventIDStr := c.Param("id")
eventID, err := uuid.Parse(eventIDStr)
if err != nil {
h.errorHandler.HandleValidationError(c, "id", "Invalid event ID format")
return
}
// Get the specific audit event
event, err := h.auditLogger.GetEventByID(c.Request.Context(), eventID)
if err != nil {
h.logger.Error("Failed to get audit event", zap.Error(err), zap.String("event_id", eventID.String()))
// Check if it's a not found error
if err.Error() == "audit event with ID '"+eventID.String()+"' not found" {
h.errorHandler.HandleNotFoundError(c, "audit_event", "Audit event not found")
} else {
h.errorHandler.HandleInternalError(c, err)
}
return
}
// Convert to response format
response := AuditEventResponse{
ID: event.ID.String(),
Type: string(event.Type),
Status: string(event.Status),
Timestamp: event.Timestamp.Format(time.RFC3339),
ActorID: event.ActorID,
ActorIP: event.ActorIP,
UserAgent: event.UserAgent,
ResourceID: event.ResourceID,
ResourceType: event.ResourceType,
Action: event.Action,
Description: event.Description,
Details: event.Details,
RequestID: event.RequestID,
SessionID: event.SessionID,
}
c.JSON(http.StatusOK, response)
}
// GetStats handles GET /audit/stats
func (h *AuditHandler) GetStats(c *gin.Context) {
// Parse query parameters
var req AuditStatsRequest
if err := c.ShouldBindQuery(&req); err != nil {
h.errorHandler.HandleValidationError(c, "query_params", "Invalid query parameters")
return
}
// Convert request to audit stats filter
filter := &audit.AuditStatsFilter{
GroupBy: req.GroupBy,
}
// Convert event types
for _, et := range req.EventTypes {
filter.EventTypes = append(filter.EventTypes, audit.EventType(et))
}
// Parse time filters
if req.StartTime != nil && *req.StartTime != "" {
if startTime, err := time.Parse(time.RFC3339, *req.StartTime); err == nil {
filter.StartTime = &startTime
} else {
h.errorHandler.HandleValidationError(c, "start_time", "Invalid start_time format, use RFC3339")
return
}
}
if req.EndTime != nil && *req.EndTime != "" {
if endTime, err := time.Parse(time.RFC3339, *req.EndTime); err == nil {
filter.EndTime = &endTime
} else {
h.errorHandler.HandleValidationError(c, "end_time", "Invalid end_time format, use RFC3339")
return
}
}
// Get audit statistics
stats, err := h.auditLogger.GetEventStats(c.Request.Context(), filter)
if err != nil {
h.logger.Error("Failed to get audit statistics", zap.Error(err))
h.errorHandler.HandleInternalError(c, err)
return
}
c.JSON(http.StatusOK, stats)
}

View File

@ -0,0 +1,311 @@
package handlers
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"net/http"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/kms/api-key-service/internal/auth"
"github.com/kms/api-key-service/internal/config"
"github.com/kms/api-key-service/internal/domain"
"github.com/kms/api-key-service/internal/errors"
"github.com/kms/api-key-service/internal/services"
)
// AuthHandler handles authentication-related HTTP requests
type AuthHandler struct {
authService services.AuthenticationService
tokenService services.TokenService
headerValidator *auth.HeaderValidator
config config.ConfigProvider
errorHandler *errors.ErrorHandler
logger *zap.Logger
loginTemplate *template.Template
}
// LoginPageData represents data passed to the login HTML template
type LoginPageData struct {
Token string
TokenJSON template.JS
RedirectURLJSON template.JS
ExpiresAt string
AppID string
UserID string
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(
authService services.AuthenticationService,
tokenService services.TokenService,
config config.ConfigProvider,
logger *zap.Logger,
) *AuthHandler {
// Load login template
templatePath := filepath.Join("templates", "login.html")
loginTemplate, err := template.ParseFiles(templatePath)
if err != nil {
logger.Error("Failed to load login template", zap.Error(err), zap.String("path", templatePath))
// Template loading failure is not fatal, we'll fall back to JSON
}
return &AuthHandler{
authService: authService,
tokenService: tokenService,
headerValidator: auth.NewHeaderValidator(config, logger),
config: config,
errorHandler: errors.NewErrorHandler(logger),
logger: logger,
loginTemplate: loginTemplate,
}
}
// Login handles login requests (both GET for HTML and POST for JSON)
func (h *AuthHandler) Login(c *gin.Context) {
// Handle GET requests or requests that prefer HTML
acceptHeader := c.GetHeader("Accept")
contentType := c.GetHeader("Content-Type")
isJSONRequest := (c.Request.Method == "POST" && (contentType == "application/json" ||
(acceptHeader != "" && (acceptHeader == "application/json" ||
(acceptHeader != "text/html" && acceptHeader != "*/*")))))
var req domain.LoginRequest
if isJSONRequest {
// Handle JSON POST request (existing API behavior)
if err := c.ShouldBindJSON(&req); err != nil {
h.errorHandler.HandleValidationError(c, "request_body", "Invalid login request format")
return
}
} else {
// Handle HTML request (GET or POST with form data)
req.AppID = c.Query("app_id")
req.RedirectURI = c.Query("redirect_uri")
// Parse permissions from query parameter (comma-separated)
if perms := c.Query("permissions"); perms != "" {
// Simple parsing for comma-separated permissions
req.Permissions = []string{perms} // Simplified for this example
}
// If no app_id provided, show error
if req.AppID == "" {
h.renderLoginError(c, "Missing required parameter: app_id", isJSONRequest)
return
}
}
// Validate authentication headers with HMAC signature
userContext, err := h.headerValidator.ValidateAuthenticationHeaders(c.Request)
if err != nil {
if isJSONRequest {
h.errorHandler.HandleAuthenticationError(c, err)
} else {
h.renderLoginError(c, "Authentication failed: "+err.Error(), isJSONRequest)
}
return
}
h.logger.Info("Processing login request", zap.String("user_id", userContext.UserID), zap.String("app_id", req.AppID))
// Generate user token
token, err := h.tokenService.GenerateUserToken(c.Request.Context(), req.AppID, userContext.UserID, req.Permissions)
if err != nil {
if isJSONRequest {
h.errorHandler.HandleInternalError(c, err)
} else {
h.renderLoginError(c, "Failed to generate token: "+err.Error(), isJSONRequest)
}
return
}
// For JSON requests without redirect URI, return token directly
if isJSONRequest && req.RedirectURI == "" {
c.JSON(http.StatusOK, gin.H{
"token": token,
"user_id": userContext.UserID,
"app_id": req.AppID,
"expires_in": 604800, // 7 days in seconds
})
return
}
// Handle redirect flows - always deliver token via query parameter
var redirectURL string
if req.RedirectURI != "" {
// Generate a secure state parameter for CSRF protection
state := h.generateSecureState(userContext.UserID, req.AppID)
redirectURL = req.RedirectURI + "?token=" + token + "&state=" + state
}
// Return appropriate response format
if isJSONRequest {
response := domain.LoginResponse{
RedirectURL: redirectURL,
}
c.JSON(http.StatusOK, response)
} else {
// Render HTML page
h.renderLoginPage(c, token, redirectURL, userContext.UserID, req.AppID)
}
}
// renderLoginPage renders the HTML login page with token information
func (h *AuthHandler) renderLoginPage(c *gin.Context, token, redirectURL, userID, appID string) {
if h.loginTemplate == nil {
// Fallback to JSON if template not available
c.JSON(http.StatusOK, gin.H{
"token": token,
"redirect_url": redirectURL,
"user_id": userID,
"app_id": appID,
"message": "Login successful - HTML template not available",
})
return
}
// Prepare template data
tokenJSON, _ := json.Marshal(token)
redirectURLJSON, _ := json.Marshal(redirectURL)
data := LoginPageData{
Token: token,
TokenJSON: template.JS(tokenJSON),
RedirectURLJSON: template.JS(redirectURLJSON),
ExpiresAt: time.Now().Add(7 * 24 * time.Hour).Format("Jan 2, 2006 at 3:04 PM MST"),
AppID: appID,
UserID: userID,
}
c.Header("Content-Type", "text/html; charset=utf-8")
// Override CSP for login page to allow inline styles and scripts
c.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
if err := h.loginTemplate.Execute(c.Writer, data); err != nil {
h.logger.Error("Failed to render login template", zap.Error(err))
// Fallback to JSON response
c.JSON(http.StatusOK, gin.H{
"token": token,
"redirect_url": redirectURL,
"user_id": userID,
"app_id": appID,
"message": "Login successful - template render failed",
})
}
}
// renderLoginError renders an error page or JSON error response
func (h *AuthHandler) renderLoginError(c *gin.Context, message string, isJSON bool) {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Bad Request",
"message": message,
})
return
}
// Simple HTML error page
c.Header("Content-Type", "text/html; charset=utf-8")
// Override CSP for error page to allow inline styles
c.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'")
c.String(http.StatusBadRequest, `
<!DOCTYPE html>
<html>
<head>
<title>Login Error</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.error { background: #f8d7da; color: #721c24; padding: 15px; border-radius: 5px; }
</style>
</head>
<body>
<h1>Login Error</h1>
<div class="error">%s</div>
<p><a href="javascript:history.back()">Go back</a></p>
</body>
</html>`, message)
}
// generateSecureState generates a secure state parameter for OAuth flows
func (h *AuthHandler) generateSecureState(userID, appID string) string {
// Generate random bytes for state
stateBytes := make([]byte, 16)
if _, err := rand.Read(stateBytes); err != nil {
h.logger.Error("Failed to generate random state", zap.Error(err))
// Fallback to less secure but functional state
return fmt.Sprintf("state_%s_%s_%d", userID, appID, time.Now().UnixNano())
}
// Create HMAC signature to prevent tampering
stateData := fmt.Sprintf("%s:%s:%x", userID, appID, stateBytes)
mac := hmac.New(sha256.New, []byte(h.config.GetString("AUTH_SIGNING_KEY")))
mac.Write([]byte(stateData))
signature := hex.EncodeToString(mac.Sum(nil))
// Return base64-encoded state with signature
return hex.EncodeToString([]byte(fmt.Sprintf("%s.%s", stateData, signature)))
}
// Verify handles POST /verify
func (h *AuthHandler) Verify(c *gin.Context) {
var req domain.VerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Warn("Invalid verify request", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Bad Request",
"message": "Invalid request body: " + err.Error(),
})
return
}
h.logger.Debug("Verifying token", zap.String("app_id", req.AppID))
response, err := h.tokenService.VerifyToken(c.Request.Context(), &req)
if err != nil {
h.logger.Error("Failed to verify token", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"message": "Failed to verify token",
})
return
}
c.JSON(http.StatusOK, response)
}
// Renew handles POST /renew
func (h *AuthHandler) Renew(c *gin.Context) {
var req domain.RenewRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Warn("Invalid renew request", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Bad Request",
"message": "Invalid request body: " + err.Error(),
})
return
}
h.logger.Info("Renewing token", zap.String("app_id", req.AppID), zap.String("user_id", req.UserID))
response, err := h.tokenService.RenewUserToken(c.Request.Context(), &req)
if err != nil {
h.logger.Error("Failed to renew token", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"message": "Failed to renew token",
})
return
}
c.JSON(http.StatusOK, response)
}

View File

@ -0,0 +1,72 @@
package handlers
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/kms/api-key-service/internal/repository"
)
// HealthHandler handles health check endpoints
type HealthHandler struct {
db repository.DatabaseProvider
logger *zap.Logger
}
// NewHealthHandler creates a new health handler
func NewHealthHandler(db repository.DatabaseProvider, logger *zap.Logger) *HealthHandler {
return &HealthHandler{
db: db,
logger: logger,
}
}
// HealthResponse represents the health check response
type HealthResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
Version string `json:"version,omitempty"`
Checks map[string]string `json:"checks,omitempty"`
}
// Health handles basic health check - lightweight endpoint for load balancers
func (h *HealthHandler) Health(c *gin.Context) {
response := HealthResponse{
Status: "healthy",
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
c.JSON(http.StatusOK, response)
}
// Ready handles readiness check - checks if service is ready to accept traffic
func (h *HealthHandler) Ready(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
checks := make(map[string]string)
status := "ready"
statusCode := http.StatusOK
// Check database connectivity
if err := h.db.Ping(ctx); err != nil {
h.logger.Error("Database health check failed", zap.Error(err))
checks["database"] = "unhealthy: " + err.Error()
status = "not ready"
statusCode = http.StatusServiceUnavailable
} else {
checks["database"] = "healthy"
}
response := HealthResponse{
Status: status,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Checks: checks,
}
c.JSON(statusCode, response)
}

View File

@ -0,0 +1,394 @@
package handlers
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"time"
"github.com/gorilla/mux"
"go.uber.org/zap"
"github.com/kms/api-key-service/internal/auth"
"github.com/kms/api-key-service/internal/config"
"github.com/kms/api-key-service/internal/domain"
"github.com/kms/api-key-service/internal/errors"
"github.com/kms/api-key-service/internal/services"
)
// OAuth2Handler handles OAuth2/OIDC authentication flows
type OAuth2Handler struct {
config config.ConfigProvider
logger *zap.Logger
oauth2Provider *auth.OAuth2Provider
authService services.AuthenticationService
}
// NewOAuth2Handler creates a new OAuth2 handler
func NewOAuth2Handler(
config config.ConfigProvider,
logger *zap.Logger,
authService services.AuthenticationService,
) *OAuth2Handler {
oauth2Provider := auth.NewOAuth2Provider(config, logger)
return &OAuth2Handler{
config: config,
logger: logger,
oauth2Provider: oauth2Provider,
authService: authService,
}
}
// AuthorizeRequest represents the OAuth2 authorization request
type AuthorizeRequest struct {
RedirectURI string `json:"redirect_uri" validate:"required,url"`
State string `json:"state,omitempty"`
}
// AuthorizeResponse represents the OAuth2 authorization response
type AuthorizeResponse struct {
AuthURL string `json:"auth_url"`
State string `json:"state"`
CodeVerifier string `json:"code_verifier"` // In production, this should be stored securely
}
// CallbackRequest represents the OAuth2 callback request
type CallbackRequest struct {
Code string `json:"code" validate:"required"`
State string `json:"state,omitempty"`
RedirectURI string `json:"redirect_uri" validate:"required,url"`
CodeVerifier string `json:"code_verifier" validate:"required"`
}
// CallbackResponse represents the OAuth2 callback response
type CallbackResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
UserInfo *auth.UserInfo `json:"user_info"`
JWTToken string `json:"jwt_token"`
}
// RefreshRequest represents the token refresh request
type RefreshRequest struct {
RefreshToken string `json:"refresh_token" validate:"required"`
}
// RefreshResponse represents the token refresh response
type RefreshResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
JWTToken string `json:"jwt_token"`
}
// RegisterRoutes registers OAuth2 routes
func (h *OAuth2Handler) RegisterRoutes(router *mux.Router) {
oauth2Router := router.PathPrefix("/oauth2").Subrouter()
oauth2Router.HandleFunc("/authorize", h.Authorize).Methods("POST")
oauth2Router.HandleFunc("/callback", h.Callback).Methods("POST")
oauth2Router.HandleFunc("/refresh", h.Refresh).Methods("POST")
oauth2Router.HandleFunc("/userinfo", h.GetUserInfo).Methods("GET")
}
// Authorize initiates the OAuth2 authorization flow
func (h *OAuth2Handler) Authorize(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
h.logger.Debug("Processing OAuth2 authorization request")
var req AuthorizeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Warn("Invalid authorization request", zap.Error(err))
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Generate state if not provided
if req.State == "" {
state, err := h.generateState()
if err != nil {
h.logger.Error("Failed to generate state", zap.Error(err))
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
req.State = state
}
// Generate authorization URL
authURL, err := h.oauth2Provider.GenerateAuthURL(ctx, req.State, req.RedirectURI)
if err != nil {
h.logger.Error("Failed to generate authorization URL", zap.Error(err))
if appErr, ok := err.(*errors.AppError); ok {
http.Error(w, appErr.Message, appErr.StatusCode)
return
}
http.Error(w, "Failed to generate authorization URL", http.StatusInternalServerError)
return
}
// In production, store the code verifier securely (e.g., in session or cache)
// For now, we'll return it in the response
codeVerifier, err := h.generateCodeVerifier()
if err != nil {
h.logger.Error("Failed to generate code verifier", zap.Error(err))
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
response := AuthorizeResponse{
AuthURL: authURL,
State: req.State,
CodeVerifier: codeVerifier,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
h.logger.Error("Failed to encode authorization response", zap.Error(err))
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.logger.Debug("Authorization URL generated successfully",
zap.String("state", req.State),
zap.String("redirect_uri", req.RedirectURI))
}
// Callback handles the OAuth2 callback and exchanges code for tokens
func (h *OAuth2Handler) Callback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
h.logger.Debug("Processing OAuth2 callback")
var req CallbackRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Warn("Invalid callback request", zap.Error(err))
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Exchange authorization code for tokens
tokenResp, err := h.oauth2Provider.ExchangeCodeForToken(ctx, req.Code, req.RedirectURI, req.CodeVerifier)
if err != nil {
h.logger.Error("Failed to exchange code for token", zap.Error(err))
if appErr, ok := err.(*errors.AppError); ok {
http.Error(w, appErr.Message, appErr.StatusCode)
return
}
http.Error(w, "Failed to exchange authorization code", http.StatusInternalServerError)
return
}
// Get user information
userInfo, err := h.oauth2Provider.GetUserInfo(ctx, tokenResp.AccessToken)
if err != nil {
h.logger.Error("Failed to get user info", zap.Error(err))
if appErr, ok := err.(*errors.AppError); ok {
http.Error(w, appErr.Message, appErr.StatusCode)
return
}
http.Error(w, "Failed to get user information", http.StatusInternalServerError)
return
}
// Generate internal JWT token for the user
jwtToken, err := h.generateInternalJWTToken(ctx, userInfo)
if err != nil {
h.logger.Error("Failed to generate internal JWT token", zap.Error(err))
http.Error(w, "Failed to generate authentication token", http.StatusInternalServerError)
return
}
response := CallbackResponse{
AccessToken: tokenResp.AccessToken,
TokenType: tokenResp.TokenType,
ExpiresIn: tokenResp.ExpiresIn,
RefreshToken: tokenResp.RefreshToken,
UserInfo: userInfo,
JWTToken: jwtToken,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
h.logger.Error("Failed to encode callback response", zap.Error(err))
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.logger.Info("OAuth2 callback processed successfully",
zap.String("user_id", userInfo.Sub),
zap.String("email", userInfo.Email))
}
// Refresh refreshes an access token using refresh token
func (h *OAuth2Handler) Refresh(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
h.logger.Debug("Processing token refresh request")
var req RefreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Warn("Invalid refresh request", zap.Error(err))
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Refresh the access token
tokenResp, err := h.oauth2Provider.RefreshAccessToken(ctx, req.RefreshToken)
if err != nil {
h.logger.Error("Failed to refresh access token", zap.Error(err))
if appErr, ok := err.(*errors.AppError); ok {
http.Error(w, appErr.Message, appErr.StatusCode)
return
}
http.Error(w, "Failed to refresh access token", http.StatusInternalServerError)
return
}
// Get updated user information
userInfo, err := h.oauth2Provider.GetUserInfo(ctx, tokenResp.AccessToken)
if err != nil {
h.logger.Error("Failed to get user info during refresh", zap.Error(err))
if appErr, ok := err.(*errors.AppError); ok {
http.Error(w, appErr.Message, appErr.StatusCode)
return
}
http.Error(w, "Failed to get user information", http.StatusInternalServerError)
return
}
// Generate new internal JWT token
jwtToken, err := h.generateInternalJWTToken(ctx, userInfo)
if err != nil {
h.logger.Error("Failed to generate internal JWT token during refresh", zap.Error(err))
http.Error(w, "Failed to generate authentication token", http.StatusInternalServerError)
return
}
response := RefreshResponse{
AccessToken: tokenResp.AccessToken,
TokenType: tokenResp.TokenType,
ExpiresIn: tokenResp.ExpiresIn,
RefreshToken: tokenResp.RefreshToken,
JWTToken: jwtToken,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
h.logger.Error("Failed to encode refresh response", zap.Error(err))
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.logger.Debug("Token refresh completed successfully",
zap.String("user_id", userInfo.Sub))
}
// GetUserInfo retrieves user information from the current session
func (h *OAuth2Handler) GetUserInfo(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
h.logger.Debug("Processing user info request")
// Extract JWT token from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Authorization header required", http.StatusUnauthorized)
return
}
// Remove "Bearer " prefix
tokenString := authHeader
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
tokenString = authHeader[7:]
}
// Validate JWT token
authContext, err := h.authService.ValidateJWTToken(ctx, tokenString)
if err != nil {
h.logger.Warn("Invalid JWT token in user info request", zap.Error(err))
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
return
}
// Return user information from JWT claims
userInfo := map[string]interface{}{
"sub": authContext.UserID,
"email": authContext.Claims["email"],
"name": authContext.Claims["name"],
"permissions": authContext.Permissions,
"app_id": authContext.AppID,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(userInfo); err != nil {
h.logger.Error("Failed to encode user info response", zap.Error(err))
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.logger.Debug("User info request completed successfully",
zap.String("user_id", authContext.UserID))
}
// generateState generates a random state parameter for OAuth2
func (h *OAuth2Handler) generateState() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes), nil
}
// generateCodeVerifier generates a PKCE code verifier
func (h *OAuth2Handler) generateCodeVerifier() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
// generateInternalJWTToken generates an internal JWT token for authenticated users
func (h *OAuth2Handler) generateInternalJWTToken(ctx context.Context, userInfo *auth.UserInfo) (string, error) {
// Create user token with information from OAuth2 provider
userToken := &domain.UserToken{
AppID: h.config.GetString("INTERNAL_APP_ID"),
UserID: userInfo.Sub,
Permissions: []string{"read", "write"}, // Default permissions, should be based on user roles
IssuedAt: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour), // 24 hour expiration
MaxValidAt: time.Now().Add(7 * 24 * time.Hour), // 7 days max validity
TokenType: domain.TokenTypeUser,
Claims: map[string]string{
"sub": userInfo.Sub,
"email": userInfo.Email,
"name": userInfo.Name,
"email_verified": func() string {
if userInfo.EmailVerified {
return "true"
}
return "false"
}(),
},
}
// Generate JWT token using authentication service
return h.authService.GenerateJWTToken(ctx, userToken)
}

View File

@ -0,0 +1,352 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"github.com/gorilla/mux"
"go.uber.org/zap"
"github.com/kms/api-key-service/internal/auth"
"github.com/kms/api-key-service/internal/config"
"github.com/kms/api-key-service/internal/domain"
"github.com/kms/api-key-service/internal/errors"
"github.com/kms/api-key-service/internal/services"
)
// SAMLHandler handles SAML authentication endpoints
type SAMLHandler struct {
samlProvider *auth.SAMLProvider
sessionService services.SessionService
authService services.AuthenticationService
tokenService services.TokenService
config config.ConfigProvider
logger *zap.Logger
}
// NewSAMLHandler creates a new SAML handler
func NewSAMLHandler(
config config.ConfigProvider,
sessionService services.SessionService,
authService services.AuthenticationService,
tokenService services.TokenService,
logger *zap.Logger,
) (*SAMLHandler, error) {
samlProvider, err := auth.NewSAMLProvider(config, logger)
if err != nil {
return nil, err
}
return &SAMLHandler{
samlProvider: samlProvider,
sessionService: sessionService,
authService: authService,
config: config,
logger: logger,
}, nil
}
// RegisterRoutes registers SAML routes
func (h *SAMLHandler) RegisterRoutes(router *mux.Router) {
// SAML endpoints
router.HandleFunc("/auth/saml/login", h.InitiateSAMLLogin).Methods("GET")
router.HandleFunc("/auth/saml/acs", h.HandleSAMLResponse).Methods("POST")
router.HandleFunc("/auth/saml/metadata", h.GetServiceProviderMetadata).Methods("GET")
router.HandleFunc("/auth/saml/slo", h.HandleSingleLogout).Methods("GET", "POST")
}
// InitiateSAMLLogin initiates SAML authentication
func (h *SAMLHandler) InitiateSAMLLogin(w http.ResponseWriter, r *http.Request) {
if !h.config.GetBool("SAML_ENABLED") {
h.writeErrorResponse(w, errors.NewConfigurationError("SAML authentication is not enabled"))
return
}
// Get query parameters
appID := r.URL.Query().Get("app_id")
redirectURL := r.URL.Query().Get("redirect_url")
if appID == "" {
h.writeErrorResponse(w, errors.NewValidationError("app_id parameter is required"))
return
}
// Generate relay state with app_id and redirect_url
relayState := appID
if redirectURL != "" {
relayState += "|" + redirectURL
}
h.logger.Debug("Initiating SAML login",
zap.String("app_id", appID),
zap.String("redirect_url", redirectURL))
// Generate SAML authentication request
authURL, requestID, err := h.samlProvider.GenerateAuthRequest(r.Context(), relayState)
if err != nil {
h.logger.Error("Failed to generate SAML auth request", zap.Error(err))
h.writeErrorResponse(w, err)
return
}
// Store request ID in session/cache for validation
// In production, you should store this securely
h.logger.Debug("Generated SAML auth request",
zap.String("request_id", requestID),
zap.String("auth_url", authURL))
// Redirect to IdP
http.Redirect(w, r, authURL, http.StatusFound)
}
// HandleSAMLResponse handles SAML assertion consumer service (ACS)
func (h *SAMLHandler) HandleSAMLResponse(w http.ResponseWriter, r *http.Request) {
if !h.config.GetBool("SAML_ENABLED") {
h.writeErrorResponse(w, errors.NewConfigurationError("SAML authentication is not enabled"))
return
}
h.logger.Debug("Handling SAML response")
// Parse form data
if err := r.ParseForm(); err != nil {
h.writeErrorResponse(w, errors.NewValidationError("Failed to parse form data").WithInternal(err))
return
}
samlResponse := r.FormValue("SAMLResponse")
relayState := r.FormValue("RelayState")
if samlResponse == "" {
h.writeErrorResponse(w, errors.NewValidationError("SAMLResponse is required"))
return
}
h.logger.Debug("Processing SAML response", zap.String("relay_state", relayState))
// Process SAML response
// In production, you should retrieve and validate the original request ID
authContext, err := h.samlProvider.ProcessSAMLResponse(r.Context(), samlResponse, "")
if err != nil {
h.logger.Error("Failed to process SAML response", zap.Error(err))
h.writeErrorResponse(w, err)
return
}
// Parse relay state to get app_id and redirect_url
appID, redirectURL := h.parseRelayState(relayState)
if appID == "" {
h.writeErrorResponse(w, errors.NewValidationError("Invalid relay state: missing app_id"))
return
}
// Create user session
sessionReq := &domain.CreateSessionRequest{
UserID: authContext.UserID,
AppID: appID,
SessionType: domain.SessionTypeWeb,
IPAddress: h.getClientIP(r),
UserAgent: r.UserAgent(),
ExpiresAt: time.Now().Add(8 * time.Hour), // 8 hour session
Permissions: authContext.Permissions,
Claims: authContext.Claims,
}
session, err := h.sessionService.CreateSession(r.Context(), sessionReq)
if err != nil {
h.logger.Error("Failed to create session", zap.Error(err))
h.writeErrorResponse(w, err)
return
}
// Generate JWT token for the session using the existing token service
userToken := &domain.UserToken{
AppID: appID,
UserID: authContext.UserID,
Permissions: authContext.Permissions,
IssuedAt: time.Now(),
ExpiresAt: session.ExpiresAt,
MaxValidAt: session.ExpiresAt,
TokenType: domain.TokenTypeUser,
Claims: authContext.Claims,
}
tokenString, err := h.authService.GenerateJWTToken(r.Context(), userToken)
if err != nil {
h.logger.Error("Failed to create JWT token", zap.Error(err))
h.writeErrorResponse(w, err)
return
}
h.logger.Debug("SAML authentication successful",
zap.String("user_id", authContext.UserID),
zap.String("session_id", session.ID.String()))
// If redirect URL is provided, redirect with token
if redirectURL != "" {
// Add token as query parameter or fragment
redirectURL += "?token=" + tokenString
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
// Otherwise, return JSON response
response := map[string]interface{}{
"success": true,
"token": tokenString,
"user": map[string]interface{}{
"id": authContext.UserID,
"email": authContext.Claims["email"],
"name": authContext.Claims["name"],
},
"session_id": session.ID.String(),
"expires_at": session.ExpiresAt,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// GetServiceProviderMetadata returns SP metadata XML
func (h *SAMLHandler) GetServiceProviderMetadata(w http.ResponseWriter, r *http.Request) {
if !h.config.GetBool("SAML_ENABLED") {
h.writeErrorResponse(w, errors.NewConfigurationError("SAML authentication is not enabled"))
return
}
h.logger.Debug("Generating SP metadata")
metadata, err := h.samlProvider.GenerateServiceProviderMetadata()
if err != nil {
h.logger.Error("Failed to generate SP metadata", zap.Error(err))
h.writeErrorResponse(w, err)
return
}
w.Header().Set("Content-Type", "application/xml")
w.Write([]byte(metadata))
}
// HandleSingleLogout handles SAML single logout
func (h *SAMLHandler) HandleSingleLogout(w http.ResponseWriter, r *http.Request) {
if !h.config.GetBool("SAML_ENABLED") {
h.writeErrorResponse(w, errors.NewConfigurationError("SAML authentication is not enabled"))
return
}
h.logger.Debug("Handling SAML single logout")
// Get session ID from query parameter or form
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" && r.Method == "POST" {
r.ParseForm()
sessionID = r.FormValue("session_id")
}
if sessionID != "" {
// Revoke specific session
h.logger.Debug("Revoking session", zap.String("session_id", sessionID))
// Implementation would depend on how you store session IDs
// For now, we'll just log it
}
// In a full implementation, you would:
// 1. Parse the SAML LogoutRequest
// 2. Validate the request
// 3. Revoke the user's sessions
// 4. Generate a LogoutResponse
// 5. Redirect back to the IdP
// For now, return a simple success response
response := map[string]interface{}{
"success": true,
"message": "Logout successful",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// parseRelayState parses the relay state to extract app_id and redirect_url
func (h *SAMLHandler) parseRelayState(relayState string) (appID, redirectURL string) {
if relayState == "" {
return "", ""
}
// RelayState format: "app_id|redirect_url" or just "app_id"
parts := []string{relayState}
if len(relayState) > 0 && relayState[0] != '|' {
// Split on first pipe character
for i, char := range relayState {
if char == '|' {
parts = []string{relayState[:i], relayState[i+1:]}
break
}
}
}
appID = parts[0]
if len(parts) > 1 {
redirectURL = parts[1]
}
return appID, redirectURL
}
// getClientIP extracts the client IP address from the request
func (h *SAMLHandler) getClientIP(r *http.Request) string {
// Check X-Forwarded-For header first
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Take the first IP if multiple are present
if idx := len(xff); idx > 0 {
for i, char := range xff {
if char == ',' {
return xff[:i]
}
}
return xff
}
}
// Check X-Real-IP header
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
// Fall back to RemoteAddr
return r.RemoteAddr
}
// writeErrorResponse writes an error response
func (h *SAMLHandler) writeErrorResponse(w http.ResponseWriter, err error) {
var statusCode int
var errorCode string
switch {
case errors.IsValidationError(err):
statusCode = http.StatusBadRequest
errorCode = "VALIDATION_ERROR"
case errors.IsAuthenticationError(err):
statusCode = http.StatusUnauthorized
errorCode = "AUTHENTICATION_ERROR"
case errors.IsConfigurationError(err):
statusCode = http.StatusServiceUnavailable
errorCode = "CONFIGURATION_ERROR"
default:
statusCode = http.StatusInternalServerError
errorCode = "INTERNAL_ERROR"
}
response := map[string]interface{}{
"success": false,
"error": map[string]interface{}{
"code": errorCode,
"message": err.Error(),
},
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(response)
}

View File

@ -0,0 +1,231 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kms/api-key-service/internal/domain"
"github.com/kms/api-key-service/internal/errors"
"github.com/kms/api-key-service/internal/services"
"github.com/kms/api-key-service/internal/validation"
)
// TokenHandler handles token-related HTTP requests
type TokenHandler struct {
tokenService services.TokenService
authService services.AuthenticationService
validator *validation.Validator
errorHandler *errors.ErrorHandler
logger *zap.Logger
}
// NewTokenHandler creates a new token handler
func NewTokenHandler(
tokenService services.TokenService,
authService services.AuthenticationService,
logger *zap.Logger,
) *TokenHandler {
return &TokenHandler{
tokenService: tokenService,
authService: authService,
validator: validation.NewValidator(logger),
errorHandler: errors.NewErrorHandler(logger),
logger: logger,
}
}
// Create handles POST /applications/:id/tokens
func (h *TokenHandler) Create(c *gin.Context) {
// Validate application ID parameter
appID := c.Param("id")
if appID == "" {
h.errorHandler.HandleValidationError(c, "id", "Application ID is required")
return
}
// Bind and validate JSON request
var req domain.CreateStaticTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Warn("Invalid request body", zap.Error(err))
h.errorHandler.HandleValidationError(c, "request_body", "Invalid request body format")
return
}
// Set app ID from URL parameter
req.AppID = appID
// Basic validation - the service layer will do more comprehensive validation
if req.AppID == "" {
h.errorHandler.HandleValidationError(c, "app_id", "Application ID is required")
return
}
// Get user ID from context
userID, exists := c.Get("user_id")
if !exists {
h.logger.Error("User ID not found in context")
h.errorHandler.HandleAuthenticationError(c, errors.NewAuthenticationError("Authentication context not found"))
return
}
userIDStr, ok := userID.(string)
if !ok {
h.logger.Error("Invalid user ID type in context", zap.Any("user_id", userID))
h.errorHandler.HandleInternalError(c, errors.NewInternalError("Invalid authentication context"))
return
}
// Create the token
token, err := h.tokenService.CreateStaticToken(c.Request.Context(), &req, userIDStr)
if err != nil {
h.logger.Error("Failed to create token",
zap.Error(err),
zap.String("app_id", appID),
zap.String("user_id", userIDStr))
// Handle different types of errors appropriately
if errors.IsNotFound(err) {
h.errorHandler.HandleError(c, err, "Application not found")
} else if errors.IsValidationError(err) {
h.errorHandler.HandleValidationError(c, "token", "Token creation validation failed")
} else if errors.IsAuthorizationError(err) {
h.errorHandler.HandleAuthorizationError(c, "token_creation")
} else {
h.errorHandler.HandleInternalError(c, err)
}
return
}
h.logger.Info("Token created successfully",
zap.String("token_id", token.ID.String()),
zap.String("app_id", appID),
zap.String("user_id", userIDStr))
c.JSON(http.StatusCreated, token)
}
// ListByApp handles GET /applications/:id/tokens
func (h *TokenHandler) ListByApp(c *gin.Context) {
// Validate application ID parameter
appID := c.Param("id")
if appID == "" {
h.errorHandler.HandleValidationError(c, "id", "Application ID is required")
return
}
// Parse and validate pagination parameters
limit := 50
offset := 0
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 1000 {
limit = parsed
} else if parsed <= 0 || parsed > 1000 {
h.errorHandler.HandleValidationError(c, "limit", "Limit must be between 1 and 1000")
return
}
}
if o := c.Query("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
offset = parsed
} else if parsed < 0 {
h.errorHandler.HandleValidationError(c, "offset", "Offset must be non-negative")
return
}
}
// List tokens
tokens, err := h.tokenService.ListByApp(c.Request.Context(), appID, limit, offset)
if err != nil {
h.logger.Error("Failed to list tokens",
zap.Error(err),
zap.String("app_id", appID),
zap.Int("limit", limit),
zap.Int("offset", offset))
// Handle different types of errors appropriately
if errors.IsNotFound(err) {
h.errorHandler.HandleNotFoundError(c, "application", "Application not found")
} else if errors.IsAuthorizationError(err) {
h.errorHandler.HandleAuthorizationError(c, "token_list")
} else {
h.errorHandler.HandleInternalError(c, err)
}
return
}
h.logger.Debug("Tokens listed successfully",
zap.String("app_id", appID),
zap.Int("token_count", len(tokens)),
zap.Int("limit", limit),
zap.Int("offset", offset))
c.JSON(http.StatusOK, gin.H{
"data": tokens,
"limit": limit,
"offset": offset,
"count": len(tokens),
})
}
// Delete handles DELETE /tokens/:id
func (h *TokenHandler) Delete(c *gin.Context) {
// Validate token ID parameter
tokenIDStr := c.Param("id")
if tokenIDStr == "" {
h.errorHandler.HandleValidationError(c, "id", "Token ID is required")
return
}
tokenID, err := uuid.Parse(tokenIDStr)
if err != nil {
h.logger.Warn("Invalid token ID format", zap.String("token_id", tokenIDStr), zap.Error(err))
h.errorHandler.HandleValidationError(c, "id", "Invalid token ID format")
return
}
// Get user ID from context
userID, exists := c.Get("user_id")
if !exists {
h.logger.Error("User ID not found in context")
h.errorHandler.HandleAuthenticationError(c, errors.NewAuthenticationError("Authentication context not found"))
return
}
userIDStr, ok := userID.(string)
if !ok {
h.logger.Error("Invalid user ID type in context", zap.Any("user_id", userID))
h.errorHandler.HandleInternalError(c, errors.NewInternalError("Invalid authentication context"))
return
}
// Delete the token
err = h.tokenService.Delete(c.Request.Context(), tokenID, userIDStr)
if err != nil {
h.logger.Error("Failed to delete token",
zap.Error(err),
zap.String("token_id", tokenID.String()),
zap.String("user_id", userIDStr))
// Handle different types of errors appropriately
if errors.IsNotFound(err) {
h.errorHandler.HandleNotFoundError(c, "token", "Token not found")
} else if errors.IsAuthorizationError(err) {
h.errorHandler.HandleAuthorizationError(c, "token_deletion")
} else {
h.errorHandler.HandleInternalError(c, err)
}
return
}
h.logger.Info("Token deleted successfully",
zap.String("token_id", tokenID.String()),
zap.String("user_id", userIDStr))
c.JSON(http.StatusNoContent, nil)
}