1
0
forked from baron/baron-sso

세션 IP 표시와 로그아웃 처리 보강

This commit is contained in:
2026-04-06 13:25:36 +09:00
parent 6a3bb19e7d
commit 2ca26cafb2
11 changed files with 292 additions and 63 deletions

View File

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

View File

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

View File

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

View File

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

View 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()
}

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

View File

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

View 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();
}
}
}

View File

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

View File

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

View 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']);
});
}