From 4b7264217ddcbf4e1bb145d1786b5578ff76aec2 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 9 Feb 2026 09:56:10 +0900 Subject: [PATCH 1/4] Apply stashed changes after rebase --- backend/internal/handler/auth_handler.go | 66 ++- .../repository/client_consent_repository.go | 10 + .../internal/service/federation_service.go | 1 - .../presentation/dashboard_screen.dart | 409 +++++++++++++++--- 4 files changed, 433 insertions(+), 53 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 24f5af02..98d86b10 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3304,7 +3304,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { Name: name, Logo: extractHydraClientLogo(client.Metadata), URL: clientURL, - Status: hydraClientStatus(client.Metadata), + Status: "active", // Hydra 세션이 있으면 활성 Scopes: scopes, }, lastAuth: lastAuth, @@ -3327,6 +3327,65 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { } } + // [New] DB에서 과거 동의 내역 가져와 병합 (비활성 RP 포함) + if h.ConsentRepo != nil { + for _, subject := range subjects { + dbConsents, err := h.ConsentRepo.ListBySubject(c.Context(), subject) + if err != nil { + slog.Error("failed to list db consents for subject", "subject", subject, "error", err) + continue + } + + for _, dc := range dbConsents { + if _, exists := records[dc.ClientID]; exists { + // 이미 Hydra 세션으로 존재하면 skip (active 우선) + continue + } + + // Hydra에서 클라이언트 정보 조회 (메타데이터용) + client, err := h.Hydra.GetClient(c.Context(), dc.ClientID) + if err != nil { + slog.Error("failed to get client info from hydra for inactive rp", "client_id", dc.ClientID, "error", err) + // Hydra에 정보가 없더라도 기본 정보로 추가 + records[dc.ClientID] = &linkedRpRecord{ + linkedRpSummary: linkedRpSummary{ + ID: dc.ClientID, + Name: dc.ClientID, + Status: "inactive", + Scopes: dc.GrantedScopes, + }, + lastAuth: dc.UpdatedAt, + } + continue + } + + name := strings.TrimSpace(client.ClientName) + if name == "" { + name = client.ClientID + } + + clientURL := strings.TrimSpace(client.ClientURI) + if clientURL == "" && len(client.RedirectURIs) > 0 { + if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil { + clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) + } + } + + records[dc.ClientID] = &linkedRpRecord{ + linkedRpSummary: linkedRpSummary{ + ID: dc.ClientID, + Name: name, + Logo: extractHydraClientLogo(client.Metadata), + URL: clientURL, + Status: "inactive", + Scopes: dc.GrantedScopes, + }, + lastAuth: dc.UpdatedAt, + } + } + } + } + ordered := make([]*linkedRpRecord, 0, len(records)) for _, record := range records { ordered = append(ordered, record) @@ -3336,7 +3395,10 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { }) items := make([]linkedRpSummary, 0, len(ordered)) - for _, record := range ordered { + for i, record := range ordered { + if i >= 100 { + break + } if !record.lastAuth.IsZero() { record.LastAuthenticatedAt = record.lastAuth.Format(time.RFC3339) } diff --git a/backend/internal/repository/client_consent_repository.go b/backend/internal/repository/client_consent_repository.go index 64a85e2a..ee85daba 100644 --- a/backend/internal/repository/client_consent_repository.go +++ b/backend/internal/repository/client_consent_repository.go @@ -12,6 +12,7 @@ type ClientConsentRepository interface { Delete(ctx context.Context, subject, clientID string) error List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) + ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error) } type clientConsentRepo struct { @@ -90,3 +91,12 @@ func (r *clientConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID return consents, total, err } + +func (r *clientConsentRepo) ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error) { + var consents []domain.ClientConsent + err := r.db.WithContext(ctx). + Where("subject = ?", subject). + Order("updated_at DESC"). + Find(&consents).Error + return consents, err +} diff --git a/backend/internal/service/federation_service.go b/backend/internal/service/federation_service.go index a32b5c09..8c1ca020 100644 --- a/backend/internal/service/federation_service.go +++ b/backend/internal/service/federation_service.go @@ -8,7 +8,6 @@ import ( "fmt" "time" - "github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" ) diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 855b2aa6..6d41a21b 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../domain/providers/linked_rps_provider.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/auth_proxy_service.dart'; @@ -12,6 +13,97 @@ import '../../profile/domain/notifiers/profile_notifier.dart'; import '../domain/dashboard_providers.dart'; import '../domain/models.dart'; +class AuditLogEntry { + final String eventId; + final DateTime timestamp; + final String userId; + final String eventType; + final String status; + final String authMethod; + final String ipAddress; + final String userAgent; + final String sessionId; + final String details; + final String source; + final String clientId; + final String appName; + final String parentSessionId; + + AuditLogEntry({ + required this.eventId, + required this.timestamp, + required this.userId, + required this.eventType, + required this.status, + required this.authMethod, + required this.ipAddress, + required this.userAgent, + required this.sessionId, + required this.details, + required this.source, + required this.clientId, + required this.appName, + required this.parentSessionId, + }); + + factory AuditLogEntry.fromJson(Map json) { + final timestampRaw = json['timestamp']?.toString() ?? ''; + DateTime parsedTimestamp; + try { + parsedTimestamp = DateTime.parse(timestampRaw).toLocal(); + } catch (_) { + parsedTimestamp = DateTime.now(); + } + + return AuditLogEntry( + eventId: json['event_id'] ?? '', + timestamp: parsedTimestamp, + userId: json['user_id'] ?? '', + eventType: json['event_type'] ?? '', + status: json['status'] ?? '', + authMethod: json['auth_method'] ?? '', + ipAddress: json['ip_address'] ?? '', + userAgent: json['user_agent'] ?? '', + sessionId: json['session_id'] ?? '', + details: json['details'] ?? '', + source: json['source'] ?? '', + clientId: json['client_id'] ?? '', + appName: json['app_name'] ?? '', + parentSessionId: json['parent_session_id'] ?? '', + ); + } + + Map get detailMap { + if (details.isEmpty) { + return {}; + } + try { + return jsonDecode(details) as Map; + } catch (_) { + return {}; + } + } + + String get path { + final detailPath = detailMap['path']?.toString(); + if (detailPath != null && detailPath.isNotEmpty) { + return detailPath; + } + final parts = eventType.split(' '); + if (parts.length >= 2) { + return parts.sublist(1).join(' '); + } + return '-'; + } +} + +class _AuditPage { + final List items; + final String? nextCursor; + + const _AuditPage({required this.items, this.nextCursor}); +} + class DashboardScreen extends ConsumerStatefulWidget { const DashboardScreen({super.key}); @@ -26,7 +118,14 @@ class _DashboardScreenState extends ConsumerState { static const _subtle = Color(0xFFF7F8FA); final ScrollController _pageScrollController = ScrollController(); + final ScrollController _rpScrollController = ScrollController(); + final List _auditLogs = []; + String? _auditNextCursor; + bool _auditLoading = false; + bool _auditLoadingMore = false; + String? _auditError; bool _isRevoking = false; + bool _showAllActivities = false; final Set _revokedClientIds = {}; @@ -34,11 +133,13 @@ class _DashboardScreenState extends ConsumerState { void initState() { super.initState(); _pageScrollController.addListener(_onPageScroll); + _loadAuditLogs(reset: true); } @override void dispose() { _pageScrollController.dispose(); + _rpScrollController.dispose(); super.dispose(); } @@ -71,7 +172,7 @@ class _DashboardScreenState extends ConsumerState { setState(() => _isRevoking = true); try { - await AuthProxyService.revokeLinkedRp(clientId); + await ref.read(linkedRpsProvider.notifier).revokeRp(clientId); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('$appName 연동이 해지되었습니다.')), @@ -264,6 +365,95 @@ class _DashboardScreenState extends ConsumerState { ref.read(authTimelineProvider.notifier).refresh(), ]); } + await _loadAuditLogs(reset: true); + await ref.read(linkedRpsProvider.notifier).refresh(); + } + + static String _envOrDefault(String key, String fallback) { + if (!dotenv.isInitialized) { + return fallback; + } + return dotenv.env[key] ?? fallback; + } + + Future<_AuditPage> _fetchAuditLogs({String? cursor}) async { + final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); + final queryParameters = { + 'limit': '20', + }; + if (cursor != null && cursor.isNotEmpty) { + queryParameters['cursor'] = cursor; + } + final url = Uri.parse('$baseUrl/api/v1/audit/auth/timeline') + .replace(queryParameters: queryParameters); + final useCookie = AuthTokenStore.usesCookie(); + final token = AuthTokenStore.getToken(); + + final client = createHttpClient(withCredentials: useCookie); + final headers = { + 'Content-Type': 'application/json', + }; + if (!useCookie && token != null) { + headers['Authorization'] = 'Bearer $token'; + } + + final response = await client.get(url, headers: headers); + client.close(); + + if (response.statusCode != 200) { + throw Exception('Failed to load audit logs'); + } + + final body = jsonDecode(response.body) as Map; + final items = (body['items'] as List?) ?? []; + final nextCursor = body['next_cursor']?.toString(); + final logs = items + .whereType>() + .map(AuditLogEntry.fromJson) + .toList(); + + return _AuditPage(items: logs, nextCursor: nextCursor); + } + + Future _loadAuditLogs({bool reset = false}) async { + if (_auditLoading || _auditLoadingMore) { + return; + } + if (!reset && (_auditNextCursor == null || _auditNextCursor!.isEmpty)) { + return; + } + + if (reset) { + setState(() { + _auditLogs.clear(); + _auditNextCursor = null; + _auditError = null; + _auditLoading = true; + }); + } else { + setState(() { + _auditLoadingMore = true; + }); + } + + try { + final page = await _fetchAuditLogs(cursor: _auditNextCursor); + setState(() { + _auditLogs.addAll(page.items); + _auditNextCursor = page.nextCursor; + _auditError = null; + }); + } catch (_) { + setState(() { + _auditError = '접속이력을 불러오지 못했습니다.'; + }); + } finally { + setState(() { + _auditLoading = false; + _auditLoadingMore = false; + }); + } + } DateTime? _getJwtIssuedAt() { final token = AuthTokenStore.getToken(); @@ -560,14 +750,10 @@ class _DashboardScreenState extends ConsumerState { _buildHeaderCard(userName, department, sessionIssuedAt), const SizedBox(height: 28), ], - _buildSectionTitle('활동상황', '현재 연결된 앱과 최근 인증 상태입니다.'), + _buildSectionTitle('나의 App 현황', '현재 연결된 앱과 최근 인증 상태입니다.'), const SizedBox(height: 12), _buildActivitySection(isMobile), const SizedBox(height: 28), - _buildSectionTitle('과거 연동 앱', '이전에 연동했던 앱 목록입니다.'), - const SizedBox(height: 12), - _buildPastRps(isMobile), - const SizedBox(height: 28), _buildSectionTitle('접속이력', 'Baron 로그인 기준의 최근 접근 기록입니다.'), const SizedBox(height: 12), _buildAccessHistory(timelineState, timelineWide), @@ -670,31 +856,24 @@ class _DashboardScreenState extends ConsumerState { } Widget _buildActivitySection(bool isMobile) { - final linkedState = ref.watch(linkedRpsProvider); - return linkedState.when( - loading: () => const SizedBox(height: 40, child: Center(child: CircularProgressIndicator())), - error: (_, __) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '연동 정보를 불러오지 못했습니다.', - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - ], - ), + final linkedRpsState = ref.watch(linkedRpsProvider); + + return linkedRpsState.when( data: (linkedRps) { final activities = _buildActivityItems(linkedRps); + final grid = _buildActivityGrid(activities, isMobile); + if (activities.isEmpty) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '연동된 RP가 없습니다.', + '연동된 앱이 없습니다.', style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600), ), const SizedBox(height: 6), Text( - 'RP를 연동하면 최근 활동과 상태가 표시됩니다.', + '앱을 연동하면 최근 활동과 상태가 표시됩니다.', style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ], @@ -702,6 +881,24 @@ class _DashboardScreenState extends ConsumerState { } return _buildActivityGrid(activities, isMobile); }, + loading: () => const SizedBox( + height: 100, + child: Center(child: CircularProgressIndicator()), + ), + error: (error, stack) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '연동 정보를 불러오지 못했습니다.', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => ref.read(linkedRpsProvider.notifier).refresh(), + child: const Text('다시 시도'), + ), + ], + ), ); } @@ -751,14 +948,18 @@ class _DashboardScreenState extends ConsumerState { List<_ActivityItem> _buildActivityItems(List linkedRps) { final items = <_ActivityItem>[]; for (final rp in linkedRps) { - final isRevoked = _revokedClientIds.contains(rp.id); + final normalizedStatus = rp.status.toLowerCase(); + // status가 'inactive'로 내려올 수 있으므로 이를 반영 + final isActiveInApi = normalizedStatus == 'active' || normalizedStatus == ''; + final isRevoked = !isActiveInApi; + final lastAuthLabel = rp.lastAuthenticatedAt != null ? _formatDateTime(rp.lastAuthenticatedAt!) : '연동됨'; - final normalizedStatus = rp.status.toLowerCase(); - final statusLabel = isRevoked ? '비활성' : (normalizedStatus.isEmpty || normalizedStatus == 'active' ? '활성' : '비활성'); + final statusLabel = isRevoked ? '해지됨' : '활성'; final name = rp.name.isNotEmpty ? rp.name : rp.id; + items.add( _ActivityItem( clientId: rp.id, @@ -769,32 +970,44 @@ class _DashboardScreenState extends ConsumerState { canLogout: false, isRevoked: isRevoked, onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name), - url: rp.url, // URL 전달 + url: rp.url, + lastAuthDateTime: rp.lastAuthenticatedAt, ), ); } + // 정렬 로직 적용: 활성 우선 -> 최근 인증 최신순 -> 비활성 + items.sort((a, b) { + final aActive = a.status == '활성'; + final bActive = b.status == '활성'; + + if (aActive && !bActive) return -1; + if (!aActive && bActive) return 1; + + // 둘 다 활성이거나 둘 다 비활성인 경우 최근 인증순 내림차순 + if (a.lastAuthDateTime != null && b.lastAuthDateTime != null) { + return b.lastAuthDateTime!.compareTo(a.lastAuthDateTime!); + } + if (a.lastAuthDateTime != null) return -1; + if (b.lastAuthDateTime != null) return 1; + + return 0; + }); + return items; } Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) { + if (activities.isEmpty) return const SizedBox.shrink(); - if (!isMobile) { - return Wrap( - spacing: 12, - runSpacing: 12, - children: activities.map(_buildActivityCard).toList(), - ); - } + final shouldShowToggle = activities.length > 5; - final visibleCount = _showAllActivities ? activities.length : 4; - final visibleActivities = activities.take(visibleCount).toList(); - final shouldShowToggle = activities.length > 4; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GridView.builder( + // 더보기를 누르지 않은 경우: 최대 5개 노출 (Grid/Wrap) + if (!_showAllActivities) { + final visibleActivities = activities.take(5).toList(); + Widget grid; + if (isMobile) { + grid = GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( @@ -805,23 +1018,117 @@ class _DashboardScreenState extends ConsumerState { ), itemCount: visibleActivities.length, itemBuilder: (context, index) => _buildActivityCard(visibleActivities[index]), - ), - if (shouldShowToggle) - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () { - setState(() { - _showAllActivities = !_showAllActivities; - }); - }, - child: Text(_showAllActivities ? '접기' : '더보기'), + ); + } else { + grid = Wrap( + spacing: 12, + runSpacing: 12, + children: visibleActivities.map(_buildActivityCard).toList(), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + grid, + if (shouldShowToggle) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () => setState(() => _showAllActivities = true), + icon: const Icon(Icons.add, size: 18, color: Colors.blueAccent), + label: const Text('더보기', style: TextStyle(color: Colors.blueAccent, fontWeight: FontWeight.bold)), + ), + ), ), + ], + ); + } + + // 더보기를 누른 경우: 가로 슬라이더/캐러셀 전환 + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + alignment: Alignment.center, + children: [ + SizedBox( + height: 220, + child: ListView.separated( + controller: _rpScrollController, + scrollDirection: Axis.horizontal, + itemCount: activities.length, + separatorBuilder: (context, index) => const SizedBox(width: 12), + itemBuilder: (context, index) => UnconstrainedBox( + alignment: Alignment.topCenter, + child: _buildActivityCard(activities[index]), + ), + ), + ), + // 왼쪽 이동 버튼 + Positioned( + left: 0, + child: _buildScrollButton( + icon: Icons.chevron_left, + onPressed: () => _rpScrollController.animateTo( + (_rpScrollController.offset - 300).clamp(0, _rpScrollController.position.maxScrollExtent), + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ), + ), + ), + // 오른쪽 이동 버튼 + Positioned( + right: 0, + child: _buildScrollButton( + icon: Icons.chevron_right, + onPressed: () => _rpScrollController.animateTo( + (_rpScrollController.offset + 300).clamp(0, _rpScrollController.position.maxScrollExtent), + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () => setState(() { + _showAllActivities = false; + _rpScrollController.jumpTo(0); // 접을 때 위치 초기화 + }), + icon: const Icon(Icons.close, size: 18, color: Colors.grey), + label: const Text('접기', style: TextStyle(color: Colors.grey, fontWeight: FontWeight.bold)), ), + ), ], ); } + Widget _buildScrollButton({required IconData icon, required VoidCallback onPressed}) { + return Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.8), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: IconButton( + icon: Icon(icon, color: _ink), + onPressed: onPressed, + ), + ); + } + Widget _buildActivityCard(_ActivityItem item) { final isActive = item.status == '활성'; final statusColor = isActive ? Colors.green : Colors.grey; @@ -1172,6 +1479,7 @@ class _ActivityItem { final bool isRevoked; final VoidCallback? onLogout; final VoidCallback? onRevoke; + final DateTime? lastAuthDateTime; _ActivityItem({ required this.clientId, @@ -1184,5 +1492,6 @@ class _ActivityItem { this.isRevoked = false, this.onLogout, this.onRevoke, + this.lastAuthDateTime, }); } From bb78c8e5725bd014ba7dd9f953dbed5c6aff6d02 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 9 Feb 2026 11:18:32 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Linked=20API=EC=97=90=20Audit=20Log=20?= =?UTF-8?q?=EC=8A=A4=EC=BA=94=EC=9D=84=20=ED=86=B5=ED=95=9C=20=EA=B3=BC?= =?UTF-8?q?=EA=B1=B0=20=EC=9D=B4=EB=A0=A5=20=EB=B3=B4=EA=B0=95=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 78 ++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 98d86b10..9941f64d 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3386,6 +3386,84 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { } } + // [New] Audit Log Scan for recent history fallback (timeline 200 items) + // Hydra 세션이나 로컬 DB(ConsentRepo)에 없지만 최근 활동 이력이 있는 앱을 보강 + if h.AuditRepo != nil { + for _, subject := range subjects { + auditLogs, err := h.AuditRepo.FindByUserAndEvents(c.Context(), subject, []string{"consent.granted", "consent.revoked"}, 200) + if err != nil { + slog.Error("failed to scan audit logs for linked rps", "error", err, "subject", subject) + continue + } + for _, log := range auditLogs { + var details struct { + ClientID string `json:"client_id"` + ClientName string `json:"client_name"` + Scopes interface{} `json:"scopes"` + } + // 로그 Details 파싱 + if err := json.Unmarshal([]byte(log.Details), &details); err != nil { + continue + } + if details.ClientID == "" { + continue + } + + // 이미 records에 있으면(Active or ConsentRepo) 패스 + if _, exists := records[details.ClientID]; exists { + continue + } + + // 스코프 추출 (consent.granted인 경우) + scopes := []string{} + if sList, ok := details.Scopes.([]interface{}); ok { + for _, s := range sList { + if str, ok := s.(string); ok { + scopes = append(scopes, str) + } + } + } + + // 기본 레코드 생성 + record := &linkedRpRecord{ + linkedRpSummary: linkedRpSummary{ + ID: details.ClientID, + Name: details.ClientName, // revoked 로그일 경우 비어있을 수 있음 + Status: "inactive", + Scopes: scopes, + }, + lastAuth: log.Timestamp, + } + + // Hydra에서 최신 메타데이터 조회 시도 + client, err := h.Hydra.GetClient(c.Context(), details.ClientID) + if err == nil { + name := strings.TrimSpace(client.ClientName) + if name == "" { + name = client.ClientID + } + record.Name = name + record.Logo = extractHydraClientLogo(client.Metadata) + + clientURL := strings.TrimSpace(client.ClientURI) + if clientURL == "" && len(client.RedirectURIs) > 0 { + if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil { + clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) + } + } + record.URL = clientURL + } else { + // Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체 + if record.Name == "" { + record.Name = details.ClientID + } + } + + records[details.ClientID] = record + } + } + } + ordered := make([]*linkedRpRecord, 0, len(records)) for _, record := range records { ordered = append(ordered, record) From 05b6ff6d9e999f1513352756be10e9f71d69b8b3 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 9 Feb 2026 11:19:10 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=EC=95=B1=20=ED=98=84=ED=99=A9=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20Linked=20API=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20UI=20=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/domain/dashboard_providers.dart | 39 +-- .../domain/providers/linked_rps_provider.dart | 111 ++++++++ .../presentation/dashboard_screen.dart | 241 +++--------------- 3 files changed, 151 insertions(+), 240 deletions(-) create mode 100644 userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart diff --git a/userfront/lib/features/dashboard/domain/dashboard_providers.dart b/userfront/lib/features/dashboard/domain/dashboard_providers.dart index 7d5a9427..61ad25f5 100644 --- a/userfront/lib/features/dashboard/domain/dashboard_providers.dart +++ b/userfront/lib/features/dashboard/domain/dashboard_providers.dart @@ -28,38 +28,7 @@ Future> _fetchLinkedRps() async { return result; } -Future> _fetchRpHistory() async { - final url = Uri.parse('$_baseUrl/api/v1/user/rp/history'); - final useCookie = AuthTokenStore.usesCookie(); - final token = AuthTokenStore.getToken(); - final client = createHttpClient(withCredentials: useCookie); - final headers = { - 'Content-Type': 'application/json', - }; - if (!useCookie && token != null) { - headers['Authorization'] = 'Bearer $token'; - } - - try { - final response = await client.get(url, headers: headers); - if (response.statusCode != 200) { - throw Exception('Failed to load rp history'); - } - - final body = jsonDecode(response.body) as Map; - final items = (body['items'] as List?) ?? []; - final result = []; - for (final item in items) { - if (item is Map) { - result.add(RpHistoryItem.fromJson(Map.from(item))); - } - } - return result; - } finally { - client.close(); - } -} Future _fetchAuthTimelinePage({String? cursor}) async { final queryParameters = { @@ -104,13 +73,9 @@ Future _fetchAuthTimelinePage({String? cursor}) async { } } -final linkedRpsProvider = FutureProvider>((ref) async { - return _fetchLinkedRps(); -}); -final rpHistoryProvider = FutureProvider>((ref) async { - return _fetchRpHistory(); -}); + + typedef AuthTimelineFetcher = Future Function({String? cursor}); diff --git a/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart b/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart new file mode 100644 index 00000000..83f20971 --- /dev/null +++ b/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:userfront/core/services/auth_proxy_service.dart'; +import 'package:userfront/core/services/auth_token_store.dart'; +import 'package:userfront/core/services/http_client.dart'; + +class LinkedRp { + final String id; + final String name; + final String logo; + final String url; + final String status; + final List scopes; + final DateTime? lastAuthenticatedAt; + + LinkedRp({ + required this.id, + required this.name, + required this.logo, + required this.url, + required this.status, + required this.scopes, + required this.lastAuthenticatedAt, + }); + + factory LinkedRp.fromJson(Map json) { + final rawLastAuth = json['lastAuthenticatedAt']?.toString() ?? ''; + DateTime? parsedLastAuth; + if (rawLastAuth.isNotEmpty) { + try { + parsedLastAuth = DateTime.parse(rawLastAuth).toLocal(); + } catch (_) { + parsedLastAuth = null; + } + } + + return LinkedRp( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + logo: json['logo']?.toString() ?? '', + url: json['url']?.toString() ?? '', + status: json['status']?.toString() ?? 'unknown', + scopes: (json['scopes'] as List?)?.whereType().toList() ?? [], + lastAuthenticatedAt: parsedLastAuth, + ); + } +} + +class LinkedRpsNotifier extends AsyncNotifier> { + @override + Future> build() async { + return _fetchLinkedRps(); + } + + String _envOrDefault(String key, String fallback) { + if (!dotenv.isInitialized) { + return fallback; + } + return dotenv.env[key] ?? fallback; + } + + Future> _fetchLinkedRps() async { + try { + final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); + final url = Uri.parse('$baseUrl/api/v1/user/rp/linked'); + + final useCookie = AuthTokenStore.usesCookie(); + final token = AuthTokenStore.getToken(); + + final client = createHttpClient(withCredentials: useCookie); + final headers = { + 'Content-Type': 'application/json', + }; + if (!useCookie && token != null) { + headers['Authorization'] = 'Bearer $token'; + } + + final response = await client.get(url, headers: headers); + client.close(); + + if (response.statusCode != 200) { + throw Exception('Failed to load linked rps: ${response.statusCode}'); + } + + final body = jsonDecode(response.body) as Map; + final items = (body['items'] as List?) ?? []; + + return items + .whereType>() + .map(LinkedRp.fromJson) + .toList(); + } catch (e) { + rethrow; + } + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() => _fetchLinkedRps()); + } + + Future revokeRp(String clientId) async { + await AuthProxyService.revokeLinkedRp(clientId); + await refresh(); + } +} + +final linkedRpsProvider = AsyncNotifierProvider>(() { + return LinkedRpsNotifier(); +}); \ No newline at end of file diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 6d41a21b..6d87dff4 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -4,105 +4,16 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../domain/providers/linked_rps_provider.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_token_store.dart'; +import '../../../../core/services/http_client.dart'; import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/ui/layout_breakpoints.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; import '../domain/dashboard_providers.dart'; -import '../domain/models.dart'; - -class AuditLogEntry { - final String eventId; - final DateTime timestamp; - final String userId; - final String eventType; - final String status; - final String authMethod; - final String ipAddress; - final String userAgent; - final String sessionId; - final String details; - final String source; - final String clientId; - final String appName; - final String parentSessionId; - - AuditLogEntry({ - required this.eventId, - required this.timestamp, - required this.userId, - required this.eventType, - required this.status, - required this.authMethod, - required this.ipAddress, - required this.userAgent, - required this.sessionId, - required this.details, - required this.source, - required this.clientId, - required this.appName, - required this.parentSessionId, - }); - - factory AuditLogEntry.fromJson(Map json) { - final timestampRaw = json['timestamp']?.toString() ?? ''; - DateTime parsedTimestamp; - try { - parsedTimestamp = DateTime.parse(timestampRaw).toLocal(); - } catch (_) { - parsedTimestamp = DateTime.now(); - } - - return AuditLogEntry( - eventId: json['event_id'] ?? '', - timestamp: parsedTimestamp, - userId: json['user_id'] ?? '', - eventType: json['event_type'] ?? '', - status: json['status'] ?? '', - authMethod: json['auth_method'] ?? '', - ipAddress: json['ip_address'] ?? '', - userAgent: json['user_agent'] ?? '', - sessionId: json['session_id'] ?? '', - details: json['details'] ?? '', - source: json['source'] ?? '', - clientId: json['client_id'] ?? '', - appName: json['app_name'] ?? '', - parentSessionId: json['parent_session_id'] ?? '', - ); - } - - Map get detailMap { - if (details.isEmpty) { - return {}; - } - try { - return jsonDecode(details) as Map; - } catch (_) { - return {}; - } - } - - String get path { - final detailPath = detailMap['path']?.toString(); - if (detailPath != null && detailPath.isNotEmpty) { - return detailPath; - } - final parts = eventType.split(' '); - if (parts.length >= 2) { - return parts.sublist(1).join(' '); - } - return '-'; - } -} - -class _AuditPage { - final List items; - final String? nextCursor; - - const _AuditPage({required this.items, this.nextCursor}); -} +import '../domain/models.dart' hide LinkedRp; class DashboardScreen extends ConsumerStatefulWidget { const DashboardScreen({super.key}); @@ -181,7 +92,6 @@ class _DashboardScreenState extends ConsumerState { _revokedClientIds.add(clientId); }); ref.invalidate(linkedRpsProvider); - ref.invalidate(rpHistoryProvider); } } catch (e) { if (mounted) { @@ -214,7 +124,6 @@ class _DashboardScreenState extends ConsumerState { context: context, builder: (context) => Consumer( builder: (context, ref, _) { - final historyState = ref.watch(rpHistoryProvider); return AlertDialog( title: Text(item.appName), content: SizedBox( @@ -240,48 +149,16 @@ class _DashboardScreenState extends ConsumerState { const SizedBox(height: 24), const Text('상태 이력', style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), - historyState.when( - loading: () => const SizedBox(height: 20, child: LinearProgressIndicator()), - error: (_, __) => const Text('이력을 불러올 수 없습니다.', style: TextStyle(color: Colors.grey)), - data: (history) { - final filtered = history.where((h) => h.clientId == item.clientId).toList(); - if (filtered.isEmpty) { - return Text('최근 인증: ${item.lastAuthAt}'); - } - final h = filtered.first; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (h.lastApprovedAt != null) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - children: [ - const Icon(Icons.check_circle_outline, size: 16, color: Colors.green), - const SizedBox(width: 8), - Text('승인: ${_formatDateTime(h.lastApprovedAt!)}'), - ], - ), - ), - if (h.lastRevokedAt != null) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - children: [ - const Icon(Icons.cancel_outlined, size: 16, color: Colors.redAccent), - const SizedBox(width: 8), - Text('해지: ${_formatDateTime(h.lastRevokedAt!)}'), - ], - ), - ), - const SizedBox(height: 4), - Text( - '현재 상태: ${h.status == 'active' ? '활성' : '해지됨'}', - style: TextStyle(color: h.status == 'active' ? Colors.green : Colors.grey), - ), - ], - ); - }, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('최근 인증: ${item.lastAuthAt}'), + const SizedBox(height: 4), + Text( + '현재 상태: ${item.status}', + style: TextStyle(color: item.status == '활성' ? Colors.green : Colors.grey), + ), + ], ), ], ), @@ -356,15 +233,12 @@ class _DashboardScreenState extends ConsumerState { _revokedClientIds.clear(); }); ref.invalidate(linkedRpsProvider); - ref.invalidate(rpHistoryProvider); - final linkedFuture = ref.read(linkedRpsProvider.future); - final historyFuture = ref.read(rpHistoryProvider.future); - await Future.wait(>[ - linkedFuture, - historyFuture, + + await Future.wait([ + ref.read(linkedRpsProvider.future), ref.read(authTimelineProvider.notifier).refresh(), ]); - } + await _loadAuditLogs(reset: true); await ref.read(linkedRpsProvider.notifier).refresh(); } @@ -376,7 +250,7 @@ class _DashboardScreenState extends ConsumerState { return dotenv.env[key] ?? fallback; } - Future<_AuditPage> _fetchAuditLogs({String? cursor}) async { + Future _fetchAuditLogs({String? cursor}) async { final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); final queryParameters = { 'limit': '20', @@ -397,22 +271,24 @@ class _DashboardScreenState extends ConsumerState { headers['Authorization'] = 'Bearer $token'; } - final response = await client.get(url, headers: headers); - client.close(); + try { + final response = await client.get(url, headers: headers); + if (response.statusCode != 200) { + throw Exception('Failed to load audit logs'); + } - if (response.statusCode != 200) { - throw Exception('Failed to load audit logs'); + final body = jsonDecode(response.body) as Map; + final items = (body['items'] as List?) ?? []; + final nextCursor = body['next_cursor']?.toString(); + final logs = items + .whereType>() + .map(AuditLogEntry.fromJson) + .toList(); + + return AuditPage(items: logs, nextCursor: nextCursor); + } finally { + client.close(); } - - final body = jsonDecode(response.body) as Map; - final items = (body['items'] as List?) ?? []; - final nextCursor = body['next_cursor']?.toString(); - final logs = items - .whereType>() - .map(AuditLogEntry.fromJson) - .toList(); - - return _AuditPage(items: logs, nextCursor: nextCursor); } Future _loadAuditLogs({bool reset = false}) async { @@ -902,48 +778,7 @@ class _DashboardScreenState extends ConsumerState { ); } - Widget _buildPastRps(bool isMobile) { - final historyState = ref.watch(rpHistoryProvider); - return historyState.when( - loading: () => const SizedBox(height: 40, child: Center(child: CircularProgressIndicator())), - error: (_, __) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '과거 연동 정보를 불러오지 못했습니다.', - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - ], - ), - data: (history) { - final pastItems = history.where((h) => h.status != 'active').toList(); - if (pastItems.isEmpty) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '과거 연동 이력이 없습니다.', - style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600), - ), - ], - ); - } - final activities = pastItems.map((h) => _ActivityItem( - clientId: h.clientId, - appName: h.clientName.isNotEmpty ? h.clientName : h.clientId, - lastAuthAt: h.lastRevokedAt != null ? '해지: ${_formatDateTime(h.lastRevokedAt!)}' : '해지됨', - status: '해지됨', - scopes: h.scopes, - canLogout: false, - isRevoked: true, - onRevoke: null, - )).toList(); - - return _buildActivityGrid(activities, isMobile); - }, - ); - } List<_ActivityItem> _buildActivityItems(List linkedRps) { final items = <_ActivityItem>[]; @@ -1000,11 +835,11 @@ class _DashboardScreenState extends ConsumerState { Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) { if (activities.isEmpty) return const SizedBox.shrink(); - final shouldShowToggle = activities.length > 5; + final shouldShowToggle = activities.length > 4; - // 더보기를 누르지 않은 경우: 최대 5개 노출 (Grid/Wrap) + // 더보기를 누르지 않은 경우: 최대 4개 노출 (Grid/Wrap) if (!_showAllActivities) { - final visibleActivities = activities.take(5).toList(); + final visibleActivities = activities.take(4).toList(); Widget grid; if (isMobile) { grid = GridView.builder( @@ -1494,4 +1329,4 @@ class _ActivityItem { this.onRevoke, this.lastAuthDateTime, }); -} +} \ No newline at end of file From 9b7b2da497116a14da553a30eb977de3b2f8bc19 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 9 Feb 2026 11:23:13 +0900 Subject: [PATCH 4/4] =?UTF-8?q?golangci=20=EB=A6=B0=ED=8A=B8=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/federation_handler.go | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/internal/handler/federation_handler.go b/backend/internal/handler/federation_handler.go index 6807391e..0c05123f 100644 --- a/backend/internal/handler/federation_handler.go +++ b/backend/internal/handler/federation_handler.go @@ -108,6 +108,7 @@ func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(req) } + // --- Deprecated Tenant-based IdP Config Methods --- // ListIdpConfigsForTenant handles listing all IdP configurations for a tenant.