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