1
0
forked from baron/baron-sso

Apply stashed changes after rebase

This commit is contained in:
2026-02-09 09:56:10 +09:00
parent 02e5fd2254
commit 4b7264217d
4 changed files with 433 additions and 53 deletions

View File

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

View File

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

View File

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

View File

@@ -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<String, dynamic> 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<String, dynamic> get detailMap {
if (details.isEmpty) {
return {};
}
try {
return jsonDecode(details) as Map<String, dynamic>;
} 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<AuditLogEntry> 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<DashboardScreen> {
static const _subtle = Color(0xFFF7F8FA);
final ScrollController _pageScrollController = ScrollController();
final ScrollController _rpScrollController = ScrollController();
final List<AuditLogEntry> _auditLogs = [];
String? _auditNextCursor;
bool _auditLoading = false;
bool _auditLoadingMore = false;
String? _auditError;
bool _isRevoking = false;
bool _showAllActivities = false;
final Set<String> _revokedClientIds = {};
@@ -34,11 +133,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
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<DashboardScreen> {
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<DashboardScreen> {
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 = <String, String>{
'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 = <String, String>{
'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<String, dynamic>;
final items = (body['items'] as List?) ?? [];
final nextCursor = body['next_cursor']?.toString();
final logs = items
.whereType<Map<String, dynamic>>()
.map(AuditLogEntry.fromJson)
.toList();
return _AuditPage(items: logs, nextCursor: nextCursor);
}
Future<void> _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<DashboardScreen> {
_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<DashboardScreen> {
}
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<DashboardScreen> {
}
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<DashboardScreen> {
List<_ActivityItem> _buildActivityItems(List<LinkedRp> 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<DashboardScreen> {
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<DashboardScreen> {
),
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,
});
}