1
0
forked from baron/baron-sso

Merge pull request 'feature/uf-enhance' (#447) from feature/uf-enhance into dev

Reviewed-on: baron/baron-sso#447
This commit is contained in:
2026-03-24 16:11:29 +09:00
13 changed files with 573 additions and 63 deletions

View File

@@ -1101,6 +1101,9 @@ components:
type: string
phone:
type: string
sessionAuthenticatedAt:
type: string
format: date-time
department:
type: string
affiliationType:

View File

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

View File

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

View 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"])
}

View File

@@ -142,6 +142,10 @@ type clientUpsertRequest struct {
Metadata *map[string]interface{} `json:"metadata"`
}
var protectedSystemClientIDs = map[string]struct{}{
"oathkeeper-introspect": {},
}
func normalizeUserRole(role string) string {
return domain.NormalizeRole(role)
}
@@ -263,6 +267,15 @@ func profileRole(profile *domain.UserProfileResponse) string {
return strings.TrimSpace(profile.Role)
}
func isProtectedSystemClientID(clientID string) bool {
_, ok := protectedSystemClientIDs[strings.TrimSpace(clientID)]
return ok
}
func isProtectedSystemClient(client domain.HydraClient) bool {
return isProtectedSystemClientID(client.ClientID)
}
func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
if (!ok || profile == nil) && h.Auth != nil {
@@ -557,6 +570,10 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
items := make([]clientSummary, 0, len(clients))
for _, client := range clients {
if isProtectedSystemClient(client) {
continue
}
summary := h.mapClientSummary(client)
// 1. [Security] Filter out 'private' clients if user is not an AppManager
@@ -604,6 +621,10 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isProtectedSystemClient(*client) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
summary := h.mapClientSummary(*client)
profile := h.getCurrentProfile(c)
if profile == nil {
@@ -678,6 +699,10 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isProtectedSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
summary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c)
if profile == nil {
@@ -759,6 +784,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
if clientID == "" {
clientID = uuid.NewString()
}
if isProtectedSystemClientID(clientID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: reserved system client id")
}
name := strings.TrimSpace(valueOr(req.Name, ""))
if name == "" {
@@ -899,6 +927,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isProtectedSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
currentSummary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c)
if profile == nil {
@@ -1030,6 +1062,10 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isProtectedSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
summary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c)
if profile == nil {
@@ -1265,6 +1301,10 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isProtectedSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
summary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c)
if profile == nil {
@@ -1462,6 +1502,9 @@ func (h *DevHandler) GetStats(c *fiber.Ctx) error {
var totalClients int64
if err == nil {
for _, client := range clients {
if isProtectedSystemClient(client) {
continue
}
if isSuperAdmin {
totalClients++
continue

View File

@@ -124,6 +124,44 @@ func TestListClients_Success(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
{"client_id": "oathkeeper-introspect", "client_name": "Internal Client"},
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}},
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member").Return(true, nil)
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: mockKeto,
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/api/v1/dev/clients", h.ListClients)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var res clientListResponse
_ = json.NewDecoder(resp.Body).Decode(&res)
assert.Len(t, res.Items, 1)
assert.Equal(t, "client-1", res.Items[0].ID)
}
func TestUpdateClientStatus_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
@@ -164,6 +202,38 @@ func TestUpdateClientStatus_Success(t *testing.T) {
assert.Equal(t, "inactive", res.Client.Status)
}
func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"client_id": "oathkeeper-introspect",
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: new(devMockKetoService),
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"})
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/oathkeeper-introspect/status", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}
func TestDeleteClient_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
@@ -204,6 +274,67 @@ func TestDeleteClient_Success(t *testing.T) {
assert.Error(t, err)
}
func TestDeleteClient_ProtectedSystemClientForbidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{"client_id": "oathkeeper-introspect"}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
SecretRepo: &mockSecretRepo{secrets: map[string]string{"oathkeeper-introspect": "secret"}},
Redis: &devMockRedisRepo{data: map[string]string{"client_secret:oathkeeper-introspect": "secret"}},
Keto: new(devMockKetoService),
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Delete("/api/v1/dev/clients/:id", h.DeleteClient)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/oathkeeper-introspect", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}
func TestGetClient_ProtectedSystemClientHidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"client_id": "oathkeeper-introspect",
"client_name": "Internal Client",
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: new(devMockKetoService),
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/api/v1/dev/clients/:id", h.GetClient)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/oathkeeper-introspect", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestRotateClientSecret_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
@@ -254,6 +385,7 @@ func TestGetStats_Success(t *testing.T) {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
{"client_id": "c1", "metadata": map[string]interface{}{"tenant_id": "t1"}},
{"client_id": "c2", "metadata": map[string]interface{}{"tenant_id": "t1"}},
{"client_id": "oathkeeper-introspect", "metadata": map[string]interface{}{"tenant_id": "t1"}},
{"client_id": "c3", "metadata": map[string]interface{}{"tenant_id": "t2"}},
}), nil
}

View File

@@ -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 문구를 유지합니다.

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:qr_flutter/qr_flutter.dart';
@@ -43,8 +44,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final TextEditingController _passwordLoginIdController =
TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final FocusNode _passwordFocusNode = FocusNode();
String? _redirectUrl;
String? _loginChallenge;
bool _isPasswordCapsLockOn = false;
// QR Login Variables
String? _qrImageBase64;
@@ -93,6 +96,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
!AuthProxyService.isProdEnv;
_redirectUrl = widget.redirectUrl;
_passwordFocusNode.addListener(_handlePasswordFocusChange);
HardwareKeyboard.instance.addHandler(_handleHardwareKeyEvent);
WidgetsBinding.instance.addPostFrameCallback((_) async {
final uri = Uri.base;
@@ -154,6 +159,40 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
});
}
void _handlePasswordFocusChange() {
if (!mounted) {
return;
}
if (_passwordFocusNode.hasFocus) {
_syncPasswordCapsLockState();
return;
}
if (_isPasswordCapsLockOn) {
setState(() {
_isPasswordCapsLockOn = false;
});
}
}
bool _handleHardwareKeyEvent(KeyEvent event) {
if (_passwordFocusNode.hasFocus) {
_syncPasswordCapsLockState();
}
return false;
}
void _syncPasswordCapsLockState() {
final isEnabled = HardwareKeyboard.instance.lockModesEnabled.contains(
KeyboardLockMode.capsLock,
);
if (!mounted || isEnabled == _isPasswordCapsLockOn) {
return;
}
setState(() {
_isPasswordCapsLockOn = isEnabled;
});
}
Future<void> _tryCookieSession({bool silent = true}) async {
final loginChallenge = _loginChallenge;
final token = AuthTokenStore.getToken();
@@ -936,6 +975,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_linkIdController.dispose();
_passwordLoginIdController.dispose();
_passwordController.dispose();
_passwordFocusNode
..removeListener(_handlePasswordFocusChange)
..dispose();
HardwareKeyboard.instance.removeHandler(_handleHardwareKeyEvent);
_shortCodePrefixController.dispose();
_shortCodeDigitsController.dispose();
_linkResendTimer?.cancel();
@@ -1299,6 +1342,24 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
);
}
String _capsLockWarningText(BuildContext context) {
const key = 'msg.userfront.login.password.caps_lock_on';
final languageCode = Localizations.localeOf(context).languageCode;
if (languageCode == 'ko') {
final translated = tr(key);
if (translated != key) {
return translated;
}
return 'Caps Lock이 켜져 있습니다.';
}
final translated = tr(key, fallback: 'Caps Lock is on.');
if (translated != key) {
return translated;
}
return 'Caps Lock is on.';
}
@override
Widget build(BuildContext context) {
if (_verificationOnly && _verificationApproved) {
@@ -1410,6 +1471,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
key: const ValueKey(
'password_login_password_input',
),
focusNode: _passwordFocusNode,
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
@@ -1423,6 +1485,29 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
onSubmitted: (_) => _handlePasswordLogin(),
),
if (_isPasswordCapsLockOn) ...[
const SizedBox(height: 8),
Row(
children: [
const Icon(
Icons.keyboard_capslock_rounded,
size: 18,
color: Colors.orange,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_capsLockWarningText(context),
style: const TextStyle(
color: Colors.orange,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
const SizedBox(height: 24),
FilledButton(
key: const ValueKey(

View File

@@ -316,9 +316,7 @@ class _SignupScreenState extends State<SignupScreen> {
phone: _phoneController.text.trim(),
affiliationType: _affiliationType,
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
department: _deptController.text.trim().isEmpty
? (_affiliationType == 'GENERAL' ? 'External' : '')
: _deptController.text.trim(),
department: _deptController.text.trim(),
termsAccepted: true,
);
if (mounted) _showSuccessDialog();

View File

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

View File

@@ -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<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) {
final yyyy = dateTime.year.toString().padLeft(4, '0');
final mm = dateTime.month.toString().padLeft(2, '0');
@@ -712,11 +687,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
profile?.email ??
profile?.phone ??
tr('ui.userfront.profile.user_fallback', fallback: 'User');
final departmentValue = profile?.department ?? '';
final departmentValue =
profile?.tenant?.name ?? profile?.department ?? '';
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,
@@ -1462,9 +1441,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
double _historySessionColumnWidth(double maxWidth) {
return math.max(
_historySessionMinWidth,
maxWidth - _historyOtherColumnsBaselineWidth,
return math.min(
200.0,
math.max(
_historySessionMinWidth,
maxWidth - _historyOtherColumnsBaselineWidth,
),
);
}

View File

@@ -33,6 +33,7 @@ class UserProfile {
final String department;
final String affiliationType;
final String companyCode;
final String? sessionAuthenticatedAt;
final Map<String, dynamic>? 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<String, dynamic>.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,
);
}

View 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');
});
}