1
0
forked from baron/baron-sso

Resolve merge conflicts with main

This commit is contained in:
2026-01-29 16:45:40 +09:00
69 changed files with 6049 additions and 1379 deletions

View File

@@ -0,0 +1,132 @@
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"sync"
"time"
)
type HTTPProbe struct {
name string
url string
interval time.Duration
timeout time.Duration
client *http.Client
mu sync.RWMutex
status string
lastError string
lastChecked time.Time
lastSuccess time.Time
}
type ProbeSnapshot struct {
Status string
Error string
LastChecked time.Time
LastSuccess time.Time
}
func NewHTTPProbe(name, url string, interval, timeout time.Duration) *HTTPProbe {
if interval <= 0 {
interval = 10 * time.Second
}
if timeout <= 0 {
timeout = 2 * time.Second
}
return &HTTPProbe{
name: name,
url: url,
interval: interval,
timeout: timeout,
client: &http.Client{
Timeout: timeout,
},
}
}
// Start는 프로브를 백그라운드에서 주기적으로 실행합니다.
func (p *HTTPProbe) Start() {
go func() {
p.checkOnce()
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for range ticker.C {
p.checkOnce()
}
}()
}
func (p *HTTPProbe) Snapshot() ProbeSnapshot {
p.mu.RLock()
defer p.mu.RUnlock()
return ProbeSnapshot{
Status: p.status,
Error: p.lastError,
LastChecked: p.lastChecked,
LastSuccess: p.lastSuccess,
}
}
func (p *HTTPProbe) StatusText() string {
s := p.Snapshot()
if s.Status == "ok" {
return "ok"
}
if s.Status == "" {
return "unknown"
}
if s.Error == "" {
return "error"
}
return "error: " + s.Error
}
func (p *HTTPProbe) checkOnce() {
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
if err != nil {
p.update("error", fmt.Sprintf("request build failed: %v", err), false)
return
}
resp, err := p.client.Do(req)
if err != nil {
p.update("error", err.Error(), false)
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
p.update("error", fmt.Sprintf("status=%d", resp.StatusCode), false)
return
}
p.update("ok", "", true)
}
func (p *HTTPProbe) update(status, errMsg string, success bool) {
p.mu.Lock()
prevStatus := p.status
p.status = status
p.lastError = errMsg
p.lastChecked = time.Now()
if success {
p.lastSuccess = p.lastChecked
}
p.mu.Unlock()
if prevStatus == status {
return
}
if status == "ok" {
slog.Info("Service probe recovered", "name", p.name, "url", p.url)
return
}
slog.Error("Service probe failed", "name", p.name, "url", p.url, "error", errMsg)
}

View File

@@ -158,6 +158,28 @@ func main() {
slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err)
}
// Oathkeeper 상태를 주기적으로 확인해 다운을 감지합니다.
var oathkeeperProbe *HTTPProbe
if strings.ToLower(getEnv("OATHKEEPER_HEALTH_ENABLED", "true")) != "false" {
intervalSec, err := strconv.Atoi(getEnv("OATHKEEPER_HEALTH_INTERVAL_SECONDS", "10"))
if err != nil || intervalSec <= 0 {
intervalSec = 10
}
timeoutSec, err := strconv.Atoi(getEnv("OATHKEEPER_HEALTH_TIMEOUT_SECONDS", "2"))
if err != nil || timeoutSec <= 0 {
timeoutSec = 2
}
oathkeeperProbe = NewHTTPProbe(
"oathkeeper",
getEnv("OATHKEEPER_HEALTH_URL", "http://oathkeeper:4456/health/ready"),
time.Duration(intervalSec)*time.Second,
time.Duration(timeoutSec)*time.Second,
)
oathkeeperProbe.Start()
} else {
slog.Info("Oathkeeper probe disabled")
}
// 2. Initialize Handlers
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider)
@@ -249,10 +271,14 @@ func main() {
app.Use(recover.New(recover.Config{
EnableStackTrace: true,
}))
allowedOrigins := getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5000")
allowCredentials := allowedOrigins != "*"
app.Use(cors.New(cors.Config{
AllowOrigins: "*", // Adjust in production
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS",
AllowOrigins: allowedOrigins,
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS",
AllowCredentials: allowCredentials,
}))
// Ensure COOKIE_SECRET is exactly 32 bytes for AES-256
@@ -271,24 +297,30 @@ func main() {
Key: cookieSecret,
}))
app.Get("/docs", func(c *fiber.Ctx) error {
return c.SendFile("./docs/swagger-ui/index.html")
})
app.Get("/docs/", func(c *fiber.Ctx) error {
return c.SendFile("./docs/swagger-ui/index.html")
})
app.Static("/docs", "./docs/swagger-ui")
app.Get("/redoc", func(c *fiber.Ctx) error {
return c.SendFile("./docs/redoc/index.html")
})
app.Get("/redoc/", func(c *fiber.Ctx) error {
return c.SendFile("./docs/redoc/index.html")
})
app.Static("/redoc", "./docs/redoc")
app.Get("/openapi.yaml", func(c *fiber.Ctx) error {
c.Type("yaml")
return c.SendFile("./docs/openapi.yaml")
})
// [Security] Disable Swagger/ReDoc in Production
if appEnv != "production" {
app.Get("/docs", func(c *fiber.Ctx) error {
return c.SendFile("./docs/swagger-ui/index.html")
})
app.Get("/docs/", func(c *fiber.Ctx) error {
return c.SendFile("./docs/swagger-ui/index.html")
})
app.Static("/docs", "./docs/swagger-ui")
app.Get("/redoc", func(c *fiber.Ctx) error {
return c.SendFile("./docs/redoc/index.html")
})
app.Get("/redoc/", func(c *fiber.Ctx) error {
return c.SendFile("./docs/redoc/index.html")
})
app.Static("/redoc", "./docs/redoc")
app.Get("/openapi.yaml", func(c *fiber.Ctx) error {
c.Type("yaml")
return c.SendFile("./docs/openapi.yaml")
})
slog.Info("📚 API Docs enabled", "swagger", "/docs", "redoc", "/redoc")
} else {
slog.Info("🔒 API Docs disabled in production")
}
// Routes
app.Get("/", func(c *fiber.Ctx) error {
@@ -325,6 +357,32 @@ func main() {
status = "degraded"
}
// Check Oathkeeper
if oathkeeperProbe != nil {
snapshot := oathkeeperProbe.Snapshot()
switch snapshot.Status {
case "ok":
checks["oathkeeper"] = "ok"
case "":
checks["oathkeeper"] = "unknown"
if status != "error" {
status = "degraded"
}
default:
if snapshot.Error == "" {
checks["oathkeeper"] = "error"
} else {
checks["oathkeeper"] = "error: " + snapshot.Error
}
status = "error"
}
} else {
checks["oathkeeper"] = "disabled"
if status != "error" {
status = "degraded"
}
}
if status == "error" {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"status": status,
@@ -340,12 +398,19 @@ func main() {
// API Group
api := app.Group("/api/v1")
api.Use(middleware.RequireAudit(middleware.AuditRequiredConfig{
workerCount, _ := strconv.Atoi(getEnv("AUDIT_WORKER_COUNT", "5"))
queueSize, _ := strconv.Atoi(getEnv("AUDIT_QUEUE_SIZE", "2000"))
api.Use(middleware.AuditMiddleware(middleware.AuditConfig{
Repo: auditRepo,
ExcludePaths: map[string]struct{}{
"/api/v1/audit": {},
"/api/v1/client-log": {},
},
BodyDump: true,
WorkerCount: workerCount,
QueueSize: queueSize,
}))
api.Post("/audit", auditHandler.CreateLog)
api.Get("/audit", auditHandler.ListLogs)
@@ -355,6 +420,8 @@ func main() {
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
auth.Post("/login/code/verify", authHandler.VerifyLoginCode)
auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode)
auth.Post("/password/login", authHandler.PasswordLogin)
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
@@ -388,6 +455,7 @@ func main() {
admin := api.Group("/admin")
admin.Use(middleware.ApiKeyAuth(middleware.ApiKeyAuthConfig{DB: db})) // API Key 인증 추가
admin.Get("/check", adminHandler.CheckAuth)
admin.Get("/stats", adminHandler.GetSystemStats)
admin.Get("/tenants", tenantHandler.ListTenants)
admin.Post("/tenants", tenantHandler.CreateTenant)
admin.Get("/tenants/:id", tenantHandler.GetTenant)
@@ -423,6 +491,9 @@ func main() {
// Webhook for Descope Generic Email Gateway (Fake Email Strategy)
auth.Post("/webhooks/descope-email", authHandler.HandleDescopeEmailRelay)
// Webhook for Kratos courier (HTTP delivery)
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)
// Client Logging Route (Standardized & Flattened)
api.Post("/client-log", func(c *fiber.Ctx) error {
type LogReq struct {