forked from baron/baron-sso
세션 IP 표시와 로그아웃 처리 보강
This commit is contained in:
@@ -17,7 +17,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -4043,18 +4042,7 @@ func extractClientIPFromHeaders(c *fiber.Ctx) string {
|
|||||||
if c == nil {
|
if c == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if forwarded := c.Get("X-Forwarded-For"); forwarded != "" {
|
return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP())
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type authTimelineItem struct {
|
type authTimelineItem struct {
|
||||||
@@ -7034,18 +7022,7 @@ func resolveRequestClientIP(c *fiber.Ctx) string {
|
|||||||
if c == nil {
|
if c == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if forwarded := c.Get("X-Forwarded-For"); forwarded != "" {
|
return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP())
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) loadSessionAuditHints(ctx context.Context, userID string) map[string]sessionAuditHint {
|
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 {
|
func isPrivateIPAddress(raw string) bool {
|
||||||
ip := net.ParseIP(strings.TrimSpace(raw))
|
return utils.IsPrivateOrReservedIP(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deriveSessionClientInfo(log domain.AuditLog) (string, string) {
|
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 := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil)
|
||||||
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
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("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)
|
resp, err := app.Test(req, -1)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -217,16 +216,5 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func extractClientIP(c *fiber.Ctx) string {
|
func extractClientIP(c *fiber.Ctx) string {
|
||||||
if forwarded := c.Get("X-Forwarded-For"); forwarded != "" {
|
return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP())
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,30 @@ func TestAuditMiddleware(t *testing.T) {
|
|||||||
mockRepo.AssertExpectations(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) {
|
t.Run("POST request - Sync Failure (Strict Mode)", func(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockRepo := new(MockAuditRepository)
|
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(
|
static Future<Map<String, dynamic>> verifyLoginShortCode(
|
||||||
String shortCode, {
|
String shortCode, {
|
||||||
bool verifyOnly = false,
|
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 '../domain/providers/user_sessions_provider.dart';
|
||||||
import '../../../../core/notifiers/auth_notifier.dart';
|
import '../../../../core/notifiers/auth_notifier.dart';
|
||||||
import '../../../../core/services/auth_proxy_service.dart';
|
import '../../../../core/services/auth_proxy_service.dart';
|
||||||
|
import '../../../../core/services/logout_service.dart';
|
||||||
import '../../../../core/services/auth_token_store.dart';
|
import '../../../../core/services/auth_token_store.dart';
|
||||||
import '../../../../core/services/http_client.dart';
|
import '../../../../core/services/http_client.dart';
|
||||||
import '../../../../core/i18n/locale_utils.dart';
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
@@ -74,8 +75,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _logout() async {
|
Future<void> _logout() async {
|
||||||
AuthTokenStore.clear();
|
await LogoutService().logout();
|
||||||
AuthNotifier.instance.notify();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onRevokeLink(String clientId, String appName) async {
|
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/notifiers/auth_notifier.dart';
|
||||||
import '../../../../core/i18n/locale_utils.dart';
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../../core/services/auth_proxy_service.dart';
|
import '../../../../core/services/auth_proxy_service.dart';
|
||||||
|
import '../../../../core/services/logout_service.dart';
|
||||||
import '../../../../core/services/auth_token_store.dart';
|
import '../../../../core/services/auth_token_store.dart';
|
||||||
import '../../../../core/ui/layout_breakpoints.dart';
|
import '../../../../core/ui/layout_breakpoints.dart';
|
||||||
import '../../../../core/ui/toast_service.dart';
|
import '../../../../core/ui/toast_service.dart';
|
||||||
@@ -164,8 +165,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _logout() async {
|
Future<void> _logout() async {
|
||||||
AuthTokenStore.clear();
|
await LogoutService().logout();
|
||||||
AuthNotifier.instance.notify();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _ensureControllers(UserProfile profile) {
|
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