diff --git a/backend/docs/openapi.yaml b/backend/docs/openapi.yaml index 10ced54f..60679946 100644 --- a/backend/docs/openapi.yaml +++ b/backend/docs/openapi.yaml @@ -1101,6 +1101,9 @@ components: type: string phone: type: string + sessionAuthenticatedAt: + type: string + format: date-time department: type: string affiliationType: diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index b81181fe..f947413c 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -68,19 +68,20 @@ type SignupRequest struct { // User Profile Models type UserProfileResponse struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Phone string `json:"phone"` - Role string `json:"role"` // 추가 - Department string `json:"department"` - AffiliationType string `json:"affiliationType"` - CompanyCode string `json:"companyCode,omitempty"` - TenantID *string `json:"tenantId,omitempty"` // 추가 - RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가 - Metadata map[string]any `json:"metadata,omitempty"` - Tenant *Tenant `json:"tenant,omitempty"` - ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록 + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Phone string `json:"phone"` + Role string `json:"role"` // 추가 + SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"` + Department string `json:"department"` + AffiliationType string `json:"affiliationType"` + CompanyCode string `json:"companyCode,omitempty"` + TenantID *string `json:"tenantId,omitempty"` // 추가 + RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가 + Metadata map[string]any `json:"metadata,omitempty"` + Tenant *Tenant `json:"tenant,omitempty"` + ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록 } type UpdateUserRequest struct { diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 3fabec6e..5b6b98ae 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4886,37 +4886,43 @@ func extractLoginIDFromClaims(claims map[string]any) string { } func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) { + identityID, traits, _, err := h.getKratosIdentityWithSession(sessionToken) + return identityID, traits, err +} + +func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]interface{}, string, error) { kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") if kratosURL == "" { kratosURL = "http://kratos:4433" } req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) if err != nil { - return "", nil, err + return "", nil, "", err } req.Header.Set("X-Session-Token", sessionToken) resp, err := http.DefaultClient.Do(req) if err != nil { - return "", nil, err + return "", nil, "", err } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) + return "", nil, "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) } var result struct { - Identity struct { + AuthenticatedAt string `json:"authenticated_at"` + Identity struct { ID string `json:"id"` Traits map[string]interface{} `json:"traits"` } `json:"identity"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", nil, err + return "", nil, "", err } - return result.Identity.ID, result.Identity.Traits, nil + return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, nil } func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) { @@ -4993,37 +4999,43 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string) } func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, error) { + identityID, traits, _, err := h.getKratosIdentityWithCookieAndSession(cookie) + return identityID, traits, err +} + +func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]interface{}, string, error) { kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") if kratosURL == "" { kratosURL = "http://kratos:4433" } req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) if err != nil { - return "", nil, err + return "", nil, "", err } req.Header.Set("Cookie", cookie) resp, err := http.DefaultClient.Do(req) if err != nil { - return "", nil, err + return "", nil, "", err } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) + return "", nil, "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) } var result struct { - Identity struct { + AuthenticatedAt string `json:"authenticated_at"` + Identity struct { ID string `json:"id"` Traits map[string]interface{} `json:"traits"` } `json:"identity"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", nil, err + return "", nil, "", err } - return result.Identity.ID, result.Identity.Traits, nil + return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, nil } func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) { @@ -5158,20 +5170,34 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s return profile } +func (h *AuthHandler) applySessionAuthenticatedAtFromWhoami(profile *domain.UserProfileResponse, authenticatedAt string) *domain.UserProfileResponse { + if profile == nil { + return nil + } + profile.SessionAuthenticatedAt = strings.TrimSpace(authenticatedAt) + return profile +} + func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) { - identityID, traits, err := h.getKratosIdentity(sessionToken) + identityID, traits, authenticatedAt, err := h.getKratosIdentityWithSession(sessionToken) if err != nil { return nil, err } - return h.mapKratosIdentityToProfile(identityID, traits), nil + return h.applySessionAuthenticatedAtFromWhoami( + h.mapKratosIdentityToProfile(identityID, traits), + authenticatedAt, + ), nil } func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) { - identityID, traits, err := h.getKratosIdentityWithCookie(cookie) + identityID, traits, authenticatedAt, err := h.getKratosIdentityWithCookieAndSession(cookie) if err != nil { return nil, err } - return h.mapKratosIdentityToProfile(identityID, traits), nil + return h.applySessionAuthenticatedAtFromWhoami( + h.mapKratosIdentityToProfile(identityID, traits), + authenticatedAt, + ), nil } // UpdateMe - Updates current user's profile with phone verification check diff --git a/backend/internal/handler/auth_handler_session_profile_test.go b/backend/internal/handler/auth_handler_session_profile_test.go new file mode 100644 index 00000000..6b657b7d --- /dev/null +++ b/backend/internal/handler/auth_handler_session_profile_test.go @@ -0,0 +1,105 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/require" +) + +func TestGetMe_IncludesSessionAuthenticatedAtFromKratosSession(t *testing.T) { + const ( + token = "token-session" + identityID = "user-session" + sessionAuthenticated = "2026-03-23T15:30:00Z" + ) + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Host == "kratos.test" && + r.URL.Path == "/sessions/whoami" && + r.Method == http.MethodGet { + require.Equal(t, token, r.Header.Get("X-Session-Token")) + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "kratos-session-1", + "authenticated_at": sessionAuthenticated, + "identity": map[string]any{ + "id": identityID, + "traits": map[string]any{ + "email": "qa@example.com", + "name": "QA User", + "department": "Platform", + "affiliationType": "GENERAL", + }, + }, + }), nil + } + + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + setDefaultHTTPClientForTest(t, transport) + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + + h := &AuthHandler{} + app := fiber.New() + app.Get("/api/v1/user/me", h.GetMe) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := app.Test(req, -1) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var profile map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile)) + require.Equal(t, sessionAuthenticated, profile["sessionAuthenticatedAt"]) +} + +func TestGetMe_IncludesSessionAuthenticatedAtForCookieSession(t *testing.T) { + const ( + cookieHeader = "ory_kratos_session=session-cookie" + identityID = "user-cookie" + sessionAuthenticated = "2026-03-24T01:20:00Z" + ) + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Host == "kratos.test" && + r.URL.Path == "/sessions/whoami" && + r.Method == http.MethodGet { + require.Equal(t, cookieHeader, r.Header.Get("Cookie")) + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "kratos-session-cookie", + "authenticated_at": sessionAuthenticated, + "identity": map[string]any{ + "id": identityID, + "traits": map[string]any{ + "email": "cookie@example.com", + "name": "Cookie User", + "department": "Platform", + "affiliationType": "GENERAL", + }, + }, + }), nil + } + + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + setDefaultHTTPClientForTest(t, transport) + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + + h := &AuthHandler{} + app := fiber.New() + app.Get("/api/v1/user/me", h.GetMe) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil) + req.Header.Set("Cookie", cookieHeader) + resp, err := app.Test(req, -1) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var profile map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile)) + require.Equal(t, sessionAuthenticated, profile["sessionAuthenticatedAt"]) +} diff --git a/docs/trouble-shooting/issue-434-dashboard-session-start-time.md b/docs/trouble-shooting/issue-434-dashboard-session-start-time.md new file mode 100644 index 00000000..13067edf --- /dev/null +++ b/docs/trouble-shooting/issue-434-dashboard-session-start-time.md @@ -0,0 +1,45 @@ +# Issue #434 트러블슈팅 기록: 대시보드 세션 시작 시간이 `Unknown`으로 표시됨 + +## 기준 시점 +- 2026-03-24 KST +- 대상 화면: UserFront 대시보드 상단 세션 정보 칩 + +## 증상 +- 로그인 후 대시보드의 세션 시작 시간이 `Unknown` 또는 `알 수 없음`으로 표시됨 +- 특히 동일 브라우저의 cookie session 승격 경로에서 재현됨 + +## 원인 +1. 기존 대시보드는 저장된 로컬 토큰만 파싱해 `iat` 또는 `auth_time`을 읽었습니다. +2. cookie mode에서는 `AuthTokenStore.setCookieMode()`가 로컬 토큰을 제거하고 cookie 플래그만 유지합니다. +3. 그 결과 대시보드는 파싱할 JWT가 없어 항상 fallback 문구로 떨어졌습니다. + +## 수정 방향 +1. Backend `/api/v1/user/me` 응답에 Kratos `sessions/whoami`의 `authenticated_at` 값을 `sessionAuthenticatedAt`으로 포함합니다. +2. UserFront 대시보드는 세션 시각 계산 시 다음 우선순위를 사용합니다. + - JWT의 `iat` 또는 `auth_time` + - profile의 `sessionAuthenticatedAt` +3. 두 값이 모두 없을 때만 `ui.userfront.session.unknown` fallback을 사용합니다. + +## 반영 파일 +- `backend/internal/domain/auth_models.go` +- `backend/internal/handler/auth_handler.go` +- `backend/docs/openapi.yaml` +- `userfront/lib/features/profile/data/models/user_profile_model.dart` +- `userfront/lib/features/dashboard/domain/session_time_resolver.dart` +- `userfront/lib/features/dashboard/presentation/dashboard_screen.dart` + +## 회귀 테스트 +- Backend + - `backend/internal/handler/auth_handler_session_profile_test.go` +- UserFront + - `userfront/test/dashboard_session_time_resolver_test.dart` + - `userfront/test/dashboard_screen_smoke_test.dart` + +## 검증 명령 +- `GOCACHE=/tmp/go-build go test ./internal/handler -run 'TestGetMe_IncludesSessionAuthenticatedAt' -count=1` +- `flutter test test/dashboard_session_time_resolver_test.dart` +- `flutter test test/dashboard_screen_smoke_test.dart` + +## 남은 참고사항 +- Hydra introspection fallback만 사용되는 토큰 경로에서는 `sessionAuthenticatedAt`이 비어 있을 수 있습니다. +- 이 경우에도 JWT claim이 없으면 기존 fallback 문구를 유지합니다. diff --git a/userfront/lib/features/dashboard/domain/session_time_resolver.dart b/userfront/lib/features/dashboard/domain/session_time_resolver.dart new file mode 100644 index 00000000..feb0210f --- /dev/null +++ b/userfront/lib/features/dashboard/domain/session_time_resolver.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +import '../../profile/data/models/user_profile_model.dart'; + +DateTime? resolveDashboardSessionIssuedAt({ + String? token, + UserProfile? profile, +}) { + final tokenIssuedAt = _getJwtIssuedAt(token); + if (tokenIssuedAt != null) { + return tokenIssuedAt; + } + return _parseSessionAuthenticatedAt(profile?.sessionAuthenticatedAt); +} + +DateTime? _getJwtIssuedAt(String? token) { + if (token == null || token.isEmpty) { + return null; + } + try { + final parts = token.split('.'); + if (parts.length != 3) { + return null; + } + final payload = utf8.decode( + base64Url.decode(base64Url.normalize(parts[1])), + ); + final data = json.decode(payload) as Map; + final iatValue = data['iat'] ?? data['auth_time']; + if (iatValue is num) { + return DateTime.fromMillisecondsSinceEpoch( + iatValue.toInt() * 1000, + ).toLocal(); + } + } catch (_) { + return null; + } + return null; +} + +DateTime? _parseSessionAuthenticatedAt(String? value) { + if (value == null || value.trim().isEmpty) { + return null; + } + try { + return DateTime.parse(value).toLocal(); + } catch (_) { + return null; + } +} diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 342d0940..d4a79714 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -7,6 +7,7 @@ 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/session_time_resolver.dart'; import '../domain/providers/linked_rps_provider.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_proxy_service.dart'; @@ -404,32 +405,6 @@ class _DashboardScreenState extends ConsumerState { } } - DateTime? _getJwtIssuedAt() { - final token = AuthTokenStore.getToken(); - if (token == null || token.isEmpty) { - return null; - } - try { - final parts = token.split('.'); - if (parts.length != 3) { - return null; - } - final payload = utf8.decode( - base64Url.decode(base64Url.normalize(parts[1])), - ); - final data = json.decode(payload) as Map; - final iatValue = data['iat'] ?? data['auth_time']; - if (iatValue is num) { - return DateTime.fromMillisecondsSinceEpoch( - iatValue.toInt() * 1000, - ).toLocal(); - } - } catch (_) { - return null; - } - return null; - } - String _formatDateTime(DateTime dateTime) { final yyyy = dateTime.year.toString().padLeft(4, '0'); final mm = dateTime.month.toString().padLeft(2, '0'); @@ -716,7 +691,10 @@ class _DashboardScreenState extends ConsumerState { final department = departmentValue.isNotEmpty ? departmentValue : tr('ui.userfront.profile.department_empty'); - final sessionIssuedAt = _getJwtIssuedAt(); + final sessionIssuedAt = resolveDashboardSessionIssuedAt( + token: AuthTokenStore.getToken(), + profile: profile, + ); return Scaffold( backgroundColor: _subtle, diff --git a/userfront/lib/features/profile/data/models/user_profile_model.dart b/userfront/lib/features/profile/data/models/user_profile_model.dart index 9485a836..eb73b30d 100644 --- a/userfront/lib/features/profile/data/models/user_profile_model.dart +++ b/userfront/lib/features/profile/data/models/user_profile_model.dart @@ -33,6 +33,7 @@ class UserProfile { final String department; final String affiliationType; final String companyCode; + final String? sessionAuthenticatedAt; final Map? metadata; final Tenant? tenant; @@ -44,6 +45,7 @@ class UserProfile { required this.department, required this.affiliationType, required this.companyCode, + this.sessionAuthenticatedAt, this.metadata, this.tenant, }); @@ -57,6 +59,7 @@ class UserProfile { department: json['department'] ?? '', affiliationType: json['affiliationType'] ?? '', companyCode: json['companyCode'] ?? '', + sessionAuthenticatedAt: json['sessionAuthenticatedAt'] as String?, metadata: json['metadata'] != null ? Map.from(json['metadata']) : null, @@ -73,6 +76,7 @@ class UserProfile { 'department': department, 'affiliationType': affiliationType, 'companyCode': companyCode, + 'sessionAuthenticatedAt': sessionAuthenticatedAt, 'metadata': metadata, 'tenant': tenant?.toJson(), }; @@ -87,6 +91,7 @@ class UserProfile { department: department ?? this.department, affiliationType: affiliationType, companyCode: companyCode, + sessionAuthenticatedAt: sessionAuthenticatedAt, tenant: tenant, ); } diff --git a/userfront/test/dashboard_session_time_resolver_test.dart b/userfront/test/dashboard_session_time_resolver_test.dart new file mode 100644 index 00000000..f3b72384 --- /dev/null +++ b/userfront/test/dashboard_session_time_resolver_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/features/dashboard/domain/session_time_resolver.dart'; +import 'package:userfront/features/profile/data/models/user_profile_model.dart'; + +void main() { + test('JWT에 iat가 있으면 세션 시각으로 사용한다', () { + const token = 'eyJhbGciOiJub25lIn0.eyJpYXQiOjE3MTEyMDc4MDB9.signature'; + + final issuedAt = resolveDashboardSessionIssuedAt(token: token); + + expect(issuedAt, isNotNull); + expect(issuedAt!.toUtc().toIso8601String(), '2024-03-23T15:30:00.000Z'); + }); + + test('cookie mode에서는 profile의 sessionAuthenticatedAt으로 복원한다', () { + final profile = UserProfile( + id: 'user-1', + email: 'qa@example.com', + name: 'QA User', + phone: '01012345678', + department: 'Platform', + affiliationType: 'GENERAL', + companyCode: '', + sessionAuthenticatedAt: '2026-03-23T15:30:00Z', + ); + + final issuedAt = resolveDashboardSessionIssuedAt( + token: null, + profile: profile, + ); + + expect(issuedAt, isNotNull); + expect(issuedAt!.toUtc().toIso8601String(), '2026-03-23T15:30:00.000Z'); + }); +}