From 2ca26cafb29616fa55a857e1cfb96028b9341076 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 6 Apr 2026 13:25:36 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=B8=EC=85=98=20IP=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EC=99=80=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 48 +--------- .../handler/auth_handler_sessions_test.go | 2 +- .../internal/middleware/audit_middleware.go | 14 +-- .../middleware/audit_middleware_test.go | 24 +++++ backend/internal/utils/client_ip.go | 87 +++++++++++++++++++ backend/internal/utils/client_ip_test.go | 24 +++++ .../lib/core/services/auth_proxy_service.dart | 35 ++++++++ .../lib/core/services/logout_service.dart | 39 +++++++++ .../presentation/dashboard_screen.dart | 4 +- .../presentation/pages/profile_page.dart | 4 +- userfront/test/logout_service_test.dart | 74 ++++++++++++++++ 11 files changed, 292 insertions(+), 63 deletions(-) create mode 100644 backend/internal/utils/client_ip.go create mode 100644 backend/internal/utils/client_ip_test.go create mode 100644 userfront/lib/core/services/logout_service.dart create mode 100644 userfront/test/logout_service_test.dart diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index c3b84696..2dcac48c 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -17,7 +17,6 @@ import ( "io" "log/slog" "math/rand" - "net" "net/http" "net/url" "os" @@ -4043,18 +4042,7 @@ func extractClientIPFromHeaders(c *fiber.Ctx) string { if c == nil { return "" } - if forwarded := c.Get("X-Forwarded-For"); forwarded != "" { - parts := strings.Split(forwarded, ",") - if len(parts) > 0 { - if ip := strings.TrimSpace(parts[0]); ip != "" { - return ip - } - } - } - if realIP := strings.TrimSpace(c.Get("X-Real-IP")); realIP != "" { - return realIP - } - return c.IP() + return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP()) } type authTimelineItem struct { @@ -7034,18 +7022,7 @@ func resolveRequestClientIP(c *fiber.Ctx) string { if c == nil { return "" } - if forwarded := c.Get("X-Forwarded-For"); forwarded != "" { - parts := strings.Split(forwarded, ",") - if len(parts) > 0 { - if ip := strings.TrimSpace(parts[0]); ip != "" { - return ip - } - } - } - if realIP := strings.TrimSpace(c.Get("X-Real-IP")); realIP != "" { - return realIP - } - return c.IP() + return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP()) } func (h *AuthHandler) loadSessionAuditHints(ctx context.Context, userID string) map[string]sessionAuditHint { @@ -7146,26 +7123,7 @@ func shouldReplaceSessionIP(existing string, candidate string) bool { } func isPrivateIPAddress(raw string) bool { - ip := net.ParseIP(strings.TrimSpace(raw)) - if ip == nil { - return false - } - if ip.IsLoopback() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() { - return true - } - for _, cidr := range []string{ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "100.64.0.0/10", - "fc00::/7", - } { - _, network, err := net.ParseCIDR(cidr) - if err == nil && network.Contains(ip) { - return true - } - } - return false + return utils.IsPrivateOrReservedIP(raw) } func deriveSessionClientInfo(log domain.AuditLog) (string, string) { diff --git a/backend/internal/handler/auth_handler_sessions_test.go b/backend/internal/handler/auth_handler_sessions_test.go index 39e6f599..928468ee 100644 --- a/backend/internal/handler/auth_handler_sessions_test.go +++ b/backend/internal/handler/auth_handler_sessions_test.go @@ -317,7 +317,7 @@ func TestListMySessions_CurrentSessionFallsBackToRequestMetadata(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil) req.Header.Set("Cookie", "ory_kratos_session=valid") req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/146.0.0.0 Safari/537.36") - req.Header.Set("X-Forwarded-For", "203.0.113.25") + req.Header.Set("X-Forwarded-For", "100.100.100.1, 203.0.113.25") resp, err := app.Test(req, -1) assert.NoError(t, err) diff --git a/backend/internal/middleware/audit_middleware.go b/backend/internal/middleware/audit_middleware.go index 59746e1d..a0c5c6fe 100644 --- a/backend/internal/middleware/audit_middleware.go +++ b/backend/internal/middleware/audit_middleware.go @@ -7,7 +7,6 @@ import ( "fmt" "log/slog" "reflect" - "strings" "sync" "time" @@ -217,16 +216,5 @@ func AuditMiddleware(config AuditConfig) fiber.Handler { } func extractClientIP(c *fiber.Ctx) string { - if forwarded := c.Get("X-Forwarded-For"); forwarded != "" { - parts := strings.Split(forwarded, ",") - if len(parts) > 0 { - if ip := strings.TrimSpace(parts[0]); ip != "" { - return ip - } - } - } - if realIP := strings.TrimSpace(c.Get("X-Real-IP")); realIP != "" { - return realIP - } - return c.IP() + return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP()) } diff --git a/backend/internal/middleware/audit_middleware_test.go b/backend/internal/middleware/audit_middleware_test.go index 9998429b..d553ad40 100644 --- a/backend/internal/middleware/audit_middleware_test.go +++ b/backend/internal/middleware/audit_middleware_test.go @@ -117,6 +117,30 @@ func TestAuditMiddleware(t *testing.T) { mockRepo.AssertExpectations(t) }) + t.Run("POST request - Prefer public forwarded IP", func(t *testing.T) { + app := fiber.New() + mockRepo := new(MockAuditRepository) + + app.Use(AuditMiddleware(AuditConfig{ + Repo: mockRepo, + })) + + app.Post("/test", func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + mockRepo.On("Create", mock.MatchedBy(func(log *domain.AuditLog) bool { + return log.IPAddress == "203.0.113.25" + })).Return(nil) + + req := httptest.NewRequest("POST", "/test", nil) + req.Header.Set("X-Forwarded-For", "100.100.100.1, 203.0.113.25") + + resp, _ := app.Test(req) + assert.Equal(t, fiber.StatusOK, resp.StatusCode) + mockRepo.AssertExpectations(t) + }) + t.Run("POST request - Sync Failure (Strict Mode)", func(t *testing.T) { app := fiber.New() mockRepo := new(MockAuditRepository) diff --git a/backend/internal/utils/client_ip.go b/backend/internal/utils/client_ip.go new file mode 100644 index 00000000..897cc1e6 --- /dev/null +++ b/backend/internal/utils/client_ip.go @@ -0,0 +1,87 @@ +package utils + +import ( + "net" + "strings" +) + +// ResolveClientIP selects the best client IP from proxy headers and the remote address. +// It prefers a public IP from X-Forwarded-For, then X-Real-IP, and finally the remote IP. +func ResolveClientIP(forwardedFor, realIP, remoteIP string) string { + forwardedCandidates := splitClientIPs(forwardedFor) + if ip := firstPublicIP(forwardedCandidates); ip != "" { + return ip + } + if ip := normalizeIP(realIP); ip != "" && !IsPrivateOrReservedIP(ip) { + return ip + } + if ip := normalizeIP(remoteIP); ip != "" && !IsPrivateOrReservedIP(ip) { + return ip + } + if len(forwardedCandidates) > 0 { + return forwardedCandidates[0] + } + if ip := normalizeIP(realIP); ip != "" { + return ip + } + return normalizeIP(remoteIP) +} + +// IsPrivateOrReservedIP reports whether the IP is private or from a non-public network range. +func IsPrivateOrReservedIP(raw string) bool { + ip := net.ParseIP(strings.TrimSpace(raw)) + if ip == nil { + return false + } + if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() { + return true + } + for _, cidr := range []string{ + "100.64.0.0/10", + "fc00::/7", + } { + _, network, err := net.ParseCIDR(cidr) + if err == nil && network.Contains(ip) { + return true + } + } + return false +} + +func splitClientIPs(forwardedFor string) []string { + if strings.TrimSpace(forwardedFor) == "" { + return nil + } + parts := strings.Split(forwardedFor, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if ip := normalizeIP(part); ip != "" { + result = append(result, ip) + } + } + return result +} + +func firstPublicIP(candidates []string) string { + for _, candidate := range candidates { + if !IsPrivateOrReservedIP(candidate) { + return candidate + } + } + return "" +} + +func normalizeIP(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if host, _, err := net.SplitHostPort(raw); err == nil { + raw = host + } + ip := net.ParseIP(raw) + if ip == nil { + return "" + } + return ip.String() +} diff --git a/backend/internal/utils/client_ip_test.go b/backend/internal/utils/client_ip_test.go new file mode 100644 index 00000000..8128fc87 --- /dev/null +++ b/backend/internal/utils/client_ip_test.go @@ -0,0 +1,24 @@ +package utils + +import "testing" + +func TestResolveClientIP_PrefersPublicForwardedIP(t *testing.T) { + got := ResolveClientIP("100.100.100.1, 203.0.113.25, 10.0.0.2", "", "172.18.0.5") + if got != "203.0.113.25" { + t.Fatalf("expected public forwarded IP, got %q", got) + } +} + +func TestResolveClientIP_FallsBackToFirstForwardedWhenAllPrivate(t *testing.T) { + got := ResolveClientIP("100.100.100.1, 10.0.0.2", "192.168.0.10", "172.18.0.5") + if got != "100.100.100.1" { + t.Fatalf("expected first forwarded private IP, got %q", got) + } +} + +func TestResolveClientIP_PrefersPublicRealIPOverPrivateForwarded(t *testing.T) { + got := ResolveClientIP("100.100.100.1, 10.0.0.2", "198.51.100.7", "172.18.0.5") + if got != "198.51.100.7" { + t.Fatalf("expected public real IP, got %q", got) + } +} diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 57eec236..2e3ae269 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -264,6 +264,41 @@ class AuthProxyService { } } + static Future fetchCurrentSessionId() async { + final url = Uri.parse('$_baseUrl/api/v1/user/sessions'); + final useCookie = AuthTokenStore.usesCookie(); + final token = AuthTokenStore.getToken(); + final client = createHttpClient(withCredentials: useCookie); + try { + final headers = {'Content-Type': 'application/json'}; + if (!useCookie && token != null && token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await client.get(url, headers: headers); + if (response.statusCode != 200) { + throw _error( + 'err.userfront.dashboard.sessions.load', + '활성 세션을 불러오지 못했습니다: {{error}}', + detail: response.body, + ); + } + + final body = jsonDecode(response.body) as Map; + final items = (body['items'] as List?) ?? const []; + for (final item in items.whereType>()) { + if (item['is_current'] == true) { + final sessionId = item['session_id']?.toString().trim() ?? ''; + if (sessionId.isNotEmpty) { + return sessionId; + } + } + } + return null; + } finally { + client.close(); + } + } + static Future> verifyLoginShortCode( String shortCode, { bool verifyOnly = false, diff --git a/userfront/lib/core/services/logout_service.dart b/userfront/lib/core/services/logout_service.dart new file mode 100644 index 00000000..38de877d --- /dev/null +++ b/userfront/lib/core/services/logout_service.dart @@ -0,0 +1,39 @@ +import '../notifiers/auth_notifier.dart'; +import 'auth_proxy_service.dart'; +import 'auth_token_store.dart'; + +typedef CurrentSessionLoader = Future Function(); +typedef SessionRevoker = Future Function(String sessionId); +typedef LogoutCallback = void Function(); + +class LogoutService { + LogoutService({ + CurrentSessionLoader? loadCurrentSessionId, + SessionRevoker? revokeSession, + LogoutCallback? clearAuth, + LogoutCallback? notifyAuthChanged, + }) : _loadCurrentSessionId = + loadCurrentSessionId ?? AuthProxyService.fetchCurrentSessionId, + _revokeSession = revokeSession ?? AuthProxyService.revokeSession, + _clearAuth = clearAuth ?? AuthTokenStore.clear, + _notifyAuthChanged = notifyAuthChanged ?? AuthNotifier.instance.notify; + + final CurrentSessionLoader _loadCurrentSessionId; + final SessionRevoker _revokeSession; + final LogoutCallback _clearAuth; + final LogoutCallback _notifyAuthChanged; + + Future logout() async { + try { + final currentSessionId = await _loadCurrentSessionId(); + if (currentSessionId != null && currentSessionId.isNotEmpty) { + await _revokeSession(currentSessionId); + } + } catch (_) { + // 서버 세션 종료는 best-effort로 처리하고, 로컬 로그아웃은 계속 진행합니다. + } finally { + _clearAuth(); + _notifyAuthChanged(); + } + } +} diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 40490b52..cbbbc9fa 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -12,6 +12,7 @@ import '../domain/providers/linked_rps_provider.dart'; import '../domain/providers/user_sessions_provider.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_proxy_service.dart'; +import '../../../../core/services/logout_service.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/http_client.dart'; import '../../../../core/i18n/locale_utils.dart'; @@ -74,8 +75,7 @@ class _DashboardScreenState extends ConsumerState { } Future _logout() async { - AuthTokenStore.clear(); - AuthNotifier.instance.notify(); + await LogoutService().logout(); } Future _onRevokeLink(String clientId, String appName) async { diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index 80b80ae3..bf8f6d50 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -6,6 +6,7 @@ import 'package:userfront/i18n.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/services/auth_proxy_service.dart'; +import '../../../../core/services/logout_service.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/ui/toast_service.dart'; @@ -164,8 +165,7 @@ class _ProfilePageState extends ConsumerState { } Future _logout() async { - AuthTokenStore.clear(); - AuthNotifier.instance.notify(); + await LogoutService().logout(); } void _ensureControllers(UserProfile profile) { diff --git a/userfront/test/logout_service_test.dart b/userfront/test/logout_service_test.dart new file mode 100644 index 00000000..b9cdc5ec --- /dev/null +++ b/userfront/test/logout_service_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/core/services/logout_service.dart'; + +void main() { + test('현재 세션이 있으면 서버 세션 종료 후 로컬 로그아웃을 진행한다', () async { + final events = []; + final service = LogoutService( + loadCurrentSessionId: () async { + events.add('load'); + return 'current-sid'; + }, + revokeSession: (sessionId) async { + events.add('revoke:$sessionId'); + }, + clearAuth: () { + events.add('clear'); + }, + notifyAuthChanged: () { + events.add('notify'); + }, + ); + + await service.logout(); + + expect(events, ['load', 'revoke:current-sid', 'clear', 'notify']); + }); + + test('현재 세션이 없으면 서버 세션 종료 없이 로컬 로그아웃만 진행한다', () async { + final events = []; + final service = LogoutService( + loadCurrentSessionId: () async { + events.add('load'); + return null; + }, + revokeSession: (sessionId) async { + events.add('revoke:$sessionId'); + }, + clearAuth: () { + events.add('clear'); + }, + notifyAuthChanged: () { + events.add('notify'); + }, + ); + + await service.logout(); + + expect(events, ['load', 'clear', 'notify']); + }); + + test('서버 세션 종료가 실패해도 로컬 로그아웃은 계속 진행한다', () async { + final events = []; + final service = LogoutService( + loadCurrentSessionId: () async { + events.add('load'); + return 'current-sid'; + }, + revokeSession: (sessionId) async { + events.add('revoke:$sessionId'); + throw Exception('revoke failed'); + }, + clearAuth: () { + events.add('clear'); + }, + notifyAuthChanged: () { + events.add('notify'); + }, + ); + + await service.logout(); + + expect(events, ['load', 'revoke:current-sid', 'clear', 'notify']); + }); +}