From 890ddd9b3c2bec131a89ea652c4a0d25133cd50d Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 6 Apr 2026 14:22:00 +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=20=EB=AA=A8=EB=93=A0=20=EC=84=B8=EC=85=98=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 5 +- .../handler/auth_handler_sessions_test.go | 144 ++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 2dcac48c..a4993396 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -7219,6 +7219,9 @@ func (h *AuthHandler) loadSessionClientBindings(ctx context.Context, userID stri logs, err := h.AuditRepo.FindByUserAndEvents(ctx, userID, []string{ "consent.granted", "POST /api/v1/auth/oidc/login/accept", + "POST /api/v1/auth/password/login", + "password_login_success", + "login_success", }, 200) if err != nil { return bindings @@ -7265,7 +7268,7 @@ func (h *AuthHandler) revokeHydraSessionAccess(ctx context.Context, userID strin clientIDs := h.loadSessionClientBindings(ctx, userID)[strings.TrimSpace(sessionID)] if len(clientIDs) == 0 { - return h.Hydra.RevokeConsentSessions(ctx, userID, "") + return nil } for _, clientID := range clientIDs { if err := h.Hydra.RevokeConsentSessions(ctx, userID, clientID); err != nil { diff --git a/backend/internal/handler/auth_handler_sessions_test.go b/backend/internal/handler/auth_handler_sessions_test.go index 928468ee..7dfc0129 100644 --- a/backend/internal/handler/auth_handler_sessions_test.go +++ b/backend/internal/handler/auth_handler_sessions_test.go @@ -430,6 +430,150 @@ func TestDeleteMySession_Success(t *testing.T) { mockKratos.AssertExpectations(t) } +func TestDeleteMySession_DoesNotRevokeAllHydraSessionsWhenClientBindingMissing(t *testing.T) { + 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 + } + case "hydra.test": + if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { + 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{ + {ID: "target-sid", Active: true}, + }, nil).Once() + mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{ + ID: "target-sid", + Active: true, + }, nil).Once() + mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once() + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + } + + app := fiber.New() + app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + req.Header.Set("User-Agent", "session-test-agent") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, 0, hydraRevokeCalls) + 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.Contains(t, auditRepo.logs[0].Details, "target-sid") + } + + mockKratos.AssertExpectations(t) +} + +func TestDeleteMySession_RevokesHydraClientBoundFromPasswordLoginAudit(t *testing.T) { + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + var hydraRevokeCalls int + var revokedClient string + 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 + } + case "hydra.test": + if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { + revokedClient = 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{ + {ID: "target-sid", Active: true}, + }, nil).Once() + mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{ + ID: "target-sid", + Active: true, + }, nil).Once() + mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once() + + auditRepo := &mockAuditRepo{} + 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/password/login", + SessionID: "target-sid", + Details: `{"client_id":"adminfront","client_name":"AdminFront","session_id":"target-sid"}`, + }) + + app := fiber.New() + app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + req.Header.Set("User-Agent", "session-test-agent") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, 1, hydraRevokeCalls) + assert.Equal(t, "adminfront", revokedClient) + + 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" {