1
0
forked from baron/baron-sso

클라이언트 대시보드 통계 실지표 연동 및 백엔드 API 구현

This commit is contained in:
2026-03-03 14:06:27 +09:00
parent 8db37c377a
commit 20c97843c3
6 changed files with 530 additions and 101 deletions

View File

@@ -10,7 +10,9 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
@@ -70,6 +72,12 @@ type devAuditListResponse struct {
NextCursor string `json:"next_cursor,omitempty"`
}
type devStatsResponse struct {
TotalClients int64 `json:"total_clients"`
ActiveSessions int64 `json:"active_sessions"`
AuthFailures int64 `json:"auth_failures_24h"`
}
type clientSummary struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -153,27 +161,75 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
return true, nil
}
// Check with Keto: System:AppManager#member
allowed, err := h.Keto.CheckPermission(c.Context(), profile.ID, "System", "AppManager", "member")
if err != nil {
return false, err
subject := strings.TrimSpace(profile.ID)
if subject == "" && strings.TrimSpace(profile.Email) != "" && h.KratosAdmin != nil {
resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), strings.TrimSpace(profile.Email))
if err == nil && strings.TrimSpace(resolved) != "" {
subject = strings.TrimSpace(resolved)
}
}
slog.Info("Dev private permission evaluated by Keto", "user_id", profile.ID, "allowed", allowed)
if subject == "" {
slog.Warn("Dev private permission denied: missing subject in profile", "email", profile.Email)
return false, nil
}
if h.Keto == nil {
slog.Warn("Dev private permission denied: keto service unavailable")
return false, nil
}
// Check with Keto: System:AppManager#member
allowed, err := h.Keto.CheckPermission(c.Context(), subject, "System", "AppManager", "member")
if err != nil {
// Fail closed for dev private endpoints: deny on permission backend error.
slog.Warn("Dev private permission check failed; denying access", "subject", subject, "error", err)
return false, nil
}
slog.Info("Dev private permission evaluated by Keto", "subject", subject, "allowed", allowed)
return allowed, nil
}
tokenSubject, tokenEmail := extractAuthClaimsFromBearer(c.Get("Authorization"))
authHeader := c.Get("Authorization")
bearerToken := extractBearerToken(authHeader)
tokenSubject, tokenEmail := extractAuthClaimsFromBearer(authHeader)
tokenRole := ""
// Fallback for OIDC access tokens that do not include full claims locally.
if bearerToken != "" && (tokenSubject == "" || tokenEmail == "") {
if info, err := h.fetchOIDCUserInfo(c.Context(), bearerToken); err == nil && info != nil {
if tokenSubject == "" {
tokenSubject = strings.TrimSpace(info.Sub)
}
if tokenEmail == "" {
tokenEmail = strings.TrimSpace(info.Email)
}
tokenRole = strings.TrimSpace(info.Role)
} else if err != nil {
slog.Warn("Dev private permission userinfo fallback failed", "error", err)
}
}
if tokenRole == domain.RoleSuperAdmin {
slog.Info("Dev private permission granted by token role", "role", tokenRole)
return true, nil
}
if isAdminEmail(tokenEmail) {
slog.Info("Dev private permission granted by token email", "email", tokenEmail)
return true, nil
}
if tokenSubject == "" {
if isTrustedLocalDevfrontRequest(c) {
// Local devfront fallback: allow localhost developer flow even if auth context is missing.
slog.Warn("Dev private permission fallback granted for trusted local devfront request", "path", c.Path(), "origin", c.Get("Origin"))
return true, nil
// If subject is missing, resolve it from Kratos by identifier(email) so Keto checks can still run.
if tokenSubject == "" && tokenEmail != "" && h.KratosAdmin != nil {
resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), tokenEmail)
if err == nil && strings.TrimSpace(resolved) != "" {
tokenSubject = strings.TrimSpace(resolved)
}
}
if tokenSubject == "" {
return false, nil
}
if h.Keto == nil {
slog.Warn("Dev private permission denied: keto service unavailable")
return false, nil
}
@@ -195,7 +251,9 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
// Check with Keto: System:AppManager#member
allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "AppManager", "member")
if err != nil {
return false, err
// Fail closed for dev private endpoints: deny on permission backend error.
slog.Warn("Dev private permission check failed; denying access", "subject", tokenSubject, "error", err)
return false, nil
}
slog.Info("Dev private permission evaluated by Keto(subject)", "subject", tokenSubject, "allowed", allowed)
@@ -247,25 +305,71 @@ func isAdminEmail(email string) bool {
return adminEmail != "" && strings.EqualFold(strings.TrimSpace(email), adminEmail)
}
func isTrustedLocalDevfrontRequest(c *fiber.Ctx) bool {
if c == nil {
return false
func extractBearerToken(authHeader string) string {
authHeader = strings.TrimSpace(authHeader)
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
return ""
}
return strings.TrimSpace(authHeader[len("Bearer "):])
}
type oidcUserInfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
TenantID string `json:"tenant_id"`
Role string `json:"role"`
}
func (h *DevHandler) fetchOIDCUserInfo(ctx context.Context, accessToken string) (*oidcUserInfo, error) {
if strings.TrimSpace(accessToken) == "" {
return nil, fmt.Errorf("missing access token")
}
if h.Hydra == nil || strings.TrimSpace(h.Hydra.PublicURL) == "" {
return nil, fmt.Errorf("hydra public url is not configured")
}
origin := strings.ToLower(strings.TrimSpace(c.Get("Origin")))
referer := strings.ToLower(strings.TrimSpace(c.Get("Referer")))
allowedPrefixes := []string{
"http://localhost:5174",
"https://localhost:5174",
endpoint := strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("userinfo failed status=%d body=%s", resp.StatusCode, string(body))
}
for _, prefix := range allowedPrefixes {
if strings.HasPrefix(origin, prefix) || strings.HasPrefix(referer, prefix) {
return true
var payload map[string]any
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
pick := func(keys ...string) string {
for _, key := range keys {
if raw, ok := payload[key]; ok {
if value, ok := raw.(string); ok {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
}
}
return ""
}
return false
return &oidcUserInfo{
Sub: pick("sub"),
Email: pick("email"),
TenantID: pick("tenant_id", "tenantId"),
Role: pick("role"),
}, nil
}
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
@@ -279,6 +383,92 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
offset = 0
}
// [Tenant Isolation] Get current user's tenant ID
userTenantID := ""
isSuperAdmin := false
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
if (!ok || profile == nil) && h.Auth != nil {
enriched, _ := h.Auth.GetEnrichedProfile(c)
if enriched != nil {
profile = enriched
ok = true
c.Locals("user_profile", enriched)
}
}
if ok && profile != nil {
if profile.TenantID != nil {
userTenantID = *profile.TenantID
}
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
} else {
// If profile resolution failed, verify bearer token via OIDC userinfo fallback.
authHeader := c.Get("Authorization")
bearerToken := extractBearerToken(authHeader)
if bearerToken == "" {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
sub, email := extractAuthClaimsFromBearer(authHeader)
if sub == "" {
info, infoErr := h.fetchOIDCUserInfo(c.Context(), bearerToken)
if infoErr != nil {
slog.Warn("ListClients userinfo fallback failed", "error", infoErr)
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
sub = strings.TrimSpace(info.Sub)
if email == "" {
email = strings.TrimSpace(info.Email)
}
if userTenantID == "" {
userTenantID = strings.TrimSpace(info.TenantID)
}
if strings.EqualFold(strings.TrimSpace(info.Role), domain.RoleSuperAdmin) {
isSuperAdmin = true
}
}
if sub == "" && email == "" {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
if h.KratosAdmin != nil && (userTenantID == "" || !isSuperAdmin) {
identityID := strings.TrimSpace(sub)
if identityID == "" && email != "" {
if resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), email); err == nil {
identityID = strings.TrimSpace(resolved)
}
}
if identityID != "" {
if identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID); err == nil && identity != nil {
if userTenantID == "" {
if tid, ok := identity.Traits["tenant_id"].(string); ok {
userTenantID = strings.TrimSpace(tid)
}
}
role := ""
if rawRole, ok := identity.Traits["role"].(string); ok {
role = strings.TrimSpace(rawRole)
}
if role == domain.RoleSuperAdmin {
isSuperAdmin = true
}
profile = &domain.UserProfileResponse{
ID: identityID,
Email: email,
Role: role,
TenantID: nil,
}
if userTenantID != "" {
tid := userTenantID
profile.TenantID = &tid
}
c.Locals("user_profile", profile)
}
}
}
}
isAppManager, err := h.checkAppManagerPermission(c)
if err != nil {
slog.Error("Failed to check app manager permission", "error", err)
@@ -299,10 +489,20 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
items := make([]clientSummary, 0, len(clients))
for _, client := range clients {
summary := h.mapClientSummary(client)
// Filter out 'private' clients if user is not an AppManager
// 1. [Security] Filter out 'private' clients if user is not an AppManager
if summary.Type == "private" && !isAppManager {
continue
}
// 2. [Isolation] If not SuperAdmin, only show clients belonging to the same tenant
if !isSuperAdmin {
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
if clientTenantID != userTenantID {
continue
}
}
items = append(items, summary)
}
@@ -330,6 +530,22 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
summary := h.mapClientSummary(*client)
// [Tenant Isolation] Check if user has access to this client
isSuperAdmin := false
userTenantID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
if profile.TenantID != nil {
userTenantID = *profile.TenantID
}
}
if !isSuperAdmin {
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
if clientTenantID != userTenantID {
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
}
}
// Check permission for private clients
if summary.Type == "private" {
isAppManager, err := h.checkAppManagerPermission(c)
@@ -374,20 +590,39 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
// [Security] Check permission before patching
current, err := h.Hydra.GetClient(c.Context(), clientID)
if err == nil {
summary := h.mapClientSummary(*current)
if summary.Type == "private" {
isAppManager, _ := h.checkAppManagerPermission(c)
if !isAppManager {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
}
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
summary := h.mapClientSummary(*current)
// [Tenant Isolation]
isSuperAdmin := false
userTenantID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
if profile.TenantID != nil {
userTenantID = *profile.TenantID
}
}
if !isSuperAdmin {
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
if clientTenantID != userTenantID {
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
}
}
beforeStatus := ""
if current != nil {
beforeStatus = h.mapClientSummary(*current).Status
if summary.Type == "private" {
isAppManager, _ := h.checkAppManagerPermission(c)
if !isAppManager {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
}
}
beforeStatus := summary.Status
h.setAuditDetailsExtra(c, map[string]any{
"action": "UPDATE_CLIENT_STATUS",
"target_id": clientID,
@@ -402,15 +637,12 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
summary := h.mapClientSummary(*updated)
updatedSummary := h.mapClientSummary(*updated)
return c.JSON(clientDetailResponse{
Client: summary,
Client: updatedSummary,
Endpoints: clientEndpoints{
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
Issuer: h.Hydra.PublicURL,
@@ -472,6 +704,15 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
if metadata == nil {
metadata = map[string]interface{}{}
}
// [Tenant Isolation] Record owner information
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
metadata["user_id"] = profile.ID
if tenantID == "" && profile.TenantID != nil {
tenantID = *profile.TenantID
}
}
if tenantID != "" {
metadata["tenant_id"] = tenantID
}
@@ -563,6 +804,24 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
currentSummary := h.mapClientSummary(*current)
// [Tenant Isolation]
isSuperAdmin := false
userTenantID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
if profile.TenantID != nil {
userTenantID = *profile.TenantID
}
}
if !isSuperAdmin {
clientTenantID, _ := currentSummary.Metadata["tenant_id"].(string)
if clientTenantID != userTenantID {
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
}
}
clientType := ""
if req.Type != nil {
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
@@ -572,7 +831,6 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
}
// [Security] Check permission for private clients (both current and new type)
currentSummary := h.mapClientSummary(*current)
if currentSummary.Type == "private" || clientType == "private" {
isAppManager, err := h.checkAppManagerPermission(c)
if err != nil {
@@ -641,9 +899,6 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
updatedClient, err := h.Hydra.UpdateClient(c.Context(), clientID, updated)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
@@ -667,15 +922,37 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
}
// [Security] Check permission for private clients
current, err := h.Hydra.GetClient(c.Context(), clientID)
if err == nil {
summary := h.mapClientSummary(*current)
if summary.Type == "private" {
isAppManager, _ := h.checkAppManagerPermission(c)
if !isAppManager {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
}
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
summary := h.mapClientSummary(*current)
// [Tenant Isolation]
isSuperAdmin := false
userTenantID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
if profile.TenantID != nil {
userTenantID = *profile.TenantID
}
}
if !isSuperAdmin {
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
if clientTenantID != userTenantID {
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
}
}
// [Security] Check permission for private clients
if summary.Type == "private" {
isAppManager, _ := h.checkAppManagerPermission(c)
if !isAppManager {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
}
}
@@ -686,9 +963,6 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
})
if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
@@ -719,8 +993,17 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
limit = 50
}
// [Isolation] Get admin tenant ID from header or locals
adminTenantID := c.Get("X-Tenant-ID") // Assume middleware sets this or trusted in dev
// [Isolation] Get admin tenant ID from locals or header
adminTenantID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
if profile.Role != domain.RoleSuperAdmin && profile.TenantID != nil {
adminTenantID = *profile.TenantID
}
}
if adminTenantID == "" {
adminTenantID = c.Get("X-Tenant-ID")
}
statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status")))
var consents []domain.ClientConsentWithTenantInfo
@@ -735,12 +1018,6 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
subject = resolved
}
}
// Single user fetch from Hydra (to get latest status) or Local DB
// Issue says: "List All", so we prefer Local DB for consistency in listing
// But for a single user, we could still use Hydra.
// Let's use Local DB to support tenant filtering even for search.
// For simplicity, we just filter the list later if search is used.
}
if adminTenantID != "" {
@@ -852,15 +1129,37 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
}
// [Security] Check permission for private clients
current, err := h.Hydra.GetClient(c.Context(), clientID)
if err == nil {
summary := h.mapClientSummary(*current)
if summary.Type == "private" {
isAppManager, _ := h.checkAppManagerPermission(c)
if !isAppManager {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
}
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
summary := h.mapClientSummary(*current)
// [Tenant Isolation]
isSuperAdmin := false
userTenantID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
if profile.TenantID != nil {
userTenantID = *profile.TenantID
}
}
if !isSuperAdmin {
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
if clientTenantID != userTenantID {
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
}
}
// [Security] Check permission for private clients
if summary.Type == "private" {
isAppManager, _ := h.checkAppManagerPermission(c)
if !isAppManager {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
}
}
@@ -876,22 +1175,14 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, "failed to generate secret")
}
// 2. Get current client to preserve other fields (already fetched above)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
// 3. Update Hydra
// 2. Update Hydra
current.ClientSecret = newSecret
updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
// 4. Update Persistence (DB & Redis)
// 3. Update Persistence (DB & Redis)
if h.SecretRepo != nil {
if err := h.SecretRepo.Upsert(c.Context(), clientID, newSecret); err != nil {
// Log error but don't fail the request as Hydra is already updated
@@ -904,11 +1195,11 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
}
// Return the new secret
summary := h.mapClientSummary(*updated)
summary.ClientSecret = newSecret
updatedSummary := h.mapClientSummary(*updated)
updatedSummary.ClientSecret = newSecret
return c.JSON(clientDetailResponse{
Client: summary,
Client: updatedSummary,
Endpoints: clientEndpoints{
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
Issuer: h.Hydra.PublicURL,
@@ -1002,6 +1293,67 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
})
}
func (h *DevHandler) GetStats(c *fiber.Ctx) error {
h.injectTenantContextFromHeader(c)
// [Security] Check permission
allowed, err := h.checkAppManagerPermission(c)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
}
if !allowed {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
userTenantID := ""
isSuperAdmin := false
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
if profile.TenantID != nil {
userTenantID = *profile.TenantID
}
}
// 1. Total Clients (Tenant Scoped)
// Hydra doesn't support tenant filtering natively, so we list and filter.
// For stats, we might want to fetch a larger batch or use a cached count.
clients, err := h.Hydra.ListClients(c.Context(), 500, 0)
var totalClients int64
if err == nil {
for _, client := range clients {
if isSuperAdmin {
totalClients++
continue
}
if client.Metadata != nil {
if tid, ok := client.Metadata["tenant_id"].(string); ok && tid == userTenantID {
totalClients++
}
}
}
}
// 2. Auth Failures (24h)
var authFailures int64
if h.AuditRepo != nil {
since := time.Now().Add(-24 * time.Hour)
authFailures, _ = h.AuditRepo.CountFailuresSince(c.Context(), since, userTenantID)
}
// 3. Active Sessions (1h)
var activeSessions int64
if h.AuditRepo != nil {
since := time.Now().Add(-1 * time.Hour)
activeSessions, _ = h.AuditRepo.CountActiveSessionsSince(c.Context(), since, userTenantID)
}
return c.JSON(devStatsResponse{
TotalClients: totalClients,
ActiveSessions: activeSessions,
AuthFailures: authFailures,
})
}
func generateRandomSecret(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {