forked from baron/baron-sso
세션 종료 시 Hydra 토큰 세션도 함께 무효화
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user