forked from baron/baron-sso
세션 IP 표시와 로그아웃 처리 보강
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
87
backend/internal/utils/client_ip.go
Normal file
87
backend/internal/utils/client_ip.go
Normal file
@@ -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()
|
||||
}
|
||||
24
backend/internal/utils/client_ip_test.go
Normal file
24
backend/internal/utils/client_ip_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -264,6 +264,41 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> 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 = <String, String>{'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<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? const [];
|
||||
for (final item in items.whereType<Map<String, dynamic>>()) {
|
||||
if (item['is_current'] == true) {
|
||||
final sessionId = item['session_id']?.toString().trim() ?? '';
|
||||
if (sessionId.isNotEmpty) {
|
||||
return sessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> verifyLoginShortCode(
|
||||
String shortCode, {
|
||||
bool verifyOnly = false,
|
||||
|
||||
39
userfront/lib/core/services/logout_service.dart
Normal file
39
userfront/lib/core/services/logout_service.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import '../notifiers/auth_notifier.dart';
|
||||
import 'auth_proxy_service.dart';
|
||||
import 'auth_token_store.dart';
|
||||
|
||||
typedef CurrentSessionLoader = Future<String?> Function();
|
||||
typedef SessionRevoker = Future<void> 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<void> logout() async {
|
||||
try {
|
||||
final currentSessionId = await _loadCurrentSessionId();
|
||||
if (currentSessionId != null && currentSessionId.isNotEmpty) {
|
||||
await _revokeSession(currentSessionId);
|
||||
}
|
||||
} catch (_) {
|
||||
// 서버 세션 종료는 best-effort로 처리하고, 로컬 로그아웃은 계속 진행합니다.
|
||||
} finally {
|
||||
_clearAuth();
|
||||
_notifyAuthChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<DashboardScreen> {
|
||||
}
|
||||
|
||||
Future<void> _logout() async {
|
||||
AuthTokenStore.clear();
|
||||
AuthNotifier.instance.notify();
|
||||
await LogoutService().logout();
|
||||
}
|
||||
|
||||
Future<void> _onRevokeLink(String clientId, String appName) async {
|
||||
|
||||
@@ -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<ProfilePage> {
|
||||
}
|
||||
|
||||
Future<void> _logout() async {
|
||||
AuthTokenStore.clear();
|
||||
AuthNotifier.instance.notify();
|
||||
await LogoutService().logout();
|
||||
}
|
||||
|
||||
void _ensureControllers(UserProfile profile) {
|
||||
|
||||
74
userfront/test/logout_service_test.dart
Normal file
74
userfront/test/logout_service_test.dart
Normal file
@@ -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 = <String>[];
|
||||
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 = <String>[];
|
||||
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 = <String>[];
|
||||
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']);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user