From 2bdfc2eb51f7865d68b41d0e79df33b3f6310b77 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Mon, 23 Feb 2026 22:06:00 +0900 Subject: [PATCH] =?UTF-8?q?userfront=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=9B=84=20/dashboard=EB=A1=9C=20=EC=9D=B4=EB=8F=99=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 + backend/internal/handler/auth_handler.go | 51 ++- backend/internal/handler/auth_handler_test.go | 180 +++++++++++ backend/internal/handler/common_test.go | 6 + backend/internal/logger/audit_logger.go | 68 ++-- backend/internal/logger/audit_logger_test.go | 80 +++++ docs/AGENTS.md | 8 + docs/test-plan/backend-test-inventory.md | 10 +- docs/test-plan/userfront-test-inventory.md | 9 +- .../issue-277-null-check-dashboard-routing.md | 76 +++++ scripts/map_wasm_stack.py | 240 ++++++++++++++ userfront/lib/core/i18n/locale_gate.dart | 44 ++- .../lib/core/i18n/locale_storage_engine.dart | 9 +- userfront/lib/core/i18n/locale_utils.dart | 20 +- .../core/services/null_check_recovery.dart | 26 ++ .../services/web_auth_integration_web.dart | 6 +- .../lib/core/widgets/language_selector.dart | 19 +- .../presentation/create_user_screen.dart | 7 +- .../presentation/user_management_screen.dart | 7 +- .../auth/domain/cookie_session_policy.dart | 15 + .../auth/presentation/approve_qr_screen.dart | 18 +- .../auth/presentation/consent_screen.dart | 3 +- .../auth/presentation/error_screen.dart | 4 +- .../auth/presentation/login_screen.dart | 85 +++-- .../presentation/login_success_screen.dart | 3 +- .../presentation/reset_password_screen.dart | 3 +- .../auth/presentation/signup_screen.dart | 3 +- .../dashboard/domain/dashboard_providers.dart | 3 +- .../presentation/dashboard_screen.dart | 303 +++++++++++------- .../presentation/pages/profile_page.dart | 5 +- userfront/lib/main.dart | 81 ++++- .../test/cookie_session_policy_test.dart | 40 +++ .../test/dashboard_screen_smoke_test.dart | 50 +++ userfront/test/locale_utils_test.dart | 27 ++ .../test/login_navigation_race_test.dart | 94 ++++++ userfront/test/null_check_recovery_test.dart | 63 ++++ .../test/router_redirect_widget_test.dart | 53 ++- 37 files changed, 1504 insertions(+), 222 deletions(-) create mode 100644 backend/internal/logger/audit_logger_test.go create mode 100644 docs/trouble-shooting/issue-277-null-check-dashboard-routing.md create mode 100644 scripts/map_wasm_stack.py create mode 100644 userfront/lib/core/services/null_check_recovery.dart create mode 100644 userfront/lib/features/auth/domain/cookie_session_policy.dart create mode 100644 userfront/test/cookie_session_policy_test.dart create mode 100644 userfront/test/dashboard_screen_smoke_test.dart create mode 100644 userfront/test/login_navigation_race_test.dart create mode 100644 userfront/test/null_check_recovery_test.dart diff --git a/README.md b/README.md index eb6aa65f..7bac9d2c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ **Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다. +## 버그 대응 대원칙 (필수) +- 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first) +- 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다. +- 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다. +- “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다. +- 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다. + * Ory Stack으로 모든 구성요소를 self-hosting 합니다. * Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다. * Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다. diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index ebdf3f68..ec73b256 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -1566,7 +1566,6 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { loginID := strings.TrimSpace(req.LoginID) ale.LoginIDs["loginId"] = req.LoginID // 원문 ale.LoginIDs["loginId_normalized"] = loginID - ale.NewPassword = req.Password // For test only, logging password (sensitive) ale.Log(slog.LevelInfo, "Attempting to login") @@ -1602,7 +1601,6 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { ale.Status = fiber.StatusOK ale.LatencyMs = time.Since(startTime) - ale.SessionJwt = authInfo.SessionToken.JWT setSessionIDLocal(c, authInfo.SessionToken) ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject)) @@ -1854,11 +1852,23 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error { ale.LoginIDs["loginId"] = loginID ale.LoginIDs["loginId_normalized"] = loginID - redirectURL := fmt.Sprintf("%s/reset-password?loginId=%s&token=%s", - os.Getenv("USERFRONT_URL"), - loginID, - token, - ) + userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/") + if userfrontURL == "" { + userfrontURL = "https://sso.hmac.kr" + } + redirectBase, parseErr := url.Parse(userfrontURL + "/reset-password") + if parseErr != nil { + ale.Status = fiber.StatusInternalServerError + ale.LatencyMs = time.Since(startTime) + ale.ProviderError = parseErr.Error() + ale.Log(slog.LevelError, "Failed to compose reset redirect URL") + return c.Status(fiber.StatusInternalServerError).SendString("Failed to compose redirect URL") + } + query := redirectBase.Query() + query.Set("loginId", loginID) + query.Set("token", token) + redirectBase.RawQuery = query.Encode() + redirectURL := redirectBase.String() ale.RedirectTo = redirectURL ale.Status = fiber.StatusFound @@ -1892,22 +1902,29 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { } // loginID는 URL 쿼리 파라미터 또는 토큰 조회로 받습니다. - loginID := c.Query("loginId") - resetToken := c.Query("token") - if loginID == "" && resetToken != "" { - if val, err := h.RedisService.Get(prefixPwdResetToken + resetToken); err == nil && val != "" { - loginID = val + loginID := strings.TrimSpace(c.Query("loginId")) + resetToken := strings.TrimSpace(c.Query("token")) + if resetToken != "" { + val, err := h.RedisService.Get(prefixPwdResetToken + resetToken) + if err != nil || strings.TrimSpace(val) == "" { + ale.Status = fiber.StatusUnauthorized + ale.LatencyMs = time.Since(startTime) + ale.ProviderError = "Invalid or expired reset token" ale.Token = resetToken + ale.Log(slog.LevelWarn, "Reset token invalid or expired") + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired reset token"}) } + loginID = strings.TrimSpace(val) + ale.Token = resetToken + } + if loginID != "" && !strings.Contains(loginID, "@") { + loginID = normalizePhoneForLoginID(loginID) } ale.LoginIDs["loginId"] = loginID - ale.RequestBody = fmt.Sprintf("{\"newPassword\": \"%s\"}", req.NewPassword) // Log request body (for test only) - ale.NewPassword = req.NewPassword // Log new password (for test only) - // Request cookie logging (minimal) + // 요청 쿠키는 원문을 기록하지 않고 존재 여부만 기록합니다. if cookieHeader := c.Get(fiber.HeaderCookie); cookieHeader != "" { - ale.Headers["Request-Cookie-Header"] = cookieHeader if dsrfCookie := c.Cookies("DSRF"); dsrfCookie != "" { ale.ParsedCookieDSRF = dsrfCookie ale.HasCookieDSRF = true @@ -1924,7 +1941,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login ID and new password are required"}) } - // 디버깅을 위해 요청된 새 비밀번호를 로그로 출력 + // 새 비밀번호 값은 기록하지 않고, 요청 수신 이벤트만 남깁니다. ale.Log(slog.LevelInfo, "Received new password for reset") policy := h.resolvePasswordPolicy() diff --git a/backend/internal/handler/auth_handler_test.go b/backend/internal/handler/auth_handler_test.go index d9fa1227..e92f19fc 100644 --- a/backend/internal/handler/auth_handler_test.go +++ b/backend/internal/handler/auth_handler_test.go @@ -3,9 +3,11 @@ package handler import ( "bytes" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" + "time" "github.com/gofiber/fiber/v2" ) @@ -17,6 +19,51 @@ func newTestApp(h *AuthHandler) *fiber.App { return app } +func newResetFlowTestApp(h *AuthHandler) *fiber.App { + app := fiber.New() + app.Post("/api/v1/auth/password/reset/verify", h.ProcessPasswordResetToken) + app.Post("/api/v1/auth/password/reset/complete", h.CompletePasswordReset) + return app +} + +type testRedisRepo struct { + values map[string]string +} + +func (m *testRedisRepo) Set(key string, value string, expiration time.Duration) error { + if m.values == nil { + m.values = map[string]string{} + } + m.values[key] = value + return nil +} + +func (m *testRedisRepo) Get(key string) (string, error) { + if m.values == nil { + return "", nil + } + return m.values[key], nil +} + +func (m *testRedisRepo) Delete(key string) error { + if m.values != nil { + delete(m.values, key) + } + return nil +} + +func (m *testRedisRepo) StoreVerificationCode(phone, code string) error { + return m.Set("sms:"+phone, code, time.Minute) +} + +func (m *testRedisRepo) GetVerificationCode(phone string) (string, error) { + return m.Get("sms:" + phone) +} + +func (m *testRedisRepo) DeleteVerificationCode(phone string) error { + return m.Delete("sms:" + phone) +} + func TestCompletePasswordReset_MissingLoginID(t *testing.T) { h := &AuthHandler{} app := newTestApp(h) @@ -106,3 +153,136 @@ func TestCompletePasswordReset_NilIDPProvider(t *testing.T) { t.Fatalf("unexpected error message: %v", got["error"]) } } + +func TestCompletePasswordReset_TokenValueOverridesLoginIDQuery(t *testing.T) { + const resetToken = "tok-reset-1" + const tokenLoginID = "user@example.com" + const wrongLoginID = "wrong@example.com" + const newPassword = "StrongPass1!" + + redis := &testRedisRepo{ + values: map[string]string{ + prefixPwdResetToken + resetToken: tokenLoginID, + }, + } + idp := &mockIdpProvider{ + userExists: true, + err: nil, + } + h := &AuthHandler{ + RedisService: redis, + IdpProvider: idp, + } + app := newResetFlowTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "newPassword": newPassword, + }) + url := fmt.Sprintf( + "/api/v1/auth/password/reset/complete?loginId=%s&token=%s", + wrongLoginID, + resetToken, + ) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if !idp.updateCalled { + t.Fatal("expected UpdateUserPassword to be called") + } + if idp.updatedLoginID != tokenLoginID { + t.Fatalf("expected loginId from token(%s), got %s", tokenLoginID, idp.updatedLoginID) + } + if idp.updatedPassword != newPassword { + t.Fatalf("expected newPassword propagated, got %s", idp.updatedPassword) + } +} + +func TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists(t *testing.T) { + const resetToken = "invalid-token" + + redis := &testRedisRepo{ + values: map[string]string{}, + } + idp := &mockIdpProvider{ + userExists: true, + err: nil, + } + h := &AuthHandler{ + RedisService: redis, + IdpProvider: idp, + } + app := newResetFlowTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "newPassword": "StrongPass1!", + }) + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/password/reset/complete?loginId=user@example.com&token="+resetToken, + bytes.NewReader(body), + ) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 for invalid token, got %d", resp.StatusCode) + } + if idp.updateCalled { + t.Fatal("UpdateUserPassword must not be called when token is invalid") + } +} + +func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) { + const token = "tok-enc" + const loginID = "user+alias@example.com" + + t.Setenv("USERFRONT_URL", "https://sss.hmac.kr") + + redis := &testRedisRepo{ + values: map[string]string{ + prefixPwdResetToken + token: loginID, + }, + } + h := &AuthHandler{ + RedisService: redis, + } + app := newResetFlowTestApp(h) + + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/password/reset/verify?token="+token, + nil, + ) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusFound { + t.Fatalf("expected 302, got %d", resp.StatusCode) + } + location := resp.Header.Get("Location") + if location == "" { + t.Fatal("missing redirect location") + } + redirectReq := httptest.NewRequest(http.MethodGet, location, nil) + gotLoginID := redirectReq.URL.Query().Get("loginId") + if gotLoginID != loginID { + t.Fatalf("expected encoded loginId round-trip=%s, got %s (location=%s)", loginID, gotLoginID, location) + } +} diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index 80c7aff1..1b862c84 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -19,6 +19,9 @@ type mockIdpProvider struct { verifyCodeInfo *domain.AuthInfo err error initiateLinkErr error + updateCalled bool + updatedLoginID string + updatedPassword string } func (m *mockIdpProvider) Name() string { @@ -63,6 +66,9 @@ func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthIn } func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { + m.updateCalled = true + m.updatedLoginID = loginID + m.updatedPassword = newPassword return m.err } diff --git a/backend/internal/logger/audit_logger.go b/backend/internal/logger/audit_logger.go index e82d1017..b52f7d92 100644 --- a/backend/internal/logger/audit_logger.go +++ b/backend/internal/logger/audit_logger.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strings" "time" "github.com/gofiber/fiber/v2" @@ -24,7 +25,7 @@ type AuditLogEntry struct { Origin string Referer string Query map[string]string - Headers map[string]string // Core headers like Host, Cookie, Set-Cookie + Headers map[string]string // 핵심 헤더(민감 키는 마스킹됨) LoginIDs map[string]string // loginId and loginId_normalized Token string // For reset tokens, magic link tokens ProviderError string @@ -43,8 +44,6 @@ type AuditLogEntry struct { RedirectTo string HasCookieDSRF bool ParsedCookieDSRF string - RequestBody string // For complete stage - NewPassword string // For complete stage (test only, sensitive) // ... potentially more fields specific to different stages } @@ -55,16 +54,14 @@ func NewAuditLogEntry(c *fiber.Ctx, stage string) *AuditLogEntry { // Extract query parameters queryParams := make(map[string]string) c.Context().QueryArgs().VisitAll(func(key, value []byte) { - queryParams[string(key)] = string(value) + k := string(key) + queryParams[k] = maskSensitiveByKey(k, string(value)) }) // Extract relevant headers headers := make(map[string]string) headers["Host"] = c.Get("Host") headers["User-Agent"] = c.Get("User-Agent") - if cookie := c.Get("Cookie"); cookie != "" { - headers["Cookie"] = cookie - } headers["Origin"] = c.Get("Origin") headers["Referer"] = c.Get("Referer") @@ -122,14 +119,14 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) { if len(ale.Query) > 0 { queryGroupArgs := make([]any, 0, len(ale.Query)) for k, v := range ale.Query { - queryGroupArgs = append(queryGroupArgs, slog.String(k, v)) + queryGroupArgs = append(queryGroupArgs, slog.String(k, maskSensitiveByKey(k, v))) } attrs = append(attrs, slog.Group("query", queryGroupArgs...)) } if len(ale.Headers) > 0 { headersGroupArgs := make([]any, 0, len(ale.Headers)) for k, v := range ale.Headers { - headersGroupArgs = append(headersGroupArgs, slog.String(k, v)) + headersGroupArgs = append(headersGroupArgs, slog.String(k, maskSensitiveByKey(k, v))) } attrs = append(attrs, slog.Group("headers", headersGroupArgs...)) } @@ -141,7 +138,7 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) { attrs = append(attrs, slog.Group("login_ids", loginIDGroupArgs...)) } if ale.Token != "" { - attrs = append(attrs, slog.String("token", ale.Token)) + attrs = append(attrs, slog.Bool("has_token", true)) } if ale.ProviderError != "" { attrs = append(attrs, slog.String("provider_error", ale.ProviderError)) @@ -153,13 +150,13 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) { attrs = append(attrs, slog.String("provider_response_body", ale.ProviderBody)) } if ale.RefreshToken != "" { - attrs = append(attrs, slog.String("refresh_token", ale.RefreshToken)) + attrs = append(attrs, slog.Bool("has_refresh_token", true)) } if ale.SessionJwt != "" { - attrs = append(attrs, slog.String("session_jwt", ale.SessionJwt)) + attrs = append(attrs, slog.Bool("has_session_jwt", true)) } if ale.AccessJwt != "" { - attrs = append(attrs, slog.String("access_jwt", ale.AccessJwt)) + attrs = append(attrs, slog.Bool("has_access_jwt", true)) } if ale.UserLoginId != "" { attrs = append(attrs, slog.String("user_login_id", ale.UserLoginId)) @@ -175,7 +172,9 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) { } if ale.SetCookieName != "" { attrs = append(attrs, slog.String("set_cookie_name", ale.SetCookieName)) - attrs = append(attrs, slog.String("set_cookie_value", ale.SetCookieValue)) + if ale.SetCookieValue != "" { + attrs = append(attrs, slog.Bool("has_set_cookie_value", true)) + } if len(ale.SetCookieAttrs) > 0 { cookieAttrsGroupArgs := make([]any, 0, len(ale.SetCookieAttrs)) for k, v := range ale.SetCookieAttrs { @@ -191,13 +190,7 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) { attrs = append(attrs, slog.Bool("has_cookie_DSRF", ale.HasCookieDSRF)) } if ale.ParsedCookieDSRF != "" { - attrs = append(attrs, slog.String("parsed_cookie_DSRF", ale.ParsedCookieDSRF)) - } - if ale.RequestBody != "" { - attrs = append(attrs, slog.String("request_body", ale.RequestBody)) - } - if ale.NewPassword != "" { // FOR TEST ONLY - DO NOT LOG IN PRODUCTION - attrs = append(attrs, slog.String("new_password", ale.NewPassword)) + attrs = append(attrs, slog.Bool("has_parsed_cookie_DSRF", true)) } // Convert variadic args to slog.Attr before appending @@ -212,3 +205,36 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) { slog.Default().LogAttrs(context.Background(), level, msg, attrs...) } + +var sensitiveAuditKeys = map[string]struct{}{ + "password": {}, + "currentpassword": {}, + "newpassword": {}, + "oldpassword": {}, + "token": {}, + "accesstoken": {}, + "refreshtoken": {}, + "authorization": {}, + "cookie": {}, + "setcookie": {}, + "verificationcode": {}, + "code": {}, + "loginchallenge": {}, + "loginverifier": {}, + "sessionjwt": {}, + "accessjwt": {}, + "refreshjwt": {}, +} + +func maskSensitiveByKey(key, value string) string { + if value == "" { + return value + } + k := strings.ToLower(key) + k = strings.ReplaceAll(k, "-", "") + k = strings.ReplaceAll(k, "_", "") + if _, ok := sensitiveAuditKeys[k]; ok { + return "*****" + } + return value +} diff --git a/backend/internal/logger/audit_logger_test.go b/backend/internal/logger/audit_logger_test.go new file mode 100644 index 00000000..ab8c7f8a --- /dev/null +++ b/backend/internal/logger/audit_logger_test.go @@ -0,0 +1,80 @@ +package logger + +import ( + "bytes" + "encoding/json" + "log/slog" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuditLogEntry_RedactsSensitiveFields(t *testing.T) { + buf := &bytes.Buffer{} + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(buf, nil))) + defer slog.SetDefault(previous) + + ale := &AuditLogEntry{ + RequestID: "req-1", + Stage: "login", + Token: "tok-secret", + RefreshToken: "refresh-secret", + SessionJwt: "session-secret", + AccessJwt: "access-secret", + SetCookieName: "sid", + SetCookieValue: "cookie-secret", + ParsedCookieDSRF: "dsrf-secret", + LoginIDs: map[string]string{ + "loginId": "user@example.com", + }, + Query: map[string]string{ + "token": "query-token", + "locale": "ko", + }, + Headers: map[string]string{ + "Authorization": "Bearer secret", + "Cookie": "session=secret", + }, + } + + ale.Log(slog.LevelInfo, "test") + + line := strings.TrimSpace(buf.String()) + require.NotEmpty(t, line) + + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &payload)) + + assert.NotContains(t, payload, "token") + assert.NotContains(t, payload, "refresh_token") + assert.NotContains(t, payload, "session_jwt") + assert.NotContains(t, payload, "access_jwt") + assert.NotContains(t, payload, "set_cookie_value") + assert.NotContains(t, payload, "parsed_cookie_DSRF") + assert.NotContains(t, payload, "request_body") + assert.NotContains(t, payload, "new_password") + + assert.Equal(t, true, payload["has_token"]) + assert.Equal(t, true, payload["has_refresh_token"]) + assert.Equal(t, true, payload["has_session_jwt"]) + assert.Equal(t, true, payload["has_access_jwt"]) + assert.Equal(t, true, payload["has_set_cookie_value"]) + assert.Equal(t, true, payload["has_parsed_cookie_DSRF"]) + + loginIDs, ok := payload["login_ids"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "user@example.com", loginIDs["loginId"]) + + query, ok := payload["query"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "*****", query["token"]) + assert.Equal(t, "ko", query["locale"]) + + headers, ok := payload["headers"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "*****", headers["Authorization"]) + assert.Equal(t, "*****", headers["Cookie"]) +} diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 227b1214..650b3219 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -1,5 +1,13 @@ # AGENTS 가이드 (Baron SSO) +## 버그 수정 절차 대원칙 (강제) +- 버그 대응 시 **재현 테스트를 먼저 작성**합니다. +- 재현 테스트가 실패하는 상태를 확인한 뒤에만 수정 작업을 시작합니다. +- 수정 후에는 테스트를 반복 실행하여 재현 테스트가 안정적으로 통과할 때까지 계속 보완합니다. +- 재현 테스트 없이 “감으로 수정”하거나, 실패 테스트를 남긴 채 성공으로 보고하지 않습니다. +- 이슈 종료 전에는 최소 1회 이상 실제 사용자 경로(예: 로그인/새로고침/리다이렉트)를 확인합니다. +- 테스트/원인/조치 내역은 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)에 반영합니다. + ## 목적 - 인증/인가 허브로서 **Backend + Ory Stack** 중심 아키텍처를 유지 - 사용자 플로우(UserFront)와 관리 플로우(Admin/DevFront)를 명확히 분리 diff --git a/docs/test-plan/backend-test-inventory.md b/docs/test-plan/backend-test-inventory.md index 6daa3b97..92cdb9d0 100644 --- a/docs/test-plan/backend-test-inventory.md +++ b/docs/test-plan/backend-test-inventory.md @@ -31,9 +31,12 @@ | `backend/internal/handler/auth_handler_qr_test.go:107` | `TestScanQRLogin_Success` | 인증/OIDC 플로우 검증 | | `backend/internal/handler/auth_handler_qr_test.go:150` | `TestResolveConsentSubjects_TokenAndCookie` | 인증/OIDC 플로우 검증 | | `backend/internal/handler/auth_handler_qr_test.go:57` | `TestQRLoginFlow_Success` | 인증/OIDC 플로우 검증 | -| `backend/internal/handler/auth_handler_test.go:20` | `TestCompletePasswordReset_MissingLoginID` | 오류/예외/거부 경로 검증 | -| `backend/internal/handler/auth_handler_test.go:50` | `TestCompletePasswordReset_InvalidPasswordPolicy` | 오류/예외/거부 경로 검증 | -| `backend/internal/handler/auth_handler_test.go:80` | `TestCompletePasswordReset_NilIDPProvider` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/auth_handler_test.go:67` | `TestCompletePasswordReset_MissingLoginID` | 오류/예외/거부 경로 검증 | +| `backend/internal/handler/auth_handler_test.go:97` | `TestCompletePasswordReset_InvalidPasswordPolicy` | 오류/예외/거부 경로 검증 | +| `backend/internal/handler/auth_handler_test.go:127` | `TestCompletePasswordReset_NilIDPProvider` | 오류/예외/거부 경로 검증 | +| `backend/internal/handler/auth_handler_test.go:157` | `TestCompletePasswordReset_TokenValueOverridesLoginIDQuery` | 비밀번호 재설정 토큰 우선 규칙 검증 | +| `backend/internal/handler/auth_handler_test.go:209` | `TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists` | 오류/예외/거부 경로 검증 | +| `backend/internal/handler/auth_handler_test.go:249` | `TestProcessPasswordResetToken_EncodesLoginIDInRedirect` | 리다이렉트/쿼리 보존 규칙 검증 | | `backend/internal/handler/dev_handler_test.go:103` | `TestCreateClient_Success` | Hydra/RP 연동 검증 | | `backend/internal/handler/dev_handler_test.go:15` | `TestListClients_Success` | Hydra/RP 연동 검증 | | `backend/internal/handler/dev_handler_test.go:49` | `TestGetClient_Success` | Hydra/RP 연동 검증 | @@ -48,6 +51,7 @@ | `backend/internal/idp/factory_test.go:123` | `TestChainedProviderMetadataUnion` | 회귀 방지 기본 동작 검증 | | `backend/internal/idp/factory_test.go:139` | `TestChainedProviderUpdateUserPasswordFallback` | 복구/격리/회복 탄력성 검증 | | `backend/internal/idp/factory_test.go:152` | `TestChainedProviderUpdateUserPasswordAllFail` | 인증/OIDC 플로우 검증 | +| `backend/internal/logger/audit_logger_test.go:14` | `TestAuditLogEntry_RedactsSensitiveFields` | 감사 로그 민감정보 마스킹/비노출 검증 | | `backend/internal/middleware/audit_middleware_test.go:42` | `TestAuditMiddleware` | 회귀 방지 기본 동작 검증 | | `backend/internal/middleware/error_code_enricher_test.go:22` | `TestErrorCodeEnricher_AddsCodeToLegacyErrorResponse` | 오류/예외/거부 경로 검증 | | `backend/internal/middleware/error_code_enricher_test.go:50` | `TestErrorCodeEnricher_DoesNotOverrideExistingCode` | 오류/예외/거부 경로 검증 | diff --git a/docs/test-plan/userfront-test-inventory.md b/docs/test-plan/userfront-test-inventory.md index 217ab751..8d2ca68e 100644 --- a/docs/test-plan/userfront-test-inventory.md +++ b/docs/test-plan/userfront-test-inventory.md @@ -43,15 +43,22 @@ | `userfront/test/login_challenge_resolver_test.dart` | `widget 값이 없으면 URI query에서 복구` | fallback/복구 경로 검증 | | `userfront/test/login_challenge_resolver_test.dart` | `widget 값이 있으면 최우선으로 사용` | 핵심 동작 회귀 방지 검증 | | `userfront/test/login_challenge_resolver_test.dart` | `값이 전부 없으면 missing` | fallback/복구 경로 검증 | +| `userfront/test/null_check_recovery_test.dart` | `Null check 오류 + 루트(/)면 선호 로케일 signin으로 복구` | Null-check 예외 복구 경로 검증 | +| `userfront/test/null_check_recovery_test.dart` | `Null check 오류 + /ko면 /ko/signin으로 복구` | Null-check 예외 복구 경로 검증 | +| `userfront/test/null_check_recovery_test.dart` | `이미 /ko/signin이면 복구 이동하지 않음` | Null-check 예외 복구 경로 검증 | +| `userfront/test/null_check_recovery_test.dart` | `Null check 오류여도 /ko/profile에서는 복구 이동하지 않음` | Null-check 예외 복구 경로 검증 | +| `userfront/test/null_check_recovery_test.dart` | `다른 오류 메시지면 복구 이동하지 않음` | Null-check 예외 복구 경로 검증 | | `userfront/test/oidc_redirect_guard_test.dart` | `http/https 절대 URL만 허용` | 핵심 동작 회귀 방지 검증 | | `userfront/test/oidc_redirect_guard_test.dart` | `빈 문자열과 파싱 실패를 차단` | 핵심 동작 회귀 방지 검증 | | `userfront/test/password_login_flow_policy_test.dart` | `OIDC challenge가 없고 jwt가 있으면 로컬 로그인 완료로 진행한다` | 로그인 분기/라우팅 규칙 검증 | | `userfront/test/password_login_flow_policy_test.dart` | `OIDC challenge가 있고 redirectTo가 없으면 accept를 시도한다` | 로그인 분기/라우팅 규칙 검증 | | `userfront/test/password_login_flow_policy_test.dart` | `redirectTo/jwt 모두 없으면 invalid로 처리한다` | 로그인 분기/라우팅 규칙 검증 | | `userfront/test/password_login_flow_policy_test.dart` | `redirectTo가 있으면 OIDC redirect를 우선한다` | 로그인 분기/라우팅 규칙 검증 | +| `userfront/test/router_redirect_widget_test.dart` | `루트 경로: /{locale} 로 접근 시 /{locale}/signin 으로 리다이렉트되어야 한다 (버그: 화면 렌더링 안됨)` | 로그인 분기/라우팅 규칙 검증 | | `userfront/test/router_redirect_widget_test.dart` | `/login: login_challenge와 redirect_uri를 전달` | 리다이렉트/쿼리 보존 규칙 검증 | | `userfront/test/router_redirect_widget_test.dart` | `로그인 상태: profile 접근 시 signin으로 리다이렉트하지 않음` | 로그인 분기/라우팅 규칙 검증 | | `userfront/test/router_redirect_widget_test.dart` | `로그인 후 같은 브라우저 새 창/팝업에서도 세션이 유지된다` | 로그인 세션 지속성(동일 브라우저) 검증 | | `userfront/test/router_redirect_widget_test.dart` | `비로그인: redirect_uri/login_challenge가 signin으로 전달` | 리다이렉트/쿼리 보존 규칙 검증 | | `userfront/test/router_redirect_widget_test.dart` | `비로그인: redirect_uri가 없으면 redirect_url을 전달` | 리다이렉트/쿼리 보존 규칙 검증 | -| `userfront/test/widget_test.dart` | `BaronSSOApp builds` | 기본 앱 렌더링 스모크 검증 | +| `userfront/test/dashboard_screen_smoke_test.dart` | `대시보드는 로그인 토큰이 있으면 크래시 없이 기본 프레임을 렌더링한다` | 대시보드 Null-check 회귀 방지 스모크 검증 | +| `userfront/test/widget_test.dart` | `smoke test` | 기본 앱 렌더링 스모크 검증 | diff --git a/docs/trouble-shooting/issue-277-null-check-dashboard-routing.md b/docs/trouble-shooting/issue-277-null-check-dashboard-routing.md new file mode 100644 index 00000000..8ee809e8 --- /dev/null +++ b/docs/trouble-shooting/issue-277-null-check-dashboard-routing.md @@ -0,0 +1,76 @@ +# Issue #277/#302 트러블슈팅 기록: 로그인 후 공백 화면 + 새로고침 시 signin 회귀 + +## 기준 시점 +- 2026-02-23 KST +- 재현 환경: `https://sss.hmac.kr` (WASM 배포) + +## 증상 +- 로그인 직후 URL은 `/{locale}` 또는 `/{locale}/dashboard`로 보이지만 화면이 렌더링되지 않음 +- 이후 새로고침하면 `/{locale}/signin`으로 되돌아감 +- 콘솔/백엔드 수집 로그: + - `Null check operator used on a null value` + - `wasm-function[765]` 포함 스택 반복 + +## 스택 매핑 결과 (source-map + no-strip-wasm) +- 매핑 커맨드: + - `python3 scripts/map_wasm_stack.py --wasm userfront/build/web/main.dart.wasm --sourcemap userfront/build/web/main.dart.wasm.map --frame ...` +- 핵심 프레임: + - `wasm-function[765]` -> `_TypeError._throwNullCheckErrorWithCurrentStack` + - 상위 프레임 -> Flutter `NavigatorState.didUpdateWidget/_updatePages` 경로 +- 결론: + - 단일 위젯 null 접근보다, 라우트 갱신 타이밍/중복 네비게이션 경쟁에서 `Navigator` 내부에서 터지는 양상 + +## 지금까지 시행착오와 실패 내역 +1. `LocaleGate`, `LanguageSelector`의 `EasyLocalization.of(context)` null 방어만 적용 +- 결과: 동일 예외 재발 +- 이유: 루트 원인은 로케일 위젯 단일 null 접근이 아니라 네비게이션 경쟁 구간 + +2. `/ko` 루트에서 signin 강제 리다이렉트만 강화 +- 결과: 최초 진입은 일부 개선됐지만 로그인 직후/새로고침 회귀 지속 +- 이유: 로그인 성공 경로가 루트(`/{locale}`)와 엮이면서 라우트 재평가가 중첩 + +3. 로그인 화면에서 `AuthNotifier.notify()` + `context.go(...)` 동시 수행 +- 결과: 간헐적 경쟁 상태 유발 가능성 확인 +- 조치: 로컬 네비게이션 1회 가드 도입(`_goLocalizedHomeOnce`) + +4. cookie 세션 승격이 토큰 저장 이후 덮어쓰는 경합 +- 결과: 일부 흐름에서 저장 상태 불안정 가능성 +- 조치: `cookie_session_policy` 추가, 토큰 존재 시 불필요한 cookie 승격 차단 + +5. `/:locale` 엔트리가 redirect 없이 매칭되는 구조 +- 결과: `/ko` 직접 진입 시 페이지 스택 재계산 과정에서 `NavigatorState.didUpdateWidget/_updatePages` 경로 null check 재발 +- 이유: `/ko`는 실질 화면이 아닌 분기 지점인데, 명시적 redirect 경로가 없으면 라우트 갱신 타이밍 경쟁에 취약 +- 조치: `/:locale`를 redirect 전용 엔트리로 확정(비로그인 `/{locale}/signin`, 로그인 `/{locale}/dashboard`) + +## 최종 반영 방향 (이번 패치) +1. 로그인 성공 기본 경로를 명시적으로 `/{locale}/dashboard`로 고정 +- `buildLocalizedHomePath()` 반환값을 `/{locale}/dashboard`로 변경 +- `/:locale` 엔트리는 `/:locale/dashboard`로 redirect 전용 처리 + +2. 라우터/화면 역할 분리 +- 보호 경로 검사는 router redirect에서 수행 +- 대시보드는 필요 시 cookie 세션 복구를 1회 시도 후 signin 이동 + +3. 중복 네비게이션 억제 +- 로그인 성공 시 내부 이동은 1회만 수행 + +## 검증 +- 추가 테스트: + - `userfront/test/login_navigation_race_test.dart` + - `userfront/test/cookie_session_policy_test.dart` + - `userfront/test/router_redirect_widget_test.dart` (`/{locale}` 직접 진입 시 signin/dashboard 분기 검증) +- 갱신 테스트: + - `userfront/test/locale_utils_test.dart` (home path `/{locale}/dashboard` 기준) +- 실행: + - `flutter test` + - `flutter test --platform chrome test/router_redirect_widget_test.dart test/login_navigation_race_test.dart test/cookie_session_policy_test.dart` + +## 남은 리스크 +- 실제 브라우저 저장소 정책(localStorage 차단/쿠키 정책)에 따라 세션 판정이 달라질 수 있음 +- 운영 검증 시 네트워크/스토리지 상태를 함께 수집해야 원인 분리 가능 + +## 운영 확인 체크리스트 +1. 비로그인으로 `/{locale}` 접속 시 즉시 `/{locale}/signin` 이동 +2. 로그인 성공 시 `/{locale}/dashboard` 진입 +3. `/{locale}/dashboard`에서 새로고침 후 세션 유지 (동일 브라우저) +4. 실패 시 `RECOVERY_NAV_NULL_CHECK`와 wasm frame 동시 수집 diff --git a/scripts/map_wasm_stack.py b/scripts/map_wasm_stack.py new file mode 100644 index 00000000..fb2e5dae --- /dev/null +++ b/scripts/map_wasm_stack.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +""" +WASM 스택의 `wasm-function[IDX]:0xOFFSET`를 이름/소스 라인으로 매핑합니다. + +사용 예시: + python3 scripts/map_wasm_stack.py \ + --wasm userfront/build/web/main.dart.wasm \ + --sourcemap userfront/build/web/main.dart.wasm.map \ + --frame "19112:0x2cd913" --frame "765:0x10af0e" +""" + +from __future__ import annotations + +import argparse +import bisect +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional, Tuple + + +BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" +BASE64_MAP = {c: i for i, c in enumerate(BASE64_CHARS)} + + +def read_u32_leb128(buf: bytes, i: int) -> Tuple[int, int]: + value = 0 + shift = 0 + while True: + b = buf[i] + i += 1 + value |= (b & 0x7F) << shift + if b < 0x80: + return value, i + shift += 7 + + +def decode_vlq_segment(segment: str) -> List[int]: + out: List[int] = [] + i = 0 + while i < len(segment): + shift = 0 + value = 0 + while True: + d = BASE64_MAP[segment[i]] + i += 1 + value |= (d & 0x1F) << shift + shift += 5 + if (d & 0x20) == 0: + break + sign = value & 1 + value >>= 1 + out.append(-value if sign else value) + return out + + +@dataclass +class SourcePoint: + generated_col: int + source_index: Optional[int] + source_line: Optional[int] + source_col: Optional[int] + name_index: Optional[int] + + +class WasmSourceMap: + def __init__(self, sourcemap_path: Path): + data = json.loads(sourcemap_path.read_text(encoding="utf-8")) + self.sources: List[str] = data["sources"] + self.names: List[str] = data.get("names", []) + mappings: str = data["mappings"] + # wasm sourcemap은 generated line 1개를 쓰는 형태라 ',' 단위로만 파싱합니다. + segments = mappings.split(",") + + points: List[SourcePoint] = [] + generated_col = 0 + source_index = 0 + source_line = 0 + source_col = 0 + name_index = 0 + + for seg in segments: + if not seg: + continue + vals = decode_vlq_segment(seg) + generated_col += vals[0] + si: Optional[int] = None + sl: Optional[int] = None + sc: Optional[int] = None + ni: Optional[int] = None + if len(vals) >= 4: + source_index += vals[1] + source_line += vals[2] + source_col += vals[3] + si = source_index + sl = source_line + sc = source_col + if len(vals) >= 5: + name_index += vals[4] + ni = name_index + points.append( + SourcePoint( + generated_col=generated_col, + source_index=si, + source_line=sl, + source_col=sc, + name_index=ni, + ) + ) + self.points = points + self.columns = [p.generated_col for p in points] + + def lookup(self, offset: int) -> Optional[SourcePoint]: + idx = bisect.bisect_right(self.columns, offset) - 1 + if idx < 0: + return None + return self.points[idx] + + def source_name(self, index: Optional[int]) -> Optional[str]: + if index is None or index < 0 or index >= len(self.sources): + return None + return self.sources[index] + + def symbol_name(self, index: Optional[int]) -> Optional[str]: + if index is None or index < 0 or index >= len(self.names): + return None + return self.names[index] + + +def parse_wasm_function_names(wasm_path: Path) -> Dict[int, str]: + b = wasm_path.read_bytes() + if b[:4] != b"\x00asm": + raise ValueError(f"Not a wasm binary: {wasm_path}") + + function_names: Dict[int, str] = {} + i = 8 # magic + version + + while i < len(b): + section_id = b[i] + i += 1 + section_size, i = read_u32_leb128(b, i) + section_start = i + section_end = i + section_size + + if section_id == 0: # custom section + name_len, j = read_u32_leb128(b, i) + custom_name = b[j : j + name_len].decode("utf-8", errors="replace") + payload_start = j + name_len + if custom_name == "name": + k = payload_start + while k < section_end: + subsection_id = b[k] + k += 1 + subsection_size, k = read_u32_leb128(b, k) + subsection_end = k + subsection_size + if subsection_id == 1: # function names + count, k = read_u32_leb128(b, k) + for _ in range(count): + fn_idx, k = read_u32_leb128(b, k) + nlen, k = read_u32_leb128(b, k) + name = b[k : k + nlen].decode("utf-8", errors="replace") + k += nlen + function_names[fn_idx] = name + else: + k = subsection_end + + i = section_end + return function_names + + +def parse_frame(raw: str) -> Tuple[int, int]: + m = re.match(r"^\s*(\d+)\s*:\s*(0x[0-9a-fA-F]+)\s*$", raw) + if not m: + raise ValueError(f"Invalid --frame format: {raw!r} (expected IDX:0xOFFSET)") + return int(m.group(1)), int(m.group(2), 16) + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Map wasm stack frames to source locations") + p.add_argument("--wasm", required=True, type=Path, help="WASM binary path") + p.add_argument("--sourcemap", required=True, type=Path, help="WASM sourcemap path") + p.add_argument( + "--frame", + action="append", + default=[], + help="Frame in IDX:0xOFFSET format (repeatable)", + ) + p.add_argument( + "--offset", + action="append", + default=[], + help="Offset only (hex), function index unknown", + ) + return p.parse_args() + + +def main() -> None: + args = parse_args() + source_map = WasmSourceMap(args.sourcemap) + function_names = parse_wasm_function_names(args.wasm) + + targets: List[Tuple[Optional[int], int]] = [] + for f in args.frame: + idx, off = parse_frame(f) + targets.append((idx, off)) + for off in args.offset: + targets.append((None, int(off, 16))) + + if not targets: + raise SystemExit("No targets. Provide --frame or --offset.") + + for fn_idx, off in targets: + point = source_map.lookup(off) + fn_name = function_names.get(fn_idx) if fn_idx is not None else None + mapped_col = point.generated_col if point else None + src = source_map.source_name(point.source_index) if point else None + src_line = (point.source_line + 1) if point and point.source_line is not None else None + src_col = (point.source_col + 1) if point and point.source_col is not None else None + symbol = source_map.symbol_name(point.name_index) if point else None + + print( + json.dumps( + { + "function_index": fn_idx, + "function_name": fn_name, + "offset_hex": hex(off), + "mapped_generated_col_hex": hex(mapped_col) if mapped_col is not None else None, + "source": src, + "source_line": src_line, + "source_column": src_col, + "symbol": symbol, + }, + ensure_ascii=False, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/userfront/lib/core/i18n/locale_gate.dart b/userfront/lib/core/i18n/locale_gate.dart index 1031703c..adeea578 100644 --- a/userfront/lib/core/i18n/locale_gate.dart +++ b/userfront/lib/core/i18n/locale_gate.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart' hide tr; import 'package:flutter/material.dart'; @@ -17,28 +19,54 @@ class LocaleGate extends StatefulWidget { } class _LocaleGateState extends State { + bool _syncScheduled = false; + @override void didChangeDependencies() { super.didChangeDependencies(); - _applyLocale(); + _scheduleLocaleSync(); } @override void didUpdateWidget(LocaleGate oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.localeCode != widget.localeCode) { - _applyLocale(); + _scheduleLocaleSync(); } } - Future _applyLocale() async { - final normalized = normalizeLocaleCode(widget.localeCode); - LocaleStorage.write(normalized); - webWindow.setTitle(tr('ui.userfront.app_title')); - if (context.locale.languageCode == normalized) { + void _scheduleLocaleSync() { + if (_syncScheduled) { + return; + } + _syncScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _syncScheduled = false; + if (!mounted) { + return; + } + unawaited(_applyLocale()); + }); + } + + Future _applyLocale() async { + if (!mounted) { + return; + } + final normalized = normalizeLocaleCode(widget.localeCode); + LocaleStorage.write(normalized); + final localization = EasyLocalization.of(context); + if (localization == null) { + return; + } + if (localization.currentLocale?.languageCode == normalized) { + webWindow.setTitle(tr('ui.userfront.app_title')); + return; + } + await localization.setLocale(Locale(normalized)); + if (!mounted) { return; } - await context.setLocale(Locale(normalized)); webWindow.setTitle(tr('ui.userfront.app_title')); } diff --git a/userfront/lib/core/i18n/locale_storage_engine.dart b/userfront/lib/core/i18n/locale_storage_engine.dart index 0c907b40..c5dd2ebf 100644 --- a/userfront/lib/core/i18n/locale_storage_engine.dart +++ b/userfront/lib/core/i18n/locale_storage_engine.dart @@ -183,10 +183,11 @@ class LocaleStorageEngine implements LocaleStorageBackend { final legacy = _readByKey(LocaleStoragePolicy.legacyKey); if (LocaleStoragePolicy.shouldMigrateLegacy( - current: current, - legacy: legacy, - )) { - _writeByKey(LocaleStoragePolicy.currentKey, legacy!); + current: current, + legacy: legacy, + ) && + legacy != null) { + _writeByKey(LocaleStoragePolicy.currentKey, legacy); _removeEverywhere(LocaleStoragePolicy.legacyKey); return legacy; } diff --git a/userfront/lib/core/i18n/locale_utils.dart b/userfront/lib/core/i18n/locale_utils.dart index 5d403bcf..dd9989c8 100644 --- a/userfront/lib/core/i18n/locale_utils.dart +++ b/userfront/lib/core/i18n/locale_utils.dart @@ -32,10 +32,10 @@ String resolvePreferredLocaleCode() { } } final deviceLocale = PlatformDispatcher.instance.locale; - final languageTag = - deviceLocale.countryCode == null || deviceLocale.countryCode!.isEmpty + final countryCode = deviceLocale.countryCode; + final languageTag = countryCode == null || countryCode.isEmpty ? deviceLocale.languageCode - : '${deviceLocale.languageCode}-${deviceLocale.countryCode}'; + : '${deviceLocale.languageCode}-$countryCode'; return normalizeLocaleCode(languageTag); } @@ -101,3 +101,17 @@ String buildSigninRedirectPath(String localeCode, Uri uri) { } return result; } + +String buildLocalizedHomePath(Uri uri, {String? preferredLocaleCode}) { + final resolvedLocale = + extractLocaleFromPath(uri) ?? + normalizeLocaleCode(preferredLocaleCode ?? resolvePreferredLocaleCode()); + return '/$resolvedLocale/dashboard'; +} + +String buildLocalizedSigninPath(Uri uri, {String? preferredLocaleCode}) { + final resolvedLocale = + extractLocaleFromPath(uri) ?? + normalizeLocaleCode(preferredLocaleCode ?? resolvePreferredLocaleCode()); + return '/$resolvedLocale/signin'; +} diff --git a/userfront/lib/core/services/null_check_recovery.dart b/userfront/lib/core/services/null_check_recovery.dart new file mode 100644 index 00000000..fa31030b --- /dev/null +++ b/userfront/lib/core/services/null_check_recovery.dart @@ -0,0 +1,26 @@ +import '../i18n/locale_utils.dart'; + +String? computeNullCheckRecoveryTarget({ + required Object exception, + required Uri uri, + required String preferredLocaleCode, +}) { + final message = exception.toString(); + if (!message.contains('Null check operator used on a null value')) { + return null; + } + + final localeCode = + extractLocaleFromPath(uri) ?? normalizeLocaleCode(preferredLocaleCode); + final path = uri.path; + final localeRootPath = '/$localeCode'; + if (path != '/' && path != localeRootPath) { + return null; + } + + final target = '/$localeCode/signin'; + if (path == target) { + return null; + } + return target; +} diff --git a/userfront/lib/core/services/web_auth_integration_web.dart b/userfront/lib/core/services/web_auth_integration_web.dart index 061c5d2c..c22f454e 100644 --- a/userfront/lib/core/services/web_auth_integration_web.dart +++ b/userfront/lib/core/services/web_auth_integration_web.dart @@ -6,6 +6,7 @@ import 'package:web/web.dart' as web; import 'package:flutter/foundation.dart'; import 'dart:js_interop'; import 'auth_token_store.dart'; +import '../i18n/locale_utils.dart'; void implSendLoginSuccess(String token) { var effectiveToken = token; @@ -87,8 +88,9 @@ void implSendLoginSuccess(String token) { } // No opener and no redirect: fall back to local navigation - debugPrint('No opener found. Redirecting to /.'); - web.window.location.href = '/'; + final fallbackTarget = buildLocalizedHomePath(Uri.base); + debugPrint('No opener found. Redirecting to $fallbackTarget.'); + web.window.location.href = fallbackTarget; } bool implIsPopup() { diff --git a/userfront/lib/core/widgets/language_selector.dart b/userfront/lib/core/widgets/language_selector.dart index 27520d84..d1ff2fd6 100644 --- a/userfront/lib/core/widgets/language_selector.dart +++ b/userfront/lib/core/widgets/language_selector.dart @@ -13,7 +13,13 @@ class LanguageSelector extends StatelessWidget { @override Widget build(BuildContext context) { - final current = context.locale.languageCode; + final localization = EasyLocalization.of(context); + final resolvedCurrent = normalizeLocaleCode( + localization?.currentLocale?.languageCode, + ); + final current = (resolvedCurrent == 'ko' || resolvedCurrent == 'en') + ? resolvedCurrent + : 'en'; final items = [ DropdownMenuItem(value: 'ko', child: Text(tr('ui.common.language_ko'))), DropdownMenuItem( @@ -34,9 +40,16 @@ class LanguageSelector extends StatelessWidget { return; } LocaleStorage.write(value); - await context.setLocale(Locale(value)); + if (localization != null) { + await localization.setLocale(Locale(value)); + } if (!context.mounted) return; - final uri = GoRouterState.of(context).uri; + Uri uri; + try { + uri = GoRouterState.of(context).uri; + } catch (_) { + uri = Uri.base; + } final target = buildLocalizedPath(value, uri); context.go(target); }, diff --git a/userfront/lib/features/admin/presentation/create_user_screen.dart b/userfront/lib/features/admin/presentation/create_user_screen.dart index 8022df09..368cc681 100644 --- a/userfront/lib/features/admin/presentation/create_user_screen.dart +++ b/userfront/lib/features/admin/presentation/create_user_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/services/auth_proxy_service.dart'; +import '../../../../core/i18n/locale_utils.dart'; class CreateUserScreen extends StatefulWidget { const CreateUserScreen({super.key}); @@ -67,7 +68,7 @@ class _CreateUserScreenState extends State { // If cancelled or empty if (inputPassword == null || inputPassword.isEmpty) { - if (mounted) context.go('/'); // Kick out + if (mounted) context.go(buildLocalizedHomePath(Uri.base)); // Kick out return; } @@ -91,7 +92,7 @@ class _CreateUserScreenState extends State { backgroundColor: Colors.red, ), ); - context.go('/'); // Kick out + context.go(buildLocalizedHomePath(Uri.base)); // Kick out } } } @@ -178,7 +179,7 @@ class _CreateUserScreenState extends State { title: const Text('Create User'), leading: IconButton( icon: const Icon(Icons.arrow_back), - onPressed: () => context.go('/'), + onPressed: () => context.go(buildLocalizedHomePath(Uri.base)), ), ), body: Center( diff --git a/userfront/lib/features/admin/presentation/user_management_screen.dart b/userfront/lib/features/admin/presentation/user_management_screen.dart index 881acd05..50274f13 100644 --- a/userfront/lib/features/admin/presentation/user_management_screen.dart +++ b/userfront/lib/features/admin/presentation/user_management_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'dart:async'; import '../../../../core/services/auth_proxy_service.dart'; +import '../../../../core/i18n/locale_utils.dart'; class UserManagementScreen extends StatefulWidget { const UserManagementScreen({super.key}); @@ -89,7 +90,7 @@ class _UserManagementScreenState extends State ); if (inputPassword == null || inputPassword.isEmpty) { - if (mounted) context.go('/'); + if (mounted) context.go(buildLocalizedHomePath(Uri.base)); return; } @@ -113,7 +114,7 @@ class _UserManagementScreenState extends State backgroundColor: Colors.red, ), ); - context.go('/'); + context.go(buildLocalizedHomePath(Uri.base)); } } } @@ -365,7 +366,7 @@ class _UserManagementScreenState extends State title: const Text('User Management'), leading: IconButton( icon: const Icon(Icons.arrow_back), - onPressed: () => context.go('/'), + onPressed: () => context.go(buildLocalizedHomePath(Uri.base)), ), bottom: TabBar( controller: _tabController, diff --git a/userfront/lib/features/auth/domain/cookie_session_policy.dart b/userfront/lib/features/auth/domain/cookie_session_policy.dart new file mode 100644 index 00000000..9d6271d7 --- /dev/null +++ b/userfront/lib/features/auth/domain/cookie_session_policy.dart @@ -0,0 +1,15 @@ +bool shouldPromoteCookieSession({ + required String? currentToken, + required String? loginChallenge, +}) { + final hasToken = currentToken != null && currentToken.trim().isNotEmpty; + final hasChallenge = + loginChallenge != null && loginChallenge.trim().isNotEmpty; + + // 토큰 기반 세션이 이미 확보된 일반 로그인 흐름에서는 + // 뒤늦은 쿠키 세션 승격이 토큰을 덮어쓰지 않도록 차단합니다. + if (hasToken && !hasChallenge) { + return false; + } + return true; +} diff --git a/userfront/lib/features/auth/presentation/approve_qr_screen.dart b/userfront/lib/features/auth/presentation/approve_qr_screen.dart index d55c6b2b..01dca34c 100644 --- a/userfront/lib/features/auth/presentation/approve_qr_screen.dart +++ b/userfront/lib/features/auth/presentation/approve_qr_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/services/auth_token_store.dart'; @@ -47,14 +48,15 @@ class _ApproveQrScreenState extends State { void _redirectIfNotLoggedIn() { if (_redirectingToLogin || !mounted) return; - final hasStoredToken = AuthTokenStore.getToken() != null; + final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false; final usesCookie = AuthTokenStore.usesCookie(); final isLoggedIn = hasStoredToken || usesCookie; if (!isLoggedIn) { _redirectingToLogin = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - context.go('/signin?notice=qr_login_required'); + final target = buildLocalizedSigninPath(Uri.base); + context.go('$target?notice=qr_login_required'); }); } } @@ -70,7 +72,8 @@ class _ApproveQrScreenState extends State { } if (storedToken == null && !hasCookie) { if (mounted) { - context.go('/signin?notice=qr_login_required'); + final target = buildLocalizedSigninPath(Uri.base); + context.go('$target?notice=qr_login_required'); } return; } @@ -94,7 +97,7 @@ class _ApproveQrScreenState extends State { // Automatically go to dashboard after a short delay Future.delayed(const Duration(seconds: 1), () { - if (mounted) context.go('/'); + if (mounted) context.go(buildLocalizedHomePath(Uri.base)); }); } catch (e) { setState(() => _message = "Error: $e"); @@ -105,7 +108,7 @@ class _ApproveQrScreenState extends State { @override Widget build(BuildContext context) { - final hasStoredToken = AuthTokenStore.getToken() != null; + final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false; final usesCookie = AuthTokenStore.usesCookie(); final isLoggedIn = hasStoredToken || usesCookie || _isCheckingSession; @@ -163,14 +166,15 @@ class _ApproveQrScreenState extends State { Padding( padding: const EdgeInsets.only(top: 16), child: TextButton( - onPressed: () => context.go('/signin'), + onPressed: () => + context.go(buildLocalizedSigninPath(Uri.base)), child: const Text("Login on this device first"), ), ), if (_success) FilledButton( - onPressed: () => context.go('/'), + onPressed: () => context.go(buildLocalizedHomePath(Uri.base)), child: const Text("Go to My Dashboard"), ), ], diff --git a/userfront/lib/features/auth/presentation/consent_screen.dart b/userfront/lib/features/auth/presentation/consent_screen.dart index 2080063d..4d4f0734 100644 --- a/userfront/lib/features/auth/presentation/consent_screen.dart +++ b/userfront/lib/features/auth/presentation/consent_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:userfront/core/i18n/locale_utils.dart'; import 'package:userfront/core/services/auth_proxy_service.dart'; import 'package:userfront/core/services/web_window.dart'; @@ -153,7 +154,7 @@ class _ConsentScreenState extends State { if (redirectTo != null) { webWindow.redirectTo(redirectTo); } else { - if (mounted) context.go('/'); + if (mounted) context.go(buildLocalizedHomePath(Uri.base)); } } catch (e) { setState(() => _isSubmitting = false); diff --git a/userfront/lib/features/auth/presentation/error_screen.dart b/userfront/lib/features/auth/presentation/error_screen.dart index d86d1092..b2ebc876 100644 --- a/userfront/lib/features/auth/presentation/error_screen.dart +++ b/userfront/lib/features/auth/presentation/error_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../../core/constants/error_whitelist.dart'; +import '../../../core/i18n/locale_utils.dart'; import '../../../core/services/auth_proxy_service.dart'; import 'package:userfront/i18n.dart'; @@ -130,7 +131,8 @@ class ErrorScreen extends StatelessWidget { child: Text(tr('ui.userfront.error.go_login')), ), OutlinedButton( - onPressed: () => context.go('/'), + onPressed: () => + context.go(buildLocalizedHomePath(Uri.base)), style: OutlinedButton.styleFrom( foregroundColor: const Color(0xFF111827), padding: const EdgeInsets.symmetric( diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index bddb8515..47539b75 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -9,9 +9,11 @@ import '../../../core/widgets/language_selector.dart'; import '../../../core/services/web_auth_integration.dart'; import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_token_store.dart'; +import '../../../core/i18n/locale_utils.dart'; import '../../../core/services/oidc_redirect_guard.dart'; import '../../../core/notifiers/auth_notifier.dart'; import '../domain/login_challenge_resolver.dart'; +import '../domain/cookie_session_policy.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; import '../../../core/services/web_window.dart'; @@ -65,6 +67,7 @@ class _LoginScreenState extends ConsumerState bool _verificationOnly = false; bool _verificationApproved = false; bool _dismissedOverlays = false; + bool _localNavigationCompleted = false; String _verificationMessage = ''; String _verificationTitle = tr('ui.userfront.login.verification.title'); String _verificationPageTitle = tr( @@ -125,7 +128,11 @@ class _LoginScreenState extends ConsumerState if (hasLoginCode) { _verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam); } else if (hasVerificationToken) { - _verifyToken(widget.verificationToken ?? uri.queryParameters['t']!); + final verificationToken = + widget.verificationToken ?? uri.queryParameters['t']; + if (verificationToken != null && verificationToken.isNotEmpty) { + _verifyToken(verificationToken); + } } if (!_noticeHandled && notice == 'qr_login_required') { @@ -142,8 +149,12 @@ class _LoginScreenState extends ConsumerState } Future _tryCookieSession({bool silent = true}) async { - if (AuthTokenStore.getToken() != null && - (_loginChallenge == null || _loginChallenge!.isEmpty)) { + final loginChallenge = _loginChallenge; + final token = AuthTokenStore.getToken(); + if (!shouldPromoteCookieSession( + currentToken: token, + loginChallenge: loginChallenge, + )) { return; } final pendingProvider = AuthTokenStore.getPendingProvider(); @@ -151,6 +162,12 @@ class _LoginScreenState extends ConsumerState try { await AuthProxyService.checkCookieSession(); + if (!shouldPromoteCookieSession( + currentToken: AuthTokenStore.getToken(), + loginChallenge: loginChallenge, + )) { + return; + } AuthTokenStore.setCookieMode(provider: provider); AuthTokenStore.clearPendingProvider(); if (mounted) { @@ -171,7 +188,6 @@ class _LoginScreenState extends ConsumerState Future _onCookieLoginSuccess(String provider) async { debugPrint("[Auth] Cookie-based login success. Provider: $provider"); - AuthNotifier.instance.notify(); if (_hasLoginChallenge) { final accepted = await _acceptOidcLoginAndRedirect(); if (accepted) { @@ -185,8 +201,9 @@ class _LoginScreenState extends ConsumerState final token = AuthTokenStore.getToken(); if (token != null && token.isNotEmpty) { + final redirectUrl = _redirectUrl; if (WebAuthIntegration.isPopup() || - (_redirectUrl != null && _redirectUrl!.isNotEmpty)) { + (redirectUrl != null && redirectUrl.isNotEmpty)) { debugPrint( "[Auth] Cookie session with external integration. Notifying...", ); @@ -196,14 +213,23 @@ class _LoginScreenState extends ConsumerState } if (mounted) { - context.go('/'); + _goLocalizedHomeOnce(); } } + void _goLocalizedHomeOnce() { + if (!mounted || _localNavigationCompleted) { + return; + } + _localNavigationCompleted = true; + context.go(buildLocalizedHomePath(Uri.base)); + } + Future _attemptOidcAutoAccept() async { if (_oidcAutoAcceptTried) return; _oidcAutoAcceptTried = true; - if (_loginChallenge == null || _loginChallenge!.isEmpty) { + final loginChallenge = _loginChallenge; + if (loginChallenge == null || loginChallenge.isEmpty) { return; } @@ -227,12 +253,13 @@ class _LoginScreenState extends ConsumerState } Future _acceptOidcLoginAndRedirect({String? token}) async { - if (_loginChallenge == null || _loginChallenge!.isEmpty) { + final loginChallenge = _loginChallenge; + if (loginChallenge == null || loginChallenge.isEmpty) { return false; } try { final res = await AuthProxyService.acceptOidcLogin( - _loginChallenge!, + loginChallenge, token: token, ); final redirectTo = res['redirectTo'] as String?; @@ -274,8 +301,10 @@ class _LoginScreenState extends ConsumerState } } - bool get _hasLoginChallenge => - _loginChallenge != null && _loginChallenge!.isNotEmpty; + bool get _hasLoginChallenge { + final loginChallenge = _loginChallenge; + return loginChallenge != null && loginChallenge.isNotEmpty; + } LoginChallengeResolution _resolveLoginChallenge(Uri uri) { return resolveLoginChallenge( @@ -486,7 +515,11 @@ class _LoginScreenState extends ConsumerState } try { - final res = await AuthProxyService.pollQrStatus(_qrPendingRef!); + final pendingRef = _qrPendingRef; + if (pendingRef == null || pendingRef.isEmpty) { + return; + } + final res = await AuthProxyService.pollQrStatus(pendingRef); if (res['error'] == 'slow_down') { final interval = res['interval']; if (interval is int && interval > 0) { @@ -656,9 +689,11 @@ class _LoginScreenState extends ConsumerState FilledButton( onPressed: () { final hasLocalSession = - AuthTokenStore.getToken() != null || + (AuthTokenStore.getToken()?.isNotEmpty ?? false) || AuthTokenStore.usesCookie(); - final target = hasLocalSession ? '/' : '/signin'; + final target = hasLocalSession + ? buildLocalizedHomePath(Uri.base) + : buildLocalizedSigninPath(Uri.base); if (mounted) { setState(() { _verificationOnly = false; @@ -691,7 +726,9 @@ class _LoginScreenState extends ConsumerState final jwt = res['token'] ?? res['sessionJwt'] ?? res['sessionToken']; final status = res['status']?.toString(); final hasLocalSession = await _hasValidLocalSession(); - final actionPath = hasLocalSession ? '/' : '/signin'; + final actionPath = hasLocalSession + ? buildLocalizedHomePath(Uri.base) + : buildLocalizedSigninPath(Uri.base); if (status == 'approved' || (jwt == null && _verificationOnly)) { if (mounted) { @@ -754,7 +791,9 @@ class _LoginScreenState extends ConsumerState "[Auth] Code verification successful for loginId: $sanitizedLoginId", ); final hasLocalSession = await _hasValidLocalSession(); - final actionPath = hasLocalSession ? '/' : '/signin'; + final actionPath = hasLocalSession + ? buildLocalizedHomePath(Uri.base) + : buildLocalizedSigninPath(Uri.base); if (jwt == null && status == 'approved') { if (mounted) { @@ -814,7 +853,9 @@ class _LoginScreenState extends ConsumerState final status = res['status']?.toString(); debugPrint("[Auth] Short code verification successful"); final hasLocalSession = await _hasValidLocalSession(); - final actionPath = hasLocalSession ? '/' : '/signin'; + final actionPath = hasLocalSession + ? buildLocalizedHomePath(Uri.base) + : buildLocalizedSigninPath(Uri.base); if (jwt == null && status == 'approved') { if (mounted) { @@ -1147,14 +1188,15 @@ class _LoginScreenState extends ConsumerState } // [Priority 2] OIDC Challenge Handling - if (_loginChallenge != null && _loginChallenge!.isNotEmpty) { + final loginChallenge = _loginChallenge; + if (loginChallenge != null && loginChallenge.isNotEmpty) { try { // Save token first, it's needed for acceptance final providerName = provider ?? AuthTokenStore.getProvider(); AuthTokenStore.setToken(token, provider: providerName); final res = await AuthProxyService.acceptOidcLogin( - _loginChallenge!, + loginChallenge, token: token, ); final nextRedirectTo = res['redirectTo'] as String?; @@ -1196,9 +1238,8 @@ class _LoginScreenState extends ConsumerState return; } - AuthNotifier.instance.notify(); if (mounted) { - context.go('/'); + _goLocalizedHomeOnce(); } } catch (globalErr) { // ignore @@ -1237,7 +1278,7 @@ class _LoginScreenState extends ConsumerState title: Text(_verificationPageTitle), leading: IconButton( icon: const Icon(Icons.arrow_back), - onPressed: () => context.go('/'), + onPressed: () => context.go(buildLocalizedHomePath(Uri.base)), ), ), body: _buildVerificationResultView(), diff --git a/userfront/lib/features/auth/presentation/login_success_screen.dart b/userfront/lib/features/auth/presentation/login_success_screen.dart index 97b7b467..cb045820 100644 --- a/userfront/lib/features/auth/presentation/login_success_screen.dart +++ b/userfront/lib/features/auth/presentation/login_success_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:userfront/core/i18n/locale_utils.dart'; import 'package:userfront/i18n.dart'; class LoginSuccessScreen extends StatelessWidget { @@ -54,7 +55,7 @@ class LoginSuccessScreen extends StatelessWidget { const SizedBox(height: 24), TextButton( onPressed: () { - context.go('/'); + context.go(buildLocalizedHomePath(Uri.base)); }, child: Text( tr('ui.userfront.login_success.later'), diff --git a/userfront/lib/features/auth/presentation/reset_password_screen.dart b/userfront/lib/features/auth/presentation/reset_password_screen.dart index 6ecc4513..d2ccd716 100644 --- a/userfront/lib/features/auth/presentation/reset_password_screen.dart +++ b/userfront/lib/features/auth/presentation/reset_password_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import '../../../core/i18n/locale_utils.dart'; import '../../../core/services/auth_proxy_service.dart'; import 'package:userfront/i18n.dart'; @@ -89,7 +90,7 @@ class _ResetPasswordScreenState extends State { backgroundColor: Colors.green, ), ); - context.go('/signin'); + context.go(buildLocalizedSigninPath(Uri.base)); } } catch (e) { if (mounted) { diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 3c704c86..708e4d5e 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:userfront/i18n.dart'; +import '../../../core/i18n/locale_utils.dart'; import '../../../core/services/auth_proxy_service.dart'; class SignupScreen extends StatefulWidget { @@ -345,7 +346,7 @@ class _SignupScreenState extends State { content: Text(tr('msg.userfront.signup.success.body')), actions: [ TextButton( - onPressed: () => context.go('/signin'), + onPressed: () => context.go(buildLocalizedSigninPath(Uri.base)), child: Text(tr('ui.userfront.signup.success.action')), ), ], diff --git a/userfront/lib/features/dashboard/domain/dashboard_providers.dart b/userfront/lib/features/dashboard/domain/dashboard_providers.dart index 93577cd1..1a4307b2 100644 --- a/userfront/lib/features/dashboard/domain/dashboard_providers.dart +++ b/userfront/lib/features/dashboard/domain/dashboard_providers.dart @@ -133,7 +133,8 @@ class AuthTimelineNotifier extends Notifier { if (state.isLoading || state.isLoadingMore) { return; } - if (state.nextCursor == null || state.nextCursor!.isEmpty) { + final nextCursor = state.nextCursor; + if (nextCursor == null || nextCursor.isEmpty) { return; } state = state.copyWith(isLoadingMore: true, error: null); diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 4d86c2f8..565bbbfb 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -7,6 +8,7 @@ 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_proxy_service.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/http_client.dart'; import '../../../../core/i18n/locale_utils.dart'; @@ -38,6 +40,7 @@ class _DashboardScreenState extends ConsumerState { bool _auditLoadingMore = false; bool _isRevoking = false; bool _redirectingToSignin = false; + bool _authBootstrapInProgress = false; bool _showAllActivities = false; final Set _revokedClientIds = {}; @@ -47,11 +50,10 @@ class _DashboardScreenState extends ConsumerState { super.initState(); _pageScrollController.addListener(_onPageScroll); WidgetsBinding.instance.addPostFrameCallback((_) { - if (!_isLoggedIn()) { - _redirectToSignin(); + if (!mounted) { return; } - _loadAuditLogs(reset: true); + unawaited(_bootstrapAuthAndLoad()); }); } @@ -254,7 +256,7 @@ class _DashboardScreenState extends ConsumerState { if (closeOnTap) { Navigator.of(context).pop(); } - context.go('/'); + context.go(buildLocalizedHomePath(Uri.base)); }, ), ListTile( @@ -302,8 +304,11 @@ class _DashboardScreenState extends ConsumerState { Future _refreshAll() async { if (!_isLoggedIn()) { - _redirectToSignin(); - return; + final recovered = await _recoverSessionFromCookie(); + if (!recovered) { + _redirectToSignin(); + return; + } } await ref.read(profileProvider.notifier).loadProfile(); setState(() { @@ -372,7 +377,8 @@ class _DashboardScreenState extends ConsumerState { if (_auditLoading || _auditLoadingMore) { return; } - if (!reset && (_auditNextCursor == null || _auditNextCursor!.isEmpty)) { + final nextCursor = _auditNextCursor; + if (!reset && (nextCursor == null || nextCursor.isEmpty)) { return; } @@ -706,109 +712,133 @@ class _DashboardScreenState extends ConsumerState { @override Widget build(BuildContext context) { - if (!_isLoggedIn()) { - _redirectToSignin(); - return const SizedBox.shrink(); - } - final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint; - final profileState = ref.watch(profileProvider); - final profile = profileState.value; - final timelineState = ref.watch(authTimelineProvider); - final userName = - profile?.name ?? - profile?.email ?? - profile?.phone ?? - tr('ui.userfront.profile.user_fallback', fallback: 'User'); - final department = profile?.department.isNotEmpty == true - ? profile!.department - : tr('ui.userfront.profile.department_empty'); - final sessionIssuedAt = _getJwtIssuedAt(); + try { + if (!_isLoggedIn()) { + _redirectToSignin(); + return const SizedBox.shrink(); + } + final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint; + final profileState = ref.watch(profileProvider); + final profile = profileState.value; + final timelineState = ref.watch(authTimelineProvider); + final userName = + profile?.name ?? + profile?.email ?? + profile?.phone ?? + tr('ui.userfront.profile.user_fallback', fallback: 'User'); + final departmentValue = profile?.department ?? ''; + final department = departmentValue.isNotEmpty + ? departmentValue + : tr('ui.userfront.profile.department_empty'); + final sessionIssuedAt = _getJwtIssuedAt(); - return Scaffold( - backgroundColor: _subtle, - appBar: AppBar( - title: Text( - tr('ui.userfront.app_title'), - style: TextStyle(fontWeight: FontWeight.bold), - ), - elevation: 0, - backgroundColor: _surface, - foregroundColor: Colors.black, - actions: [ - IconButton( - icon: const Icon(Icons.person_outline), - tooltip: tr('ui.userfront.nav.profile'), - onPressed: () => context.push('/profile'), + return Scaffold( + backgroundColor: _subtle, + appBar: AppBar( + title: Text( + tr('ui.userfront.app_title'), + style: const TextStyle(fontWeight: FontWeight.bold), ), - IconButton( - icon: const Icon(Icons.qr_code_scanner), - tooltip: tr('ui.userfront.nav.qr_scan'), - onPressed: _onScanQR, - ), - IconButton( - icon: const Icon(Icons.logout), - tooltip: tr('ui.userfront.nav.logout'), - onPressed: _logout, - ), - ], - ), - drawer: isWide - ? null - : Drawer(child: _buildSideMenu(context, closeOnTap: true)), - body: Row( - children: [ - if (isWide) - SizedBox( - width: 240, - child: _buildSideMenu(context, closeOnTap: false), + elevation: 0, + backgroundColor: _surface, + foregroundColor: Colors.black, + actions: [ + IconButton( + icon: const Icon(Icons.person_outline), + tooltip: tr('ui.userfront.nav.profile'), + onPressed: () => context.push('/profile'), ), - Expanded( - child: RefreshIndicator( - onRefresh: _refreshAll, - child: LayoutBuilder( - builder: (context, constraints) { - final timelineWide = constraints.maxWidth >= 900; - final isMobile = constraints.maxWidth < 600; - return SingleChildScrollView( - controller: _pageScrollController, - physics: const AlwaysScrollableScrollPhysics(), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isMobile) ...[ - _buildHeaderCard( - userName, - department, - sessionIssuedAt, + IconButton( + icon: const Icon(Icons.qr_code_scanner), + tooltip: tr('ui.userfront.nav.qr_scan'), + onPressed: _onScanQR, + ), + IconButton( + icon: const Icon(Icons.logout), + tooltip: tr('ui.userfront.nav.logout'), + onPressed: _logout, + ), + ], + ), + drawer: isWide + ? null + : Drawer(child: _buildSideMenu(context, closeOnTap: true)), + body: Row( + children: [ + if (isWide) + SizedBox( + width: 240, + child: _buildSideMenu(context, closeOnTap: false), + ), + Expanded( + child: RefreshIndicator( + onRefresh: _refreshAll, + child: LayoutBuilder( + builder: (context, constraints) { + final timelineWide = constraints.maxWidth >= 900; + final isMobile = constraints.maxWidth < 600; + return SingleChildScrollView( + controller: _pageScrollController, + physics: const AlwaysScrollableScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isMobile) ...[ + _buildHeaderCard( + userName, + department, + sessionIssuedAt, + ), + const SizedBox(height: 28), + ], + _buildSectionTitle( + tr('ui.userfront.sections.apps'), + tr('msg.userfront.sections.apps_subtitle'), ), + const SizedBox(height: 12), + _buildActivitySection(isMobile), const SizedBox(height: 28), + _buildSectionTitle( + tr('ui.userfront.sections.audit'), + tr('msg.userfront.sections.audit_subtitle'), + ), + const SizedBox(height: 12), + _buildAccessHistory(timelineState, timelineWide), ], - _buildSectionTitle( - tr('ui.userfront.sections.apps'), - tr('msg.userfront.sections.apps_subtitle'), - ), - const SizedBox(height: 12), - _buildActivitySection(isMobile), - const SizedBox(height: 28), - _buildSectionTitle( - tr('ui.userfront.sections.audit'), - tr('msg.userfront.sections.audit_subtitle'), - ), - const SizedBox(height: 12), - _buildAccessHistory(timelineState, timelineWide), - ], + ), ), - ), - ); - }, + ); + }, + ), ), ), + ], + ), + ); + } catch (error, stackTrace) { + AuthProxyService.logError( + 'DASHBOARD_RENDER_ERROR: $error\nuri=${Uri.base}', + error: error, + stackTrace: stackTrace, + ); + return Scaffold( + backgroundColor: _subtle, + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + tr( + 'msg.userfront.dashboard.render_error', + fallback: '대시보드 렌더링 중 오류가 발생했습니다. 다시 시도해 주세요.', + ), + textAlign: TextAlign.center, + ), ), - ], - ), - ); + ), + ); + } } Widget _buildHeaderCard( @@ -973,8 +1003,9 @@ class _DashboardScreenState extends ConsumerState { normalizedStatus == 'active' || normalizedStatus == ''; final isRevoked = !isActiveInApi; - final lastAuthLabel = rp.lastAuthenticatedAt != null - ? _formatDateTime(rp.lastAuthenticatedAt!) + final lastAuthAt = rp.lastAuthenticatedAt; + final lastAuthLabel = lastAuthAt != null + ? _formatDateTime(lastAuthAt) : tr('ui.userfront.dashboard.activity.linked'); final statusCode = isRevoked ? 'revoked' : 'active'; @@ -1004,8 +1035,10 @@ class _DashboardScreenState extends ConsumerState { if (!aActive && bActive) return 1; // 둘 다 활성이거나 둘 다 비활성인 경우 최근 인증순 내림차순 - if (a.lastAuthDateTime != null && b.lastAuthDateTime != null) { - return b.lastAuthDateTime!.compareTo(a.lastAuthDateTime!); + final aLastAuth = a.lastAuthDateTime; + final bLastAuth = b.lastAuthDateTime; + if (aLastAuth != null && bLastAuth != null) { + return bLastAuth.compareTo(aLastAuth); } if (a.lastAuthDateTime != null) return -1; if (b.lastAuthDateTime != null) return 1; @@ -1045,7 +1078,7 @@ class _DashboardScreenState extends ConsumerState { } // 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려) - final double spacing = 12.0; + const spacing = 12.0; final double cardWidth = (maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount; @@ -1244,8 +1277,9 @@ class _DashboardScreenState extends ConsumerState { child: GestureDetector( onTap: () async { final messenger = ScaffoldMessenger.of(context); - if (item.url != null && item.url!.isNotEmpty) { - final uri = Uri.parse(item.url!); + final itemUrl = item.url; + if (itemUrl != null && itemUrl.isNotEmpty) { + final uri = Uri.parse(itemUrl); final canOpen = await canLaunchUrl(uri); if (!mounted) return; if (canOpen) { @@ -1568,7 +1602,8 @@ class _DashboardScreenState extends ConsumerState { ), ); } - if (state.nextCursor == null || state.nextCursor!.isEmpty) { + final nextCursor = state.nextCursor; + if (nextCursor == null || nextCursor.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 8), child: Text( @@ -1581,7 +1616,8 @@ class _DashboardScreenState extends ConsumerState { } bool _isLoggedIn() { - return AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie(); + final token = AuthTokenStore.getToken(); + return (token != null && token.isNotEmpty) || AuthTokenStore.usesCookie(); } void _redirectToSignin() { @@ -1593,13 +1629,60 @@ class _DashboardScreenState extends ConsumerState { if (!mounted) { return; } - final uri = GoRouterState.of(context).uri; + Uri uri; + try { + uri = GoRouterState.of(context).uri; + } catch (_) { + uri = Uri.base; + } final localeCode = extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode(); context.go('/$localeCode/signin'); _redirectingToSignin = false; }); } + + Future _bootstrapAuthAndLoad() async { + if (!mounted || _authBootstrapInProgress) { + return; + } + _authBootstrapInProgress = true; + try { + var authenticated = _isLoggedIn(); + if (!authenticated) { + authenticated = await _recoverSessionFromCookie(); + } + if (!mounted) { + return; + } + if (!authenticated) { + _redirectToSignin(); + return; + } + await _loadAuditLogs(reset: true); + } finally { + _authBootstrapInProgress = false; + } + } + + Future _recoverSessionFromCookie() async { + try { + await AuthProxyService.checkCookieSession(); + final provider = + AuthTokenStore.getProvider() ?? + AuthTokenStore.getPendingProvider() ?? + 'ory'; + AuthTokenStore.setCookieMode(provider: provider); + AuthTokenStore.clearPendingProvider(); + AuthNotifier.instance.notify(); + try { + await ref.read(profileProvider.notifier).loadProfile(); + } catch (_) {} + return true; + } catch (_) { + return false; + } + } } class _ActivityItem { diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index 5c06da38..861a7b88 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:userfront/i18n.dart'; import '../../../../core/notifiers/auth_notifier.dart'; +import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/widgets/language_selector.dart'; @@ -509,7 +510,7 @@ class _ProfilePageState extends ConsumerState { ListTile( leading: const Icon(Icons.home_outlined), title: Text(tr('ui.userfront.nav.dashboard')), - onTap: () => context.go('/'), + onTap: () => context.go(buildLocalizedHomePath(Uri.base)), ), ListTile( leading: const Icon(Icons.person_outline), @@ -1092,7 +1093,7 @@ class _ProfilePageState extends ConsumerState { IconButton( icon: const Icon(Icons.home_outlined), tooltip: tr('ui.userfront.nav.dashboard'), - onPressed: () => context.go('/'), + onPressed: () => context.go(buildLocalizedHomePath(Uri.base)), ), IconButton( icon: const Icon(Icons.qr_code_scanner), diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 8e86bc67..ae2f26d8 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -20,6 +20,8 @@ import 'features/profile/presentation/pages/profile_page.dart'; import 'core/services/auth_proxy_service.dart'; import 'core/services/auth_token_store.dart'; import 'core/services/logger_service.dart'; +import 'core/services/null_check_recovery.dart'; +import 'core/services/web_window.dart'; import 'core/notifiers/auth_notifier.dart'; import 'core/i18n/locale_gate.dart'; import 'core/i18n/locale_registry.dart'; @@ -31,6 +33,29 @@ import 'i18n.dart'; final _log = Logger('Main'); +void _attemptRecoveryFromNullCheck({ + required Object exception, + StackTrace? stackTrace, +}) { + final uri = Uri.base; + final target = computeNullCheckRecoveryTarget( + exception: exception, + uri: uri, + preferredLocaleCode: resolvePreferredLocaleCode(), + ); + if (target == null) { + return; + } + final path = uri.path; + + AuthProxyService.logError( + 'RECOVERY_NAV_NULL_CHECK path=$path target=$target uri=$uri', + error: exception, + stackTrace: stackTrace, + ); + webWindow.redirectTo(target); +} + Future _loadBundledFonts() async { const family = 'NotoSansKR'; final loader = FontLoader(family); @@ -57,11 +82,16 @@ void main() async { AuthProxyService.logError( "FLUTTER_ERROR: ${details.exception}\n${details.stack}", ); + _attemptRecoveryFromNullCheck( + exception: details.exception, + stackTrace: details.stack, + ); }; PlatformDispatcher.instance.onError = (error, stack) { _log.severe("PLATFORM_ERROR", error, stack); AuthProxyService.logError("PLATFORM_ERROR: $error\n$stack"); + _attemptRecoveryFromNullCheck(exception: error, stackTrace: stack); return true; }; @@ -107,6 +137,15 @@ final _router = GoRouter( debugLogDiagnostics: !kReleaseMode, refreshListenable: AuthNotifier.instance, routes: [ + GoRoute( + path: '/', + redirect: (context, state) { + return buildLocalizedHomePath( + state.uri, + preferredLocaleCode: resolvePreferredLocaleCode(), + ); + }, + ), ShellRoute( builder: (context, state, child) { final localeCode = @@ -116,10 +155,25 @@ final _router = GoRouter( routes: [ GoRoute( path: '/:locale', - // Note: Removed direct builder here to prevent interference with sub-routes + redirect: (context, state) { + // /{locale} 진입은 화면 렌더링 없이 단일 목적지로만 보냅니다. + if (state.uri.pathSegments.length != 1) { + return null; + } + final rawLocale = state.pathParameters['locale']; + final localeCode = normalizeLocaleCode(rawLocale); + final token = AuthTokenStore.getToken(); + final isLoggedIn = + (token != null && token.isNotEmpty) || + AuthTokenStore.usesCookie(); + if (!isLoggedIn) { + return buildSigninRedirectPath(localeCode, state.uri); + } + return '/$localeCode/dashboard'; + }, routes: [ GoRoute( - path: '', // Matches /:locale + path: 'dashboard', builder: (context, state) { return const DashboardScreen(); }, @@ -299,9 +353,16 @@ final _router = GoRouter( } if (!isLoggedIn) { + if (path == '/') { + return '/$requestedLocale/signin'; + } return buildSigninRedirectPath(requestedLocale, uri); } + if (path == '/') { + return '/$requestedLocale/dashboard'; + } + return null; }, ); @@ -311,11 +372,21 @@ class BaronSSOApp extends StatelessWidget { @override Widget build(BuildContext context) { + final localization = EasyLocalization.of(context); + final supportedLocales = + localization?.supportedLocales ?? + LocaleRegistry.supportedLocaleCodes + .map((code) => Locale(code)) + .toList(growable: false); + final delegates = localization?.delegates ?? const []; + final locale = + localization?.currentLocale ?? Locale(resolvePreferredLocaleCode()); + return MaterialApp.router( title: tr('ui.userfront.app_title'), - localizationsDelegates: context.localizationDelegates, - supportedLocales: context.supportedLocales, - locale: context.locale, + localizationsDelegates: delegates, + supportedLocales: supportedLocales, + locale: locale, theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base diff --git a/userfront/test/cookie_session_policy_test.dart b/userfront/test/cookie_session_policy_test.dart new file mode 100644 index 00000000..c927447c --- /dev/null +++ b/userfront/test/cookie_session_policy_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/features/auth/domain/cookie_session_policy.dart'; + +void main() { + group('cookie_session_policy', () { + test('토큰이 없고 login_challenge도 없으면 cookie 승격 허용', () { + expect( + shouldPromoteCookieSession(currentToken: null, loginChallenge: null), + isTrue, + ); + }); + + test('토큰이 이미 있으면 일반 로그인에서 cookie 승격 차단', () { + expect( + shouldPromoteCookieSession( + currentToken: 'existing-token', + loginChallenge: null, + ), + isFalse, + ); + }); + + test('OIDC login_challenge가 있으면 token 존재 시에도 cookie 승격 허용', () { + expect( + shouldPromoteCookieSession( + currentToken: 'existing-token', + loginChallenge: 'lc_123', + ), + isTrue, + ); + }); + + test('공백 토큰은 유효 토큰으로 간주하지 않음', () { + expect( + shouldPromoteCookieSession(currentToken: ' ', loginChallenge: null), + isTrue, + ); + }); + }); +} diff --git a/userfront/test/dashboard_screen_smoke_test.dart b/userfront/test/dashboard_screen_smoke_test.dart new file mode 100644 index 00000000..519bd003 --- /dev/null +++ b/userfront/test/dashboard_screen_smoke_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/core/services/auth_token_store.dart'; +import 'package:userfront/features/dashboard/presentation/dashboard_screen.dart'; + +void main() { + setUp(() { + AuthTokenStore.clear(); + }); + + tearDown(() { + AuthTokenStore.clear(); + }); + + testWidgets('대시보드는 로그인 토큰이 있으면 크래시 없이 기본 프레임을 렌더링한다', (tester) async { + final recordedErrors = []; + final previousOnError = FlutterError.onError; + FlutterError.onError = (details) { + final text = details.exceptionAsString(); + if (text.contains('A RenderFlex overflowed')) { + return; + } + recordedErrors.add(details); + }; + addTearDown(() { + FlutterError.onError = previousOnError; + }); + + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1920, 1080); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + AuthTokenStore.setToken('smoke-token', provider: 'ory'); + + await tester.pumpWidget( + const ProviderScope(child: MaterialApp(home: DashboardScreen())), + ); + await tester.pump(); + + expect(find.byType(Scaffold), findsOneWidget); + final hasNullCheckCrash = recordedErrors.any( + (error) => error.exceptionAsString().contains( + 'Null check operator used on a null value', + ), + ); + expect(hasNullCheckCrash, isFalse); + }); +} diff --git a/userfront/test/locale_utils_test.dart b/userfront/test/locale_utils_test.dart index 3137c41e..adb2be4f 100644 --- a/userfront/test/locale_utils_test.dart +++ b/userfront/test/locale_utils_test.dart @@ -127,5 +127,32 @@ void main() { '/ko/signin?redirect_url=https%3A%2F%2Fa.example.com%2Fcb&redirect_uri=https%3A%2F%2Fb.example.com%2Fcb', ); }); + + test('buildLocalizedHomePath keeps locale from uri', () { + expect(buildLocalizedHomePath(Uri.parse('/ko/signin')), '/ko/dashboard'); + expect(buildLocalizedHomePath(Uri.parse('/en/profile')), '/en/dashboard'); + }); + + test('buildLocalizedHomePath falls back to preferred locale', () { + expect( + buildLocalizedHomePath(Uri.parse('/signin'), preferredLocaleCode: 'ko'), + '/ko/dashboard', + ); + }); + + test('buildLocalizedSigninPath keeps locale from uri', () { + expect(buildLocalizedSigninPath(Uri.parse('/ko')), '/ko/signin'); + expect(buildLocalizedSigninPath(Uri.parse('/en/profile')), '/en/signin'); + }); + + test('buildLocalizedSigninPath falls back to preferred locale', () { + expect( + buildLocalizedSigninPath( + Uri.parse('/profile'), + preferredLocaleCode: 'ko', + ), + '/ko/signin', + ); + }); }); } diff --git a/userfront/test/login_navigation_race_test.dart b/userfront/test/login_navigation_race_test.dart new file mode 100644 index 00000000..7d55bc5f --- /dev/null +++ b/userfront/test/login_navigation_race_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:userfront/core/i18n/locale_registry.dart'; +import 'package:userfront/core/i18n/locale_utils.dart'; +import 'package:userfront/core/services/auth_token_store.dart'; + +class _AuthRefreshNotifier extends ChangeNotifier { + void refresh() => notifyListeners(); +} + +Widget _buildRaceTestApp(_AuthRefreshNotifier notifier) { + final router = GoRouter( + initialLocation: '/ko/signin', + refreshListenable: notifier, + routes: [ + GoRoute( + path: '/:locale', + builder: (context, state) => const Scaffold(body: Text('locale-root')), + routes: [ + GoRoute( + path: 'dashboard', + builder: (context, state) => const Scaffold(body: Text('home')), + ), + GoRoute( + path: 'signin', + builder: (context, state) { + return Scaffold( + body: Center( + child: FilledButton( + onPressed: () { + AuthTokenStore.setToken('race-token', provider: 'ory'); + notifier.refresh(); + context.go('/ko/dashboard'); + }, + child: const Text('login'), + ), + ), + ); + }, + ), + ], + ), + ], + redirect: (context, state) { + final requestedLocale = extractLocaleFromPath(state.uri); + if (requestedLocale == null) { + return buildLocalizedPath(resolvePreferredLocaleCode(), state.uri); + } + + final token = AuthTokenStore.getToken(); + final isLoggedIn = + (token != null && token.isNotEmpty) || AuthTokenStore.usesCookie(); + final path = stripLocalePath(state.uri); + if (path == '/signin') { + return null; + } + if (!isLoggedIn) { + return buildSigninRedirectPath(requestedLocale, state.uri); + } + if (path == '/') { + return '/$requestedLocale/dashboard'; + } + return null; + }, + ); + + return MaterialApp.router(routerConfig: router); +} + +void main() { + setUp(() { + LocaleRegistry.setSupportedLocaleCodesForTest(['en', 'ko']); + AuthTokenStore.clear(); + }); + + tearDown(() { + AuthTokenStore.clear(); + LocaleRegistry.resetForTest(); + }); + + testWidgets('로그인 성공 이벤트(notify + go) 동시 호출 시 홈으로 안정적으로 이동', (tester) async { + final notifier = _AuthRefreshNotifier(); + await tester.pumpWidget(_buildRaceTestApp(notifier)); + await tester.pumpAndSettle(); + expect(find.text('login'), findsOneWidget); + + await tester.tap(find.text('login')); + await tester.pumpAndSettle(); + + expect(find.text('home'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/userfront/test/null_check_recovery_test.dart b/userfront/test/null_check_recovery_test.dart new file mode 100644 index 00000000..9b7dffed --- /dev/null +++ b/userfront/test/null_check_recovery_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/core/i18n/locale_registry.dart'; +import 'package:userfront/core/services/null_check_recovery.dart'; + +void main() { + setUp(() { + LocaleRegistry.setSupportedLocaleCodesForTest(['en', 'ko']); + }); + + tearDown(() { + LocaleRegistry.resetForTest(); + }); + + test('Null check 오류 + 루트(/)면 선호 로케일 signin으로 복구', () { + final target = computeNullCheckRecoveryTarget( + exception: Exception('Null check operator used on a null value'), + uri: Uri.parse('https://sss.hmac.kr/'), + preferredLocaleCode: 'ko', + ); + + expect(target, '/ko/signin'); + }); + + test('Null check 오류 + /ko면 /ko/signin으로 복구', () { + final target = computeNullCheckRecoveryTarget( + exception: Exception('Null check operator used on a null value'), + uri: Uri.parse('https://sss.hmac.kr/ko'), + preferredLocaleCode: 'en', + ); + + expect(target, '/ko/signin'); + }); + + test('이미 /ko/signin이면 복구 이동하지 않음', () { + final target = computeNullCheckRecoveryTarget( + exception: Exception('Null check operator used on a null value'), + uri: Uri.parse('https://sss.hmac.kr/ko/signin'), + preferredLocaleCode: 'ko', + ); + + expect(target, isNull); + }); + + test('Null check 오류여도 /ko/profile에서는 복구 이동하지 않음', () { + final target = computeNullCheckRecoveryTarget( + exception: Exception('Null check operator used on a null value'), + uri: Uri.parse('https://sss.hmac.kr/ko/profile'), + preferredLocaleCode: 'ko', + ); + + expect(target, isNull); + }); + + test('다른 오류 메시지면 복구 이동하지 않음', () { + final target = computeNullCheckRecoveryTarget( + exception: Exception('Some other error'), + uri: Uri.parse('https://sss.hmac.kr/ko'), + preferredLocaleCode: 'ko', + ); + + expect(target, isNull); + }); +} diff --git a/userfront/test/router_redirect_widget_test.dart b/userfront/test/router_redirect_widget_test.dart index a11580f8..87c0f8e3 100644 --- a/userfront/test/router_redirect_widget_test.dart +++ b/userfront/test/router_redirect_widget_test.dart @@ -11,8 +11,28 @@ Widget _buildTestApp(String initialLocation) { routes: [ GoRoute( path: '/:locale', - builder: (context, state) => const Scaffold(body: Text('root')), + redirect: (context, state) { + if (state.uri.pathSegments.length != 1) { + return null; + } + final localeCode = normalizeLocaleCode( + state.pathParameters['locale'], + ); + final token = AuthTokenStore.getToken(); + final isLoggedIn = + (token != null && token.isNotEmpty) || + AuthTokenStore.usesCookie(); + if (!isLoggedIn) { + return buildSigninRedirectPath(localeCode, state.uri); + } + return '/$localeCode/dashboard'; + }, routes: [ + GoRoute( + path: 'dashboard', + builder: (context, state) => + const Scaffold(body: Text('dashboard-page')), + ), GoRoute( path: 'signin', builder: (context, state) { @@ -57,8 +77,9 @@ Widget _buildTestApp(String initialLocation) { return buildLocalizedPath(resolvePreferredLocaleCode(), state.uri); } + final token = AuthTokenStore.getToken(); final isLoggedIn = - AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie(); + (token != null && token.isNotEmpty) || AuthTokenStore.usesCookie(); final path = stripLocalePath(state.uri); final isPublicPath = path == '/signin' || path == '/login'; if (isPublicPath) { @@ -85,6 +106,25 @@ void main() { LocaleRegistry.resetForTest(); }); + testWidgets( + '루트 경로: /{locale} 로 접근 시 /{locale}/signin 으로 리다이렉트되어야 한다 (버그: 화면 렌더링 안됨)', + (tester) async { + await tester.pumpWidget(_buildTestApp('/ko')); + await tester.pumpAndSettle(); + + expect(find.textContaining('signin|'), findsOneWidget); + }, + ); + + testWidgets('로그인 상태에서 /{locale} 접근 시 dashboard로 이동', (tester) async { + AuthTokenStore.setToken('root-token', provider: 'ory'); + await tester.pumpWidget(_buildTestApp('/ko')); + await tester.pumpAndSettle(); + + expect(find.text('dashboard-page'), findsOneWidget); + expect(find.textContaining('signin|'), findsNothing); + }); + testWidgets('/login: login_challenge와 redirect_uri를 전달', (tester) async { final encodedRedirectUri = Uri.encodeComponent( 'https://rp.example.com/callback?x=1', @@ -153,6 +193,15 @@ void main() { expect(find.textContaining('signin|'), findsNothing); }); + testWidgets('빈 토큰은 로그인으로 간주하지 않고 signin으로 리다이렉트', (tester) async { + AuthTokenStore.setToken('', provider: 'ory'); + await tester.pumpWidget(_buildTestApp('/ko/profile')); + await tester.pumpAndSettle(); + + expect(find.textContaining('signin|'), findsOneWidget); + expect(find.text('profile-page'), findsNothing); + }); + testWidgets('로그인 후 같은 브라우저 새 창/팝업에서도 세션이 유지된다', (tester) async { await tester.pumpWidget(_buildTestApp('/en/signin')); await tester.pumpAndSettle();