forked from baron/baron-sso
/api/v1/user/me 세션 시각을 추가하고 userfront 대시보드 Unknown 세션 시간 문제 수정
This commit is contained in:
@@ -1101,6 +1101,9 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
phone:
|
phone:
|
||||||
type: string
|
type: string
|
||||||
|
sessionAuthenticatedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
department:
|
department:
|
||||||
type: string
|
type: string
|
||||||
affiliationType:
|
affiliationType:
|
||||||
|
|||||||
@@ -68,19 +68,20 @@ type SignupRequest struct {
|
|||||||
// User Profile Models
|
// User Profile Models
|
||||||
|
|
||||||
type UserProfileResponse struct {
|
type UserProfileResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Role string `json:"role"` // 추가
|
Role string `json:"role"` // 추가
|
||||||
Department string `json:"department"`
|
SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"`
|
||||||
AffiliationType string `json:"affiliationType"`
|
Department string `json:"department"`
|
||||||
CompanyCode string `json:"companyCode,omitempty"`
|
AffiliationType string `json:"affiliationType"`
|
||||||
TenantID *string `json:"tenantId,omitempty"` // 추가
|
CompanyCode string `json:"companyCode,omitempty"`
|
||||||
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
TenantID *string `json:"tenantId,omitempty"` // 추가
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
||||||
Tenant *Tenant `json:"tenant,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
Tenant *Tenant `json:"tenant,omitempty"`
|
||||||
|
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserRequest struct {
|
type UpdateUserRequest struct {
|
||||||
|
|||||||
@@ -4886,37 +4886,43 @@ func extractLoginIDFromClaims(claims map[string]any) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) {
|
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"), "/")
|
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
||||||
if kratosURL == "" {
|
if kratosURL == "" {
|
||||||
kratosURL = "http://kratos:4433"
|
kratosURL = "http://kratos:4433"
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("X-Session-Token", sessionToken)
|
req.Header.Set("X-Session-Token", sessionToken)
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, "", err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode >= 300 {
|
if resp.StatusCode >= 300 {
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
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 {
|
var result struct {
|
||||||
Identity struct {
|
AuthenticatedAt string `json:"authenticated_at"`
|
||||||
|
Identity struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Traits map[string]interface{} `json:"traits"`
|
Traits map[string]interface{} `json:"traits"`
|
||||||
} `json:"identity"`
|
} `json:"identity"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
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) {
|
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) {
|
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"), "/")
|
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
||||||
if kratosURL == "" {
|
if kratosURL == "" {
|
||||||
kratosURL = "http://kratos:4433"
|
kratosURL = "http://kratos:4433"
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("Cookie", cookie)
|
req.Header.Set("Cookie", cookie)
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, "", err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode >= 300 {
|
if resp.StatusCode >= 300 {
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
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 {
|
var result struct {
|
||||||
Identity struct {
|
AuthenticatedAt string `json:"authenticated_at"`
|
||||||
|
Identity struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Traits map[string]interface{} `json:"traits"`
|
Traits map[string]interface{} `json:"traits"`
|
||||||
} `json:"identity"`
|
} `json:"identity"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
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) {
|
func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) {
|
||||||
@@ -5158,20 +5170,34 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
|
|||||||
return profile
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// UpdateMe - Updates current user's profile with phone verification check
|
||||||
|
|||||||
105
backend/internal/handler/auth_handler_session_profile_test.go
Normal file
105
backend/internal/handler/auth_handler_session_profile_test.go
Normal file
@@ -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"])
|
||||||
|
}
|
||||||
@@ -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 문구를 유지합니다.
|
||||||
@@ -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<String, dynamic>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
import '../domain/session_time_resolver.dart';
|
||||||
import '../domain/providers/linked_rps_provider.dart';
|
import '../domain/providers/linked_rps_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';
|
||||||
@@ -404,32 +405,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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<String, dynamic>;
|
|
||||||
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) {
|
String _formatDateTime(DateTime dateTime) {
|
||||||
final yyyy = dateTime.year.toString().padLeft(4, '0');
|
final yyyy = dateTime.year.toString().padLeft(4, '0');
|
||||||
final mm = dateTime.month.toString().padLeft(2, '0');
|
final mm = dateTime.month.toString().padLeft(2, '0');
|
||||||
@@ -716,7 +691,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
final department = departmentValue.isNotEmpty
|
final department = departmentValue.isNotEmpty
|
||||||
? departmentValue
|
? departmentValue
|
||||||
: tr('ui.userfront.profile.department_empty');
|
: tr('ui.userfront.profile.department_empty');
|
||||||
final sessionIssuedAt = _getJwtIssuedAt();
|
final sessionIssuedAt = resolveDashboardSessionIssuedAt(
|
||||||
|
token: AuthTokenStore.getToken(),
|
||||||
|
profile: profile,
|
||||||
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: _subtle,
|
backgroundColor: _subtle,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class UserProfile {
|
|||||||
final String department;
|
final String department;
|
||||||
final String affiliationType;
|
final String affiliationType;
|
||||||
final String companyCode;
|
final String companyCode;
|
||||||
|
final String? sessionAuthenticatedAt;
|
||||||
final Map<String, dynamic>? metadata;
|
final Map<String, dynamic>? metadata;
|
||||||
final Tenant? tenant;
|
final Tenant? tenant;
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ class UserProfile {
|
|||||||
required this.department,
|
required this.department,
|
||||||
required this.affiliationType,
|
required this.affiliationType,
|
||||||
required this.companyCode,
|
required this.companyCode,
|
||||||
|
this.sessionAuthenticatedAt,
|
||||||
this.metadata,
|
this.metadata,
|
||||||
this.tenant,
|
this.tenant,
|
||||||
});
|
});
|
||||||
@@ -57,6 +59,7 @@ class UserProfile {
|
|||||||
department: json['department'] ?? '',
|
department: json['department'] ?? '',
|
||||||
affiliationType: json['affiliationType'] ?? '',
|
affiliationType: json['affiliationType'] ?? '',
|
||||||
companyCode: json['companyCode'] ?? '',
|
companyCode: json['companyCode'] ?? '',
|
||||||
|
sessionAuthenticatedAt: json['sessionAuthenticatedAt'] as String?,
|
||||||
metadata: json['metadata'] != null
|
metadata: json['metadata'] != null
|
||||||
? Map<String, dynamic>.from(json['metadata'])
|
? Map<String, dynamic>.from(json['metadata'])
|
||||||
: null,
|
: null,
|
||||||
@@ -73,6 +76,7 @@ class UserProfile {
|
|||||||
'department': department,
|
'department': department,
|
||||||
'affiliationType': affiliationType,
|
'affiliationType': affiliationType,
|
||||||
'companyCode': companyCode,
|
'companyCode': companyCode,
|
||||||
|
'sessionAuthenticatedAt': sessionAuthenticatedAt,
|
||||||
'metadata': metadata,
|
'metadata': metadata,
|
||||||
'tenant': tenant?.toJson(),
|
'tenant': tenant?.toJson(),
|
||||||
};
|
};
|
||||||
@@ -87,6 +91,7 @@ class UserProfile {
|
|||||||
department: department ?? this.department,
|
department: department ?? this.department,
|
||||||
affiliationType: affiliationType,
|
affiliationType: affiliationType,
|
||||||
companyCode: companyCode,
|
companyCode: companyCode,
|
||||||
|
sessionAuthenticatedAt: sessionAuthenticatedAt,
|
||||||
tenant: tenant,
|
tenant: tenant,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
35
userfront/test/dashboard_session_time_resolver_test.dart
Normal file
35
userfront/test/dashboard_session_time_resolver_test.dart
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user