1 Commits
master ... sso

Author SHA1 Message Date
86900b0bd4 sso 2025-08-26 19:15:37 -04:00
123 changed files with 2434 additions and 6 deletions

View File

View File

@ -88,6 +88,9 @@ npm test
# Start all services (PostgreSQL, API, Nginx, Frontend)
podman-compose up -d
# Start with SSO testing enabled (Keycloak + SAML IdP)
podman-compose -f docker-compose.yml -f docker-compose.sso.yml up -d
# Check service health
curl http://localhost:8081/health
@ -97,10 +100,15 @@ podman-compose logs -f
# View specific service logs
podman-compose logs -f api-service
podman-compose logs -f postgres
podman-compose logs -f keycloak
podman-compose logs -f saml-idp
# Stop services
podman-compose down
# Stop SSO services
podman-compose -f docker-compose.yml -f docker-compose.sso.yml down
# Rebuild services after code changes
podman-compose up -d --build
```
@ -182,12 +190,34 @@ podman-compose down
- **Port 3000**: React frontend (direct access)
- **Port 5432**: PostgreSQL database
- **Port 9090**: Metrics endpoint (if enabled)
- **Port 8090**: Keycloak SSO server (admin console)
- **Port 8091**: SimpleSAMLphp IdP (SAML console: /simplesaml)
- **Port 8443**: SimpleSAMLphp IdP (HTTPS)
The service provides different test user contexts:
- Regular user: `test@example.com`
- Admin user: `admin@example.com`
- Limited user: `limited@example.com`
### SSO Testing Users
For SSO testing with Keycloak or SAML IdP, use these credentials:
| Email | Password | Permissions | Provider |
|-------|----------|-------------|----------|
| admin@example.com | admin123 | internal.* | Keycloak |
| test@example.com | test123 | app.read, token.read | Keycloak |
| limited@example.com | limited123 | repo.read | Keycloak |
| user1@example.com | user1pass | Basic access | SAML IdP |
| user2@example.com | user2pass | Basic access | SAML IdP |
### SSO Access Points
- **Keycloak Admin Console**: http://localhost:8090 (admin / admin)
- **SAML IdP Admin Console**: http://localhost:8091/simplesaml (admin / secret)
- **Keycloak Realm**: http://localhost:8090/realms/kms
- **SAML IdP Metadata**: http://localhost:8091/simplesaml/saml2/idp/metadata.php
## Key Configuration
### Required Environment Variables
@ -211,14 +241,28 @@ SERVER_HOST=0.0.0.0
SERVER_PORT=8080
# Authentication
AUTH_PROVIDER=header # or 'sso'
AUTH_PROVIDER=header # 'header', 'sso', or 'saml'
AUTH_HEADER_USER_EMAIL=X-User-Email
# SSO / OAuth2 Configuration (for Keycloak)
OAUTH2_ENABLED=false # Set to true for OAuth2/OIDC auth
OAUTH2_PROVIDER_URL=http://keycloak:8080/realms/kms
OAUTH2_CLIENT_ID=kms-api
OAUTH2_CLIENT_SECRET=kms-client-secret
OAUTH2_REDIRECT_URL=http://localhost:8081/api/oauth2/callback
# SAML Configuration (for SimpleSAMLphp)
SAML_ENABLED=false # Set to true for SAML auth
SAML_IDP_SSO_URL=http://saml-idp:8080/simplesaml/saml2/idp/SSOService.php
SAML_IDP_METADATA_URL=http://saml-idp:8080/simplesaml/saml2/idp/metadata.php
SAML_SP_ENTITY_ID=http://localhost:8081
SAML_SP_ACS_URL=http://localhost:8081/api/saml/acs
SAML_SP_SLS_URL=http://localhost:8081/api/saml/sls
# Features
RATE_LIMIT_ENABLED=true
CACHE_ENABLED=false # Set to true to enable Redis
METRICS_ENABLED=true
SAML_ENABLED=false # Set to true for SAML auth
```
### Optional Configuration
@ -323,15 +367,67 @@ Example: `repo` permission includes `repo.read` and `repo.write`.
- **Filtering**: Support for date ranges, event types, statuses, users, resource types
- **Statistics**: Aggregated metrics by type, severity, status, and time
## SSO Testing Workflow
### Quick Start - OAuth2/OIDC Testing (Keycloak)
```bash
# 1. Start services with SSO enabled
podman-compose -f docker-compose.yml -f docker-compose.sso.yml up -d
# 2. Wait for Keycloak to start (check logs)
podman-compose logs -f keycloak
# 3. Test OAuth2 login flow
curl -v "http://localhost:8090/realms/kms/protocol/openid-connect/auth?client_id=kms-api&response_type=code&redirect_uri=http://localhost:8081/api/oauth2/callback"
# 4. Access Keycloak admin console
open http://localhost:8090
# Login with: admin / admin
# 5. Test API with OAuth2 token
# (Use Keycloak to get access token, then use in Authorization: Bearer header)
```
### Quick Start - SAML Testing (SimpleSAMLphp)
```bash
# 1. Services should already be running from previous step
# 2. Access SAML IdP admin console
open http://localhost:8091/simplesaml
# Login with: admin / secret
# 3. View IdP metadata
curl http://localhost:8091/simplesaml/saml2/idp/metadata.php
# 4. Test SAML authentication flow
# Navigate to your app and it should redirect to SAML IdP for auth
```
### Environment Switching
```bash
# Switch to OAuth2 mode
podman exec kms-api-service sh -c "export AUTH_PROVIDER=sso OAUTH2_ENABLED=true && supervisorctl restart all"
# Switch to SAML mode
podman exec kms-api-service sh -c "export AUTH_PROVIDER=sso SAML_ENABLED=true && supervisorctl restart all"
# Switch back to header mode
podman exec kms-api-service sh -c "export AUTH_PROVIDER=header && supervisorctl restart all"
```
## Development Notes
### Critical Information
- **Go Version**: Requires Go 1.23+ (currently using 1.24.4)
- **Node Version**: Requires Node 24+ and npm 11+
- **Database**: Auto-migrations run on startup
- **Container Names**: Use `kms-postgres`, `kms-api-service`, `kms-frontend`, `kms-nginx`
- **Default Ports**: API:8080, Nginx:8081, Frontend:3000, DB:5432, Metrics:9090
- **Container Names**: Use `kms-postgres`, `kms-api-service`, `kms-frontend`, `kms-nginx`, `kms-keycloak`, `kms-saml-idp`
- **Default Ports**: API:8080, Nginx:8081, Frontend:3000, DB:5432, Metrics:9090, Keycloak:8090, SAML:8091
- **Test Database**: `kms_test` (separate from `kms`)
- **SSO Config**: Located in `sso-config/` directory
### Important Files
- `internal/config/config.go` - Complete configuration management

343
cmd/server/main.go Normal file
View File

@ -0,0 +1,343 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/kms/api-key-service/internal/audit"
"github.com/kms/api-key-service/internal/config"
"github.com/kms/api-key-service/internal/database"
"github.com/kms/api-key-service/internal/domain"
"github.com/kms/api-key-service/internal/handlers"
"github.com/kms/api-key-service/internal/metrics"
"github.com/kms/api-key-service/internal/middleware"
"github.com/kms/api-key-service/internal/repository/postgres"
"github.com/kms/api-key-service/internal/services"
)
func main() {
// Initialize configuration
cfg := config.NewConfig()
if err := cfg.Validate(); err != nil {
log.Fatal("Configuration validation failed:", err)
}
// Initialize logger
logger := initLogger(cfg)
defer logger.Sync()
logger.Info("Starting API Key Management Service",
zap.String("version", cfg.GetString("APP_VERSION")),
zap.String("environment", cfg.GetString("APP_ENV")),
)
// Initialize database
logger.Info("Connecting to database",
zap.String("dsn", cfg.GetDatabaseDSNForLogging()))
db, err := database.NewPostgresProvider(
cfg.GetDatabaseDSN(),
cfg.GetInt("DB_MAX_OPEN_CONNS"),
cfg.GetInt("DB_MAX_IDLE_CONNS"),
cfg.GetString("DB_CONN_MAX_LIFETIME"),
)
if err != nil {
logger.Fatal("Failed to initialize database",
zap.String("dsn", cfg.GetDatabaseDSNForLogging()),
zap.Error(err))
}
logger.Info("Database connection established successfully")
// Database migrations are handled by PostgreSQL docker-entrypoint-initdb.d
logger.Info("Database migrations are handled by PostgreSQL on container startup")
// Initialize repositories
appRepo := postgres.NewApplicationRepository(db)
tokenRepo := postgres.NewStaticTokenRepository(db)
permRepo := postgres.NewPermissionRepository(db)
grantRepo := postgres.NewGrantedPermissionRepository(db)
auditRepo := postgres.NewAuditRepository(db)
// Initialize audit logger
auditLogger := audit.NewAuditLogger(cfg, logger, auditRepo)
// Initialize services
appService := services.NewApplicationService(appRepo, auditRepo, logger)
tokenService := services.NewTokenService(tokenRepo, appRepo, permRepo, grantRepo, cfg.GetString("INTERNAL_HMAC_KEY"), cfg, logger)
authService := services.NewAuthenticationService(cfg, logger, permRepo)
// Initialize handlers
healthHandler := handlers.NewHealthHandler(db, logger)
appHandler := handlers.NewApplicationHandler(appService, authService, logger)
tokenHandler := handlers.NewTokenHandler(tokenService, authService, logger)
authHandler := handlers.NewAuthHandler(authService, tokenService, cfg, logger)
auditHandler := handlers.NewAuditHandler(auditLogger, authService, logger)
testHandler := handlers.NewTestHandler(logger)
// Set up router
router := setupRouter(cfg, logger, healthHandler, appHandler, tokenHandler, authHandler, auditHandler, testHandler)
// Create HTTP server
srv := &http.Server{
Addr: cfg.GetServerAddress(),
Handler: router,
ReadTimeout: cfg.GetDuration("SERVER_READ_TIMEOUT"),
WriteTimeout: cfg.GetDuration("SERVER_WRITE_TIMEOUT"),
IdleTimeout: cfg.GetDuration("SERVER_IDLE_TIMEOUT"),
}
// Initialize bootstrap data
logger.Info("Initializing bootstrap data")
if err := initializeBootstrapData(context.Background(), appService, tokenService, cfg, logger); err != nil {
logger.Fatal("Failed to initialize bootstrap data", zap.Error(err))
}
// Start server in goroutine
go func() {
logger.Info("Starting HTTP server", zap.String("address", srv.Addr))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal("Failed to start server", zap.Error(err))
}
}()
// Start metrics server if enabled
var metricsSrv *http.Server
if cfg.GetBool("METRICS_ENABLED") {
metricsSrv = startMetricsServer(cfg, logger)
}
// Wait for interrupt signal to gracefully shutdown the server
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("Shutting down server...")
// Give outstanding requests time to complete
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Shutdown main server
if err := srv.Shutdown(ctx); err != nil {
logger.Error("Server forced to shutdown", zap.Error(err))
}
// Shutdown metrics server
if metricsSrv != nil {
if err := metricsSrv.Shutdown(ctx); err != nil {
logger.Error("Metrics server forced to shutdown", zap.Error(err))
}
}
logger.Info("Server exited")
}
func initLogger(cfg config.ConfigProvider) *zap.Logger {
var logger *zap.Logger
var err error
if cfg.IsProduction() {
logger, err = zap.NewProduction()
} else {
logger, err = zap.NewDevelopment()
}
if err != nil {
log.Fatal("Failed to initialize logger:", err)
}
return logger
}
func setupRouter(cfg config.ConfigProvider, logger *zap.Logger, healthHandler *handlers.HealthHandler, appHandler *handlers.ApplicationHandler, tokenHandler *handlers.TokenHandler, authHandler *handlers.AuthHandler, auditHandler *handlers.AuditHandler, testHandler *handlers.TestHandler) *gin.Engine {
// Set Gin mode based on environment
if cfg.IsProduction() {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
// Add middleware
router.Use(middleware.Logger(logger))
router.Use(middleware.Recovery(logger))
router.Use(metrics.Middleware(logger))
router.Use(middleware.CORS())
router.Use(middleware.Security())
router.Use(middleware.ValidateContentType())
if cfg.GetBool("RATE_LIMIT_ENABLED") {
router.Use(middleware.RateLimit(cfg.GetInt("RATE_LIMIT_RPS"), cfg.GetInt("RATE_LIMIT_BURST")))
}
// Health check endpoint (no authentication required)
router.GET("/health", healthHandler.Health)
router.GET("/ready", healthHandler.Ready)
// Development/Testing endpoints (no authentication required)
if !cfg.IsProduction() {
router.GET("/test/sso", testHandler.SSOTestPage)
}
// API routes
api := router.Group("/api")
{
// Authentication endpoints (no prior auth required)
api.GET("/login", authHandler.Login) // HTML page for browser access
api.POST("/login", authHandler.Login) // JSON API for programmatic access
api.POST("/verify", authHandler.Verify)
api.POST("/renew", authHandler.Renew)
// Protected routes (require authentication)
protected := api.Group("/")
protected.Use(middleware.Authentication(cfg, logger))
{
// Application management
protected.GET("/applications", appHandler.List)
protected.POST("/applications", appHandler.Create)
protected.GET("/applications/:id", appHandler.GetByID)
protected.PUT("/applications/:id", appHandler.Update)
protected.DELETE("/applications/:id", appHandler.Delete)
// Token management
protected.GET("/applications/:id/tokens", tokenHandler.ListByApp)
protected.POST("/applications/:id/tokens", tokenHandler.Create)
protected.DELETE("/tokens/:id", tokenHandler.Delete)
// Audit management
protected.GET("/audit/events", auditHandler.ListEvents)
protected.GET("/audit/events/:id", auditHandler.GetEvent)
protected.GET("/audit/stats", auditHandler.GetStats)
// Documentation endpoint
protected.GET("/docs", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"service": "API Key Management Service",
"version": cfg.GetString("APP_VERSION"),
"documentation": "See README.md and docs/ directory",
"endpoints": map[string]interface{}{
"authentication": []string{
"POST /api/login",
"POST /api/verify",
"POST /api/renew",
},
"applications": []string{
"GET /api/applications",
"POST /api/applications",
"GET /api/applications/:id",
"PUT /api/applications/:id",
"DELETE /api/applications/:id",
},
"tokens": []string{
"GET /api/applications/:id/tokens",
"POST /api/applications/:id/tokens",
"DELETE /api/tokens/:id",
},
"audit": []string{
"GET /api/audit/events",
"GET /api/audit/events/:id",
"GET /api/audit/stats",
},
},
})
})
}
}
return router
}
func startMetricsServer(cfg config.ConfigProvider, logger *zap.Logger) *http.Server {
mux := http.NewServeMux()
// Prometheus metrics endpoint
mux.HandleFunc("/metrics", metrics.PrometheusHandler())
// Health endpoint for metrics server
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
srv := &http.Server{
Addr: cfg.GetMetricsAddress(),
Handler: mux,
}
go func() {
logger.Info("Starting metrics server", zap.String("address", srv.Addr))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("Failed to start metrics server", zap.Error(err))
}
}()
return srv
}
func initializeBootstrapData(ctx context.Context, appService services.ApplicationService, tokenService services.TokenService, cfg config.ConfigProvider, logger *zap.Logger) error {
// Check if internal application already exists
internalAppID := cfg.GetString("INTERNAL_APP_ID")
_, err := appService.GetByID(ctx, internalAppID)
if err == nil {
logger.Info("Internal application already exists, skipping bootstrap")
return nil
}
logger.Info("Creating internal application for bootstrap", zap.String("app_id", internalAppID))
// Create internal application for system operations
internalAppReq := &domain.CreateApplicationRequest{
AppID: internalAppID,
AppLink: "https://kms.internal/system",
Type: []domain.ApplicationType{domain.ApplicationTypeStatic, domain.ApplicationTypeUser},
CallbackURL: "https://kms.internal/callback",
TokenPrefix: "KMS",
TokenRenewalDuration: domain.Duration{Duration: 365 * 24 * time.Hour}, // 1 year
MaxTokenDuration: domain.Duration{Duration: 365 * 24 * time.Hour}, // 1 year
Owner: domain.Owner{
Type: domain.OwnerTypeTeam,
Name: "KMS System",
Owner: "system@kms.internal",
},
}
app, err := appService.Create(ctx, internalAppReq, "system")
if err != nil {
logger.Error("Failed to create internal application", zap.Error(err))
return err
}
logger.Info("Internal application created successfully",
zap.String("app_id", app.AppID),
zap.String("hmac_key", app.HMACKey))
// Create a static token for internal system operations if needed
internalTokenReq := &domain.CreateStaticTokenRequest{
AppID: internalAppID,
Owner: domain.Owner{
Type: domain.OwnerTypeTeam,
Name: "KMS System Token",
Owner: "system@kms.internal",
},
Permissions: []string{"internal.*", "app.*", "token.*", "audit.*"},
}
token, err := tokenService.CreateStaticToken(ctx, internalTokenReq, "system")
if err != nil {
logger.Warn("Failed to create internal system token, continuing...", zap.Error(err))
} else {
logger.Info("Internal system token created successfully",
zap.String("token_id", token.ID.String()))
}
logger.Info("Bootstrap data initialization completed successfully")
return nil
}

22
docker-compose.sso.yml Normal file
View File

@ -0,0 +1,22 @@
version: '3.8'
# Override file for enabling SSO testing
# Usage: podman-compose -f docker-compose.yml -f docker-compose.sso.yml up -d
services:
api-service:
environment:
# Enable OAuth2 for Keycloak testing
OAUTH2_ENABLED: true
# Enable SAML for SimpleSAMLphp testing
SAML_ENABLED: true
# Switch to SSO auth provider instead of header
AUTH_PROVIDER: sso
# Set the required SSO configuration
SSO_PROVIDER_URL: http://keycloak:8080/realms/kms
SSO_CLIENT_ID: kms-api
SSO_CLIENT_SECRET: kms-client-secret
SSO_REDIRECT_URL: http://localhost:8081/api/sso/callback
depends_on:
- keycloak
- saml-idp

View File

@ -63,6 +63,19 @@ services:
RATE_LIMIT_ENABLED: true
CACHE_ENABLED: false
METRICS_ENABLED: true
# OAuth2 / OIDC Configuration (for Keycloak)
OAUTH2_ENABLED: false
OAUTH2_PROVIDER_URL: http://keycloak:8080/realms/kms
OAUTH2_CLIENT_ID: kms-api
OAUTH2_CLIENT_SECRET: kms-client-secret
OAUTH2_REDIRECT_URL: http://localhost:8081/api/oauth2/callback
# SAML Configuration (for SimpleSAMLphp)
SAML_ENABLED: false
SAML_IDP_SSO_URL: http://saml-idp:8080/simplesaml/saml2/idp/SSOService.php
SAML_IDP_METADATA_URL: http://saml-idp:8080/simplesaml/saml2/idp/metadata.php
SAML_SP_ENTITY_ID: http://localhost:8081
SAML_SP_ACS_URL: http://localhost:8081/api/saml/acs
SAML_SP_SLS_URL: http://localhost:8081/api/saml/sls
ports:
- "8080:8080"
- "9090:9090" # Metrics port
@ -86,6 +99,39 @@ services:
- kms-network
restart: unless-stopped
# Keycloak OAuth2/OIDC Identity Provider for testing
keycloak:
image: quay.io/keycloak/keycloak:25.0.2
container_name: kms-keycloak
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_DB: dev-file
ports:
- "8090:8080"
networks:
- kms-network
command: ["start-dev", "--import-realm"]
volumes:
- ./sso-config/keycloak:/opt/keycloak/data/import:Z
restart: unless-stopped
# SimpleSAMLphp SAML Identity Provider for testing
saml-idp:
image: kristophjunge/test-saml-idp:1.15
container_name: kms-saml-idp
environment:
SIMPLESAMLPHP_SP_ENTITY_ID: http://localhost:8081
SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE: http://localhost:8081/api/saml/acs
SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE: http://localhost:8081/api/saml/sls
SIMPLESAMLPHP_TRUSTED_DOMAINS: '["localhost", "kms-api-service", "kms-nginx"]'
ports:
- "8091:8080"
- "8443:8443"
networks:
- kms-network
restart: unless-stopped
volumes:
postgres_data:
driver: local

View File

View File

491
internal/handlers/test.go Normal file
View File

@ -0,0 +1,491 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// TestHandler handles test endpoints for development
type TestHandler struct {
logger *zap.Logger
}
// NewTestHandler creates a new test handler
func NewTestHandler(logger *zap.Logger) *TestHandler {
return &TestHandler{
logger: logger,
}
}
// SSOTestPage serves the SSO manual test page
func (h *TestHandler) SSOTestPage(c *gin.Context) {
h.logger.Debug("Serving SSO test page")
html := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KMS SSO Manual Testing</title>
<style>
body { font-family: Arial, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
.container { background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #333; border-bottom: 3px solid #007acc; padding-bottom: 10px; }
h2 { color: #007acc; margin-top: 30px; }
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #ddd; border-radius: 5px; background: #fafafa; }
.btn { display: inline-block; padding: 12px 20px; margin: 10px 5px; background: #007acc; color: white; text-decoration: none; border-radius: 5px; font-weight: bold; }
.btn:hover { background: #005a9e; }
.btn-secondary { background: #28a745; }
.btn-warning { background: #ffc107; color: #333; }
.status { padding: 10px; margin: 10px 0; border-radius: 3px; }
.status.success { background: #d4edda; border: 1px solid #c3e6cb; color: #155724; }
.status.error { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; }
.status.info { background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; }
.code { background: #f8f9fa; padding: 15px; border-radius: 5px; font-family: monospace; margin: 10px 0; overflow-x: auto; }
.endpoint { margin: 10px 0; }
.endpoint strong { color: #007acc; }
pre { background: #f8f9fa; padding: 15px; border-radius: 5px; overflow-x: auto; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin: 20px 0; }
.card { padding: 20px; border: 1px solid #ddd; border-radius: 5px; background: white; }
.user-info { background: #e7f3ff; padding: 10px; border-radius: 5px; margin: 10px 0; }
button { background: #007acc; color: white; border: none; padding: 12px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; margin: 5px; }
button:hover { background: #005a9e; }
.test-result { margin: 10px 0; padding: 10px; border-radius: 3px; }
</style>
</head>
<body>
<div class="container">
<h1>🔐 KMS SSO Manual Testing Suite</h1>
<p><strong>Served from KMS API</strong> - No CORS issues!</p>
<div class="status info">
<strong>Environment Status:</strong> Local Development
<div id="services-status">Loading service status...</div>
</div>
<div class="grid">
<div class="card">
<h2>🎯 OAuth2/OIDC Testing (Keycloak)</h2>
<div class="user-info">
<strong>Test Users:</strong><br>
• admin@example.com / admin123 (Full access)<br>
• test@example.com / test123 (Limited access)<br>
• limited@example.com / limited123 (Read-only)
</div>
<div class="endpoint">
<strong>Admin Console:</strong><br>
<a href="http://localhost:8090" target="_blank" class="btn">Open Keycloak Admin</a>
<small>Login: admin / admin</small>
</div>
<div class="endpoint">
<strong>OAuth2 Authorization Flow:</strong><br>
<a href="http://localhost:8090/realms/kms/protocol/openid-connect/auth?client_id=kms-api&response_type=code&redirect_uri=http://localhost:3000/callback&scope=openid+email+profile&state=test123" target="_blank" class="btn">Test OAuth2 Login</a>
</div>
<div class="endpoint">
<strong>Discovery Document:</strong><br>
<button onclick="testOIDCDiscovery()">Test OIDC Discovery</button>
<div id="oidc-result"></div>
</div>
</div>
<div class="card">
<h2>📝 SAML Testing (SimpleSAMLphp)</h2>
<div class="user-info">
<strong>Test Users:</strong><br>
• user1 / user1pass<br>
• user2 / user2pass
</div>
<div class="endpoint">
<strong>Admin Console:</strong><br>
<a href="http://localhost:8091/simplesaml" target="_blank" class="btn">Open SAML Admin</a>
<small>Login: admin / secret</small>
</div>
<div class="endpoint">
<strong>SAML Metadata:</strong><br>
<button onclick="testSAMLMetadata()">Test SAML Metadata</button>
<div id="saml-result"></div>
</div>
<div class="endpoint">
<strong>Test Authentication:</strong><br>
<a href="http://localhost:8091/simplesaml/module.php/core/authenticate.php?as=example-userpass" target="_blank" class="btn">Test SAML Login</a>
</div>
</div>
</div>
<div class="test-section">
<h2>🚀 KMS API Testing</h2>
<div class="endpoint">
<strong>Frontend Application:</strong><br>
<a href="http://localhost:3000" target="_blank" class="btn">Open KMS Frontend</a>
</div>
<div class="endpoint">
<strong>API Health Check:</strong><br>
<button onclick="testHealth()">Check API Health</button>
<div id="health-result"></div>
</div>
<div class="endpoint">
<strong>Test API with Different Users:</strong><br>
<button onclick="testAPI('admin@example.com')">Test as Admin</button>
<button onclick="testAPI('test@example.com')">Test as User</button>
<button onclick="testAPI('limited@example.com')">Test as Limited</button>
<div id="api-result"></div>
</div>
<div class="endpoint">
<strong>Create Test Application:</strong><br>
<button onclick="createTestApp()">Create Test App</button>
<div id="create-app-result"></div>
</div>
</div>
<div class="test-section">
<h2>🔍 Permission System Testing</h2>
<button onclick="testPermissions()">Test Permission System</button>
<div id="permission-result"></div>
</div>
<div class="test-section">
<h2>📊 Current Implementation Status</h2>
<div class="status success">
<strong>✅ Working:</strong><br>
• Keycloak OAuth2/OIDC provider with test realm<br>
• SimpleSAMLphp SAML IdP with test users<br>
• KMS API with header authentication<br>
• Hierarchical permission system (25+ permissions)<br>
• Application and token management<br>
• Database with proper permission structure
</div>
<div class="status error">
<strong>❌ Missing:</strong><br>
• OAuth2 callback handler in KMS API<br>
• SAML assertion processing in KMS API<br>
• Frontend SSO login integration<br>
• Automatic permission mapping from SSO claims
</div>
<div class="status info">
<strong> Next Steps:</strong><br>
• Complete OAuth2 callback implementation<br>
• Add SAML response handling<br>
• Map SSO user attributes to KMS permissions<br>
• Add SSO login buttons to frontend
</div>
</div>
<div class="test-section">
<h2>🛠️ Development Commands</h2>
<div class="code">
<pre># Start SSO services
podman-compose -f docker-compose.yml -f docker-compose.sso.yml up -d
# Run automated tests
./test/quick_sso_test.sh
# Check service logs
podman-compose logs keycloak
podman-compose logs saml-idp
podman-compose logs api-service
# Reset to header auth mode
podman-compose up -d</pre>
</div>
</div>
</div>
<script>
// Test OIDC Discovery
async function testOIDCDiscovery() {
const resultDiv = document.getElementById('oidc-result');
resultDiv.innerHTML = '<div style="color: blue;">Testing OIDC discovery...</div>';
try {
const response = await fetch('http://localhost:8090/realms/kms/.well-known/openid-configuration');
const data = await response.json();
resultDiv.innerHTML = ` + "`" + `
<div class="test-result" style="background: #d4edda; border: 1px solid #c3e6cb; color: #155724;">
<strong>✅ OIDC Discovery Successful!</strong><br>
<strong>Authorization Endpoint:</strong> ${data.authorization_endpoint}<br>
<strong>Token Endpoint:</strong> ${data.token_endpoint}<br>
<strong>UserInfo Endpoint:</strong> ${data.userinfo_endpoint}<br>
<details style="margin-top: 10px;">
<summary>View Full Response</summary>
<pre style="max-height: 200px; overflow-y: auto;">${JSON.stringify(data, null, 2)}</pre>
</details>
</div>
` + "`" + `;
} catch (error) {
resultDiv.innerHTML = ` + "`" + `
<div class="test-result" style="background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24;">
<strong>❌ OIDC Discovery Failed</strong><br>
${error.message}
</div>
` + "`" + `;
}
}
// Test SAML Metadata
async function testSAMLMetadata() {
const resultDiv = document.getElementById('saml-result');
resultDiv.innerHTML = '<div style="color: blue;">Testing SAML metadata...</div>';
try {
const response = await fetch('http://localhost:8091/simplesaml/saml2/idp/metadata.php');
const text = await response.text();
if (text.includes('EntityDescriptor')) {
const entityId = text.match(/entityID="([^"]*)"/)?.[1] || 'Not found';
resultDiv.innerHTML = ` + "`" + `
<div class="test-result" style="background: #d4edda; border: 1px solid #c3e6cb; color: #155724;">
<strong>✅ SAML Metadata Accessible!</strong><br>
<strong>Entity ID:</strong> ${entityId}<br>
<details style="margin-top: 10px;">
<summary>View Metadata</summary>
<pre style="max-height: 200px; overflow-y: auto;">${text}</pre>
</details>
</div>
` + "`" + `;
} else {
resultDiv.innerHTML = ` + "`" + `
<div class="test-result" style="background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24;">
<strong>❌ Invalid SAML Metadata</strong><br>
Response does not contain EntityDescriptor
</div>
` + "`" + `;
}
} catch (error) {
resultDiv.innerHTML = ` + "`" + `
<div class="test-result" style="background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24;">
<strong>❌ SAML Metadata Test Failed</strong><br>
${error.message}
</div>
` + "`" + `;
}
}
// Test API Health
async function testHealth() {
const resultDiv = document.getElementById('health-result');
resultDiv.innerHTML = '<div style="color: blue;">Checking API health...</div>';
try {
const response = await fetch('/health');
const text = await response.text();
if (text === 'healthy') {
resultDiv.innerHTML = ` + "`" + `
<div class="test-result" style="background: #d4edda; border: 1px solid #c3e6cb; color: #155724;">
<strong>✅ API is Healthy!</strong>
</div>
` + "`" + `;
} else {
resultDiv.innerHTML = ` + "`" + `
<div class="test-result" style="background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24;">
<strong>❌ API Health Check Failed</strong><br>
Response: ${text}
</div>
` + "`" + `;
}
} catch (error) {
resultDiv.innerHTML = ` + "`" + `
<div class="test-result" style="background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24;">
<strong>❌ API Health Check Error</strong><br>
${error.message}
</div>
` + "`" + `;
}
}
// Test API with different users (simulating SSO result)
async function testAPI(userEmail) {
const resultDiv = document.getElementById('api-result');
resultDiv.innerHTML = ` + "`" + `<div style="color: blue;">Testing API as ${userEmail}...</div>` + "`" + `;
try {
const response = await fetch('/api/applications', {
headers: {
'X-User-Email': userEmail,
'Accept': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
resultDiv.innerHTML = ` + "`" + `
<div class="test-result" style="background: #d4edda; border: 1px solid #c3e6cb; color: #155724;">
<strong>✅ API Test Successful (${userEmail})!</strong><br>
Found ${data.count} applications<br>
<details style="margin-top: 10px;">
<summary>View Response</summary>
<pre style="max-height: 200px; overflow-y: auto;">${JSON.stringify(data, null, 2)}</pre>
</details>
</div>
` + "`" + `;
} else {
resultDiv.innerHTML = ` + "`" + `
<div class="test-result" style="background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24;">
<strong>❌ API Test Failed (${userEmail})</strong><br>
Status: ${response.status} ${response.statusText}
</div>
` + "`" + `;
}
} catch (error) {
resultDiv.innerHTML = ` + "`" + `
<div class="test-result" style="background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24;">
<strong>❌ API Test Error (${userEmail})</strong><br>
${error.message}
</div>
` + "`" + `;
}
}
// Create test application
async function createTestApp() {
const resultDiv = document.getElementById('create-app-result');
resultDiv.innerHTML = '<div style="color: blue;">Creating test application...</div>';
const testAppData = {
app_id: ` + "`" + `sso-test-${Date.now()}` + "`" + `,
app_link: "https://test.example.com",
type: ["static"],
callback_url: "https://test.example.com/callback",
token_prefix: "TEST",
token_renewal_duration: 604800000000000,
max_token_duration: 2592000000000000,
owner: {
type: "individual",
name: "SSO Test App",
owner: "admin@example.com"
}
};
try {
const response = await fetch('/api/applications', {
method: 'POST',
headers: {
'X-User-Email': 'admin@example.com',
'Content-Type': 'application/json'
},
body: JSON.stringify(testAppData)
});
if (response.ok) {
const data = await response.json();
resultDiv.innerHTML = ` + "`" + `
<div class="test-result" style="background: #d4edda; border: 1px solid #c3e6cb; color: #155724;">
<strong>✅ Application Created Successfully!</strong><br>
<strong>App ID:</strong> ${data.app_id}<br>
<strong>HMAC Key:</strong> ${data.hmac_key.substring(0, 20)}...<br>
<details style="margin-top: 10px;">
<summary>View Full Response</summary>
<pre style="max-height: 200px; overflow-y: auto;">${JSON.stringify(data, null, 2)}</pre>
</details>
</div>
` + "`" + `;
} else {
const errorText = await response.text();
resultDiv.innerHTML = ` + "`" + `
<div class="test-result" style="background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24;">
<strong>❌ Application Creation Failed</strong><br>
Status: ${response.status} ${response.statusText}<br>
${errorText}
</div>
` + "`" + `;
}
} catch (error) {
resultDiv.innerHTML = ` + "`" + `
<div class="test-result" style="background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24;">
<strong>❌ Application Creation Error</strong><br>
${error.message}
</div>
` + "`" + `;
}
}
// Test permission system
async function testPermissions() {
const resultDiv = document.getElementById('permission-result');
resultDiv.innerHTML = '<div style="color: blue;">Testing permission system...</div>';
// Test different permission levels by trying to access different endpoints
const tests = [
{ user: 'admin@example.com', description: 'Admin (full access)' },
{ user: 'test@example.com', description: 'Test user (limited access)' },
{ user: 'limited@example.com', description: 'Limited user (read-only)' }
];
let results = '<div class="test-result" style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460;"><strong>Permission Test Results:</strong><br><br>';
for (const test of tests) {
try {
const response = await fetch('/api/applications', {
headers: {
'X-User-Email': test.user,
'Accept': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
results += ` + "`" + `<span style="color: #28a745;">✅ ${test.description}: Can access applications (${data.count} found)</span><br>` + "`" + `;
} else {
results += ` + "`" + `<span style="color: #dc3545;">❌ ${test.description}: Access denied (${response.status})</span><br>` + "`" + `;
}
} catch (error) {
results += ` + "`" + `<span style="color: #dc3545;">❌ ${test.description}: Error - ${error.message}</span><br>` + "`" + `;
}
}
results += '</div>';
resultDiv.innerHTML = results;
}
// Check service status on load
window.addEventListener('load', async function() {
const statusDiv = document.getElementById('services-status');
statusDiv.innerHTML = '<br>Checking services...';
const services = [
{ name: 'KMS API', test: () => fetch('/health') },
{ name: 'Keycloak', test: () => fetch('http://localhost:8090') },
{ name: 'SAML IdP', test: () => fetch('http://localhost:8091/simplesaml') }
];
let statusHtml = '<br>';
for (const service of services) {
try {
const response = await service.test();
if (response.ok) {
statusHtml += ` + "`" + `<span style="color: green;">✅ ${service.name}: Online</span><br>` + "`" + `;
} else {
statusHtml += ` + "`" + `<span style="color: orange;">⚠️ ${service.name}: Response ${response.status}</span><br>` + "`" + `;
}
} catch (error) {
statusHtml += ` + "`" + `<span style="color: red;">❌ ${service.name}: Not accessible</span><br>` + "`" + `;
}
}
statusDiv.innerHTML = statusHtml;
});
</script>
</body>
</html>`
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
}

View File

@ -59,7 +59,10 @@ func Security() gin.HandlerFunc {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-XSS-Protection", "1; mode=block")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Content-Security-Policy", "default-src 'self'")
// Set Content Security Policy - more permissive for test pages in development
csp := "default-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'"
c.Header("Content-Security-Policy", csp)
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
c.Next()

View File

@ -13,6 +13,7 @@ import (
"sync"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"golang.org/x/time/rate"
@ -167,7 +168,17 @@ func (s *SecurityMiddleware) SecurityHeadersMiddleware(next http.Handler) http.H
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy", "default-src 'self'")
// Set Content Security Policy - more permissive for test pages in development
csp := "default-src 'self'"
if !s.config.IsProduction() && strings.HasPrefix(r.URL.Path, "/test/") {
// Allow inline styles and scripts for test pages in development
csp = "default-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'"
s.logger.Debug("Using permissive CSP for test page", zap.String("path", r.URL.Path), zap.String("csp", csp))
} else {
s.logger.Debug("Using default CSP", zap.String("path", r.URL.Path), zap.Bool("is_production", s.config.IsProduction()))
}
w.Header().Set("Content-Security-Policy", csp)
// Add HSTS header for HTTPS
if r.TLS != nil {
@ -178,6 +189,35 @@ func (s *SecurityMiddleware) SecurityHeadersMiddleware(next http.Handler) http.H
})
}
// GinSecurityHeaders returns a Gin-compatible middleware function
func (s *SecurityMiddleware) GinSecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
// Add security headers
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("X-XSS-Protection", "1; mode=block")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// Set Content Security Policy - more permissive for test pages in development
csp := "default-src 'self'"
if !s.config.IsProduction() && strings.HasPrefix(c.Request.URL.Path, "/test/") {
// Allow inline styles and scripts for test pages in development
csp = "default-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'"
s.logger.Debug("Using permissive CSP for test page", zap.String("path", c.Request.URL.Path), zap.String("csp", csp))
} else {
s.logger.Debug("Using default CSP", zap.String("path", c.Request.URL.Path), zap.Bool("is_production", s.config.IsProduction()))
}
c.Header("Content-Security-Policy", csp)
// Add HSTS header for HTTPS
if c.Request.TLS != nil {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
c.Next()
}
}
// AuthenticationFailureTracker tracks authentication failures for brute force protection
func (s *SecurityMiddleware) TrackAuthenticationFailure(clientIP, userID string) {
ctx := context.Background()

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Some files were not shown because too many files have changed in this diff Show More