1
0
forked from baron/baron-sso

userfront 로그인 후 /dashboard로 이동하게 변경

This commit is contained in:
Lectom C Han
2026-02-23 22:06:00 +09:00
parent 19d3bade30
commit 2bdfc2eb51
37 changed files with 1504 additions and 222 deletions

View File

@@ -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개 계층으로 분리됩니다.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,13 @@
# AGENTS 가이드 (Baron SSO)
## 버그 수정 절차 대원칙 (강제)
- 버그 대응 시 **재현 테스트를 먼저 작성**합니다.
- 재현 테스트가 실패하는 상태를 확인한 뒤에만 수정 작업을 시작합니다.
- 수정 후에는 테스트를 반복 실행하여 재현 테스트가 안정적으로 통과할 때까지 계속 보완합니다.
- 재현 테스트 없이 “감으로 수정”하거나, 실패 테스트를 남긴 채 성공으로 보고하지 않습니다.
- 이슈 종료 전에는 최소 1회 이상 실제 사용자 경로(예: 로그인/새로고침/리다이렉트)를 확인합니다.
- 테스트/원인/조치 내역은 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)에 반영합니다.
## 목적
- 인증/인가 허브로서 **Backend + Ory Stack** 중심 아키텍처를 유지
- 사용자 플로우(UserFront)와 관리 플로우(Admin/DevFront)를 명확히 분리

View File

@@ -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` | 오류/예외/거부 경로 검증 |

View File

@@ -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` | 기본 앱 렌더링 스모크 검증 |

View File

@@ -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 동시 수집

240
scripts/map_wasm_stack.py Normal file
View File

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

View File

@@ -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<LocaleGate> {
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<void> _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<void> _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'));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<CreateUserScreen> {
// 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<CreateUserScreen> {
backgroundColor: Colors.red,
),
);
context.go('/'); // Kick out
context.go(buildLocalizedHomePath(Uri.base)); // Kick out
}
}
}
@@ -178,7 +179,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
title: const Text('Create User'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
),
),
body: Center(

View File

@@ -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<UserManagementScreen>
);
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<UserManagementScreen>
backgroundColor: Colors.red,
),
);
context.go('/');
context.go(buildLocalizedHomePath(Uri.base));
}
}
}
@@ -365,7 +366,7 @@ class _UserManagementScreenState extends State<UserManagementScreen>
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,

View File

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

View File

@@ -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<ApproveQrScreen> {
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<ApproveQrScreen> {
}
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<ApproveQrScreen> {
// 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<ApproveQrScreen> {
@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<ApproveQrScreen> {
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"),
),
],

View File

@@ -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<ConsentScreen> {
if (redirectTo != null) {
webWindow.redirectTo(redirectTo);
} else {
if (mounted) context.go('/');
if (mounted) context.go(buildLocalizedHomePath(Uri.base));
}
} catch (e) {
setState(() => _isSubmitting = false);

View File

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

View File

@@ -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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
}
Future<void> _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<LoginScreen>
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<LoginScreen>
Future<void> _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<LoginScreen>
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<LoginScreen>
}
if (mounted) {
context.go('/');
_goLocalizedHomeOnce();
}
}
void _goLocalizedHomeOnce() {
if (!mounted || _localNavigationCompleted) {
return;
}
_localNavigationCompleted = true;
context.go(buildLocalizedHomePath(Uri.base));
}
Future<void> _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<LoginScreen>
}
Future<bool> _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<LoginScreen>
}
}
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<LoginScreen>
}
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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
"[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<LoginScreen>
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<LoginScreen>
}
// [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<LoginScreen>
return;
}
AuthNotifier.instance.notify();
if (mounted) {
context.go('/');
_goLocalizedHomeOnce();
}
} catch (globalErr) {
// ignore
@@ -1237,7 +1278,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
title: Text(_verificationPageTitle),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
),
),
body: _buildVerificationResultView(),

View File

@@ -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'),

View File

@@ -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<ResetPasswordScreen> {
backgroundColor: Colors.green,
),
);
context.go('/signin');
context.go(buildLocalizedSigninPath(Uri.base));
}
} catch (e) {
if (mounted) {

View File

@@ -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<SignupScreen> {
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')),
),
],

View File

@@ -133,7 +133,8 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
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);

View File

@@ -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<DashboardScreen> {
bool _auditLoadingMore = false;
bool _isRevoking = false;
bool _redirectingToSignin = false;
bool _authBootstrapInProgress = false;
bool _showAllActivities = false;
final Set<String> _revokedClientIds = {};
@@ -47,11 +50,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
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<DashboardScreen> {
if (closeOnTap) {
Navigator.of(context).pop();
}
context.go('/');
context.go(buildLocalizedHomePath(Uri.base));
},
),
ListTile(
@@ -302,8 +304,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Future<void> _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<DashboardScreen> {
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<DashboardScreen> {
@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<DashboardScreen> {
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<DashboardScreen> {
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<DashboardScreen> {
}
// 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려)
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<DashboardScreen> {
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<DashboardScreen> {
),
);
}
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<DashboardScreen> {
}
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<DashboardScreen> {
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<void> _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<bool> _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 {

View File

@@ -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<ProfilePage> {
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<ProfilePage> {
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),

View File

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

View File

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

View File

@@ -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 = <FlutterErrorDetails>[];
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);
});
}

View File

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

View File

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

View File

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

View File

@@ -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();