1
0
forked from baron/baron-sso

세션 종료 시 Hydra 토큰 세션도 함께 무효화

This commit is contained in:
2026-04-02 11:46:41 +09:00
parent a2f2b2dd71
commit 1524da2d6a
3 changed files with 247 additions and 25 deletions

View File

@@ -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 == "<nil>" {
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 == "" {

View File

@@ -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)
}

View File

@@ -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 {