From 1524da2d6a7ef459bbda50c4eab187ca60c96e64 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 2 Apr 2026 11:46:41 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=A2=85=EB=A3=8C=20?= =?UTF-8?q?=EC=8B=9C=20Hydra=20=ED=86=A0=ED=81=B0=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=EB=8F=84=20=ED=95=A8=EA=BB=98=20=EB=AC=B4=ED=9A=A8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 159 +++++++++++++++++- .../handler/auth_handler_sessions_test.go | 111 +++++++++--- .../internal/service/hydra_admin_service.go | 2 + 3 files changed, 247 insertions(+), 25 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index e73f112a..1810dea7 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -1002,6 +1002,18 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string) map[strin return claims } +func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string]any { + if claims == nil { + claims = map[string]any{} + } + sessionID = strings.TrimSpace(sessionID) + if sessionID != "" { + claims["session_id"] = sessionID + claims["sid"] = sessionID + } + return claims +} + func collectEmailList(traits map[string]any, primaryEmail string) []string { emails := make([]string, 0) seen := make(map[string]struct{}) @@ -4755,7 +4767,10 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { slog.Error("failed to load identity for skip consent", "error", err, "subject", consentRequest.Subject) // 신원 정보를 가져오지 못하면 자동 승인을 진행할 수 없으므로 일반 흐름(UI 노출)으로 진행 } else { - sessionClaims := buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope) + sessionClaims := withOidcSessionMetadata( + buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope), + h.resolveCurrentSessionID(c), + ) acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims) if err != nil { slog.Error("failed to auto-accept hydra consent request", "error", err) @@ -4875,7 +4890,11 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { if loginID := pickLoginIDFromTraits(identity.Traits); loginID != "" { c.Locals("login_id", loginID) } - sessionClaims := buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope) + currentSessionID := h.resolveCurrentSessionID(c) + sessionClaims := withOidcSessionMetadata( + buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope), + currentSessionID, + ) acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest, sessionClaims) if err != nil { @@ -4902,12 +4921,17 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { "scopes": consentRequest.RequestedScope, "client_name": consentRequest.Client.ClientName, } + if currentSessionID != "" { + detailsMap["session_id"] = currentSessionID + detailsMap["approved_session_id"] = currentSessionID + } detailsBytes, _ := json.Marshal(detailsMap) _ = h.AuditRepo.Create(&domain.AuditLog{ EventID: GenerateSecureToken(16), Timestamp: time.Now(), UserID: consentRequest.Subject, + SessionID: currentSessionID, EventType: "consent.granted", Status: "success", IPAddress: c.IP(), @@ -5050,12 +5074,12 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe // 1. Try to fetch real profile if token/cookie exists if token != "" || cookie != "" { // Try Redis Cache - if h.RedisService != nil && token != "" { - cacheKey = "cache:profile:token:" + token + if h.RedisService != nil && token == "" && cookie != "" { + cacheKey = "cache:profile:cookie:" + cookie cached, _ := h.RedisService.Get(cacheKey) if cached != "" { if json.Unmarshal([]byte(cached), &profile) == nil { - slog.Debug("Profile loaded from cache", "token", token[:10]+"...", "role", profile.Role) + slog.Debug("Profile loaded from cache", "role", profile.Role) } } } @@ -5146,7 +5170,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe // IMPORTANT: In dev mode, if role was overridden, we should NOT cache it under the token key // or we should include the mock role in the cache key. // For simplicity, let's skip caching if mockRole is present in dev. - if h.RedisService != nil && cacheKey != "" && err == nil && !(isDev && mockRole != "") { + if h.RedisService != nil && token == "" && cacheKey != "" && err == nil && !(isDev && mockRole != "") { if data, err := json.Marshal(profile); err == nil { ttlStr := os.Getenv("PROFILE_CACHE_TTL") ttl := 30 * time.Minute // Default TTL @@ -6208,6 +6232,10 @@ func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domai slog.Warn("Hydra token is not active") return nil, errors.New("token is not active") } + if err := h.validateHydraTokenSession(ctx, intro); err != nil { + slog.Warn("Hydra token session validation failed", "error", err) + return nil, err + } slog.Info("Hydra token introspected", "subject", intro.Subject, "client_id", intro.ClientID) @@ -6820,6 +6848,9 @@ func (h *AuthHandler) DeleteMySession(c *fiber.Ctx) error { } else if err := h.KratosAdmin.DeleteSession(c.Context(), targetSessionID); err != nil { return errorJSON(c, fiber.StatusInternalServerError, "Failed to delete session") } + if err := h.revokeHydraSessionAccess(c.Context(), profile.ID, targetSessionID); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to revoke linked app sessions") + } h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, result) return c.JSON(fiber.Map{"status": "ok"}) @@ -7049,6 +7080,122 @@ func deriveSessionClientInfo(log domain.AuditLog) (string, string) { return clientID, appName } +func extractStringLikeValue(raw any) string { + switch value := raw.(type) { + case string: + return strings.TrimSpace(value) + default: + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" || text == "" { + return "" + } + return text + } +} + +func extractHydraSessionID(ext map[string]interface{}) string { + if len(ext) == 0 { + return "" + } + for _, key := range []string{"session_id", "sid", "sessionId"} { + if value := extractStringLikeValue(ext[key]); value != "" { + return value + } + } + return "" +} + +func (h *AuthHandler) validateHydraTokenSession(ctx context.Context, intro *service.HydraIntrospectionResponse) error { + if h == nil || h.KratosAdmin == nil || intro == nil { + return nil + } + + sessionID := extractHydraSessionID(intro.Ext) + if sessionID == "" { + return nil + } + + session, err := h.KratosAdmin.GetSession(ctx, sessionID) + if err != nil { + return fmt.Errorf("kratos session lookup failed: %w", err) + } + if session == nil { + return errors.New("linked session not found") + } + if !session.Active { + return errors.New("linked session is inactive") + } + if identityID := strings.TrimSpace(session.Identity.ID); identityID != "" && strings.TrimSpace(intro.Subject) != "" && identityID != strings.TrimSpace(intro.Subject) { + return errors.New("linked session subject mismatch") + } + return nil +} + +func (h *AuthHandler) loadSessionClientBindings(ctx context.Context, userID string) map[string][]string { + bindings := make(map[string][]string) + if h == nil || h.AuditRepo == nil || strings.TrimSpace(userID) == "" { + return bindings + } + + logs, err := h.AuditRepo.FindByUserAndEvents(ctx, userID, []string{ + "consent.granted", + "POST /api/v1/auth/oidc/login/accept", + }, 200) + if err != nil { + return bindings + } + + for _, log := range logs { + sessionID := strings.TrimSpace(log.SessionID) + if sessionID == "" { + sessionID = strings.TrimSpace(extractApprovedSessionIDFromAuditDetails(log.Details)) + } + if sessionID == "" { + sessionID = strings.TrimSpace(extractSessionIDFromAuditDetails(log.Details)) + } + if sessionID == "" { + continue + } + + clientID, _ := deriveSessionClientInfo(log) + clientID = strings.TrimSpace(clientID) + if clientID == "" { + continue + } + + existing := bindings[sessionID] + seen := false + for _, candidate := range existing { + if candidate == clientID { + seen = true + break + } + } + if !seen { + bindings[sessionID] = append(existing, clientID) + } + } + + return bindings +} + +func (h *AuthHandler) revokeHydraSessionAccess(ctx context.Context, userID string, sessionID string) error { + if h == nil || h.Hydra == nil { + return nil + } + + clientIDs := h.loadSessionClientBindings(ctx, userID)[strings.TrimSpace(sessionID)] + if len(clientIDs) == 0 { + return h.Hydra.RevokeConsentSessions(ctx, userID, "") + } + for _, clientID := range clientIDs { + if err := h.Hydra.RevokeConsentSessions(ctx, userID, clientID); err != nil { + return err + } + } + return nil +} + func looksLikeInternalUserAgent(userAgent string) bool { normalized := strings.ToLower(strings.TrimSpace(userAgent)) if normalized == "" { diff --git a/backend/internal/handler/auth_handler_sessions_test.go b/backend/internal/handler/auth_handler_sessions_test.go index ab6c2e2e..e0c95d41 100644 --- a/backend/internal/handler/auth_handler_sessions_test.go +++ b/backend/internal/handler/auth_handler_sessions_test.go @@ -3,7 +3,9 @@ package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" + "context" "encoding/json" + "io" "net/http" "net/http/httptest" "testing" @@ -102,23 +104,40 @@ func TestListMySessions_Success(t *testing.T) { } func TestDeleteMySession_Success(t *testing.T) { - setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { - if r.URL.Path == "/sessions/whoami" { - return httpJSONAny(r, http.StatusOK, map[string]any{ - "id": "current-sid", - "authenticated_at": time.Now().UTC().Format(time.RFC3339), - "identity": map[string]any{ - "id": "user-123", - "traits": map[string]any{ - "email": "user@example.com", - "name": "User", - "role": "user", + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + var hydraRevokeCalls int + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.Host { + case "kratos.test": + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": time.Now().UTC().Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, }, - }, - }), nil + }), nil + } + case "hydra.test": + if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { + if r.URL.Query().Get("subject") != "user-123" { + t.Fatalf("unexpected revoke subject: %s", r.URL.Query().Get("subject")) + } + if r.URL.Query().Get("client") != "devfront" { + t.Fatalf("unexpected revoke client: %s", r.URL.Query().Get("client")) + } + hydraRevokeCalls++ + return httpResponse(r, http.StatusNoContent, ""), nil + } } return httpResponse(r, http.StatusNotFound, "not found"), nil - })) + })} + setDefaultHTTPClientForTest(t, client.Transport) mockKratos := new(MockKratosAdminService) mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ @@ -134,7 +153,17 @@ func TestDeleteMySession_Success(t *testing.T) { h := &AuthHandler{ KratosAdmin: mockKratos, AuditRepo: auditRepo, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, } + auditRepo.logs = append(auditRepo.logs, domain.AuditLog{ + UserID: "user-123", + EventType: "POST /api/v1/auth/oidc/login/accept", + SessionID: "target-sid", + Details: `{"client_id":"devfront","client_name":"Devfront"}`, + }) app := fiber.New() app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession) @@ -146,12 +175,56 @@ func TestDeleteMySession_Success(t *testing.T) { resp, err := app.Test(req, -1) assert.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) - if assert.Len(t, auditRepo.logs, 1) { - assert.Equal(t, "session.revoked", auditRepo.logs[0].EventType) - assert.Equal(t, "user-123", auditRepo.logs[0].UserID) - assert.Equal(t, "current-sid", auditRepo.logs[0].SessionID) - assert.Contains(t, auditRepo.logs[0].Details, "target-sid") + if assert.Len(t, auditRepo.logs, 2) { + assert.Equal(t, "session.revoked", auditRepo.logs[len(auditRepo.logs)-1].EventType) + assert.Equal(t, "user-123", auditRepo.logs[len(auditRepo.logs)-1].UserID) + assert.Equal(t, "current-sid", auditRepo.logs[len(auditRepo.logs)-1].SessionID) + assert.Contains(t, auditRepo.logs[len(auditRepo.logs)-1].Details, "target-sid") } + assert.Equal(t, 1, hydraRevokeCalls) mockKratos.AssertExpectations(t) } + +func TestGetHydraProfile_RejectsInactiveLinkedSession(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Host == "hydra.test" && r.URL.Path == "/oauth2/introspect" { + body, _ := io.ReadAll(r.Body) + if string(body) != "token=opaque-token" { + t.Fatalf("unexpected introspect body: %s", string(body)) + } + return httpJSONAny(r, http.StatusOK, map[string]any{ + "active": true, + "sub": "user-123", + "client_id": "devfront", + "ext": map[string]any{ + "session_id": "target-sid", + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })} + + mockKratos := new(MockKratosAdminService) + mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{ + ID: "target-sid", + Active: false, + Identity: &service.KratosIdentity{ + ID: "user-123", + }, + }, nil).Once() + + h := &AuthHandler{ + KratosAdmin: mockKratos, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + } + + profile, err := h.getHydraProfile(context.Background(), "opaque-token") + assert.Nil(t, profile) + assert.Error(t, err) + assert.Contains(t, err.Error(), "inactive") + mockKratos.AssertExpectations(t) +} diff --git a/backend/internal/service/hydra_admin_service.go b/backend/internal/service/hydra_admin_service.go index 6bafc76c..8dda5318 100644 --- a/backend/internal/service/hydra_admin_service.go +++ b/backend/internal/service/hydra_admin_service.go @@ -264,6 +264,8 @@ func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject, } if clientID != "" { params["client"] = clientID + } else { + params["all"] = "true" } endpoint, err := s.buildURLWithParams("/oauth2/auth/sessions/consent", params) if err != nil {