forked from baron/baron-sso
feat(i18n): apply ORY bypass whitelist policy and add error-code tests
This commit is contained in:
33
backend/cmd/server/error_handler.go
Normal file
33
backend/cmd/server/error_handler.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/response"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func newErrorHandler(appEnv string) fiber.ErrorHandler {
|
||||
return func(c *fiber.Ctx, err error) error {
|
||||
code := fiber.StatusInternalServerError
|
||||
|
||||
var e *fiber.Error
|
||||
if errors.As(err, &e) {
|
||||
code = e.Code
|
||||
}
|
||||
|
||||
if appEnv == "production" || appEnv == "stage" {
|
||||
if code >= 500 {
|
||||
slog.Error("Internal Server Error",
|
||||
"error", err.Error(),
|
||||
"path", c.Path(),
|
||||
"method", c.Method(),
|
||||
)
|
||||
return response.Error(c, code, response.StatusCode(code), "Internal Server Error")
|
||||
}
|
||||
}
|
||||
|
||||
return response.Error(c, code, response.StatusCode(code), err.Error())
|
||||
}
|
||||
}
|
||||
118
backend/cmd/server/error_handler_test.go
Normal file
118
backend/cmd/server/error_handler_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func decodeJSONBody(t *testing.T, resp *http.Response) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("failed to decode response body: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestNewErrorHandler_ProductionMasksServerError(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{ErrorHandler: newErrorHandler("production")})
|
||||
app.Get("/boom", func(c *fiber.Ctx) error {
|
||||
return errors.New("database connection failed")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body := decodeJSONBody(t, resp)
|
||||
if body["error"] != "Internal Server Error" {
|
||||
t.Fatalf("unexpected error message: %v", body["error"])
|
||||
}
|
||||
if body["code"] != "internal_error" {
|
||||
t.Fatalf("unexpected error code: %v", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewErrorHandler_ProductionPassesClientError(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{ErrorHandler: newErrorHandler("production")})
|
||||
app.Get("/bad", func(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "bad request payload")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/bad", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body := decodeJSONBody(t, resp)
|
||||
if body["error"] != "bad request payload" {
|
||||
t.Fatalf("unexpected error message: %v", body["error"])
|
||||
}
|
||||
if body["code"] != "bad_request" {
|
||||
t.Fatalf("unexpected error code: %v", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewErrorHandler_DevelopmentReturnsOriginalServerError(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{ErrorHandler: newErrorHandler("dev")})
|
||||
app.Get("/boom", func(c *fiber.Ctx) error {
|
||||
return errors.New("database connection failed")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body := decodeJSONBody(t, resp)
|
||||
if body["error"] != "database connection failed" {
|
||||
t.Fatalf("unexpected error message: %v", body["error"])
|
||||
}
|
||||
if body["code"] != "internal_error" {
|
||||
t.Fatalf("unexpected error code: %v", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewErrorHandler_MapsUnauthorizedCode(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{ErrorHandler: newErrorHandler("production")})
|
||||
app.Get("/unauthorized", func(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "missing token")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/unauthorized", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body := decodeJSONBody(t, resp)
|
||||
if body["code"] != "invalid_session" {
|
||||
t.Fatalf("unexpected error code: %v", body["code"])
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,9 @@ import (
|
||||
"baron-sso-backend/internal/idp"
|
||||
"baron-sso-backend/internal/logger"
|
||||
"baron-sso-backend/internal/middleware"
|
||||
"baron-sso-backend/internal/response"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/validator"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
@@ -272,34 +270,7 @@ func main() {
|
||||
AppName: "Baron SSO Backend",
|
||||
DisableStartupMessage: true, // Clean logs
|
||||
ReadBufferSize: 32768, // 32KB로 증가 (긴 OIDC 챌린지 대응)
|
||||
// Global Error Handler for Production Masking
|
||||
ErrorHandler: func(c *fiber.Ctx, err error) error {
|
||||
// Default status code
|
||||
code := fiber.StatusInternalServerError
|
||||
|
||||
// Check if it's a known fiber.Error
|
||||
var e *fiber.Error
|
||||
if errors.As(err, &e) {
|
||||
code = e.Code
|
||||
}
|
||||
|
||||
// In production or stage, mask detailed 500+ errors
|
||||
if appEnv == "production" || appEnv == "stage" {
|
||||
if code >= 500 {
|
||||
// Log the actual error for developers
|
||||
slog.Error("Internal Server Error",
|
||||
"error", err.Error(),
|
||||
"path", c.Path(),
|
||||
"method", c.Method(),
|
||||
)
|
||||
// Return masked message
|
||||
return response.Error(c, code, response.StatusCode(code), "Internal Server Error")
|
||||
}
|
||||
}
|
||||
|
||||
// For development or non-500 errors, return the actual error message
|
||||
return response.Error(c, code, response.StatusCode(code), err.Error())
|
||||
},
|
||||
ErrorHandler: newErrorHandler(appEnv),
|
||||
})
|
||||
|
||||
// Middleware
|
||||
|
||||
@@ -849,6 +849,11 @@ components:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
code:
|
||||
type: string
|
||||
details:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
MessageResponse:
|
||||
type: object
|
||||
|
||||
85
backend/internal/response/error_response_test.go
Normal file
85
backend/internal/response/error_response_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func parseBody(t *testing.T, resp *http.Response) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("failed to parse response body: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestErrorWithDetailsResponseShape(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Get("/test", func(c *fiber.Ctx) error {
|
||||
return ErrorWithDetails(
|
||||
c,
|
||||
fiber.StatusConflict,
|
||||
"conflict",
|
||||
"resource already exists",
|
||||
map[string]any{"field": "email"},
|
||||
)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body := parseBody(t, resp)
|
||||
if body["error"] != "resource already exists" {
|
||||
t.Fatalf("unexpected error value: %v", body["error"])
|
||||
}
|
||||
if body["code"] != "conflict" {
|
||||
t.Fatalf("unexpected code value: %v", body["code"])
|
||||
}
|
||||
|
||||
details, ok := body["details"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("details should be map, got: %T", body["details"])
|
||||
}
|
||||
if details["field"] != "email" {
|
||||
t.Fatalf("unexpected details value: %v", details["field"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCodeMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
expected string
|
||||
}{
|
||||
{name: "bad request", status: fiber.StatusBadRequest, expected: "bad_request"},
|
||||
{name: "unauthorized", status: fiber.StatusUnauthorized, expected: "invalid_session"},
|
||||
{name: "forbidden", status: fiber.StatusForbidden, expected: "forbidden"},
|
||||
{name: "not found", status: fiber.StatusNotFound, expected: "not_found"},
|
||||
{name: "conflict", status: fiber.StatusConflict, expected: "conflict"},
|
||||
{name: "too many requests", status: fiber.StatusTooManyRequests, expected: "rate_limited"},
|
||||
{name: "service unavailable", status: fiber.StatusServiceUnavailable, expected: "service_unavailable"},
|
||||
{name: "fallback", status: fiber.StatusInternalServerError, expected: "internal_error"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := StatusCode(tc.status)
|
||||
if got != tc.expected {
|
||||
t.Fatalf("unexpected code: got=%s expected=%s", got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -86,11 +86,13 @@
|
||||
```json
|
||||
{
|
||||
"error": "사람이 읽을 수 있는 에러 메시지",
|
||||
"code": "MACHINE_READABLE_CODE", // 선택적 (예: USER_NOT_FOUND, HYDRA_CONN_ERR)
|
||||
"code": "MACHINE_READABLE_CODE", // 1차 표준 필드 (예: invalid_session, rate_limited)
|
||||
"details": { ... } // 선택적 (Validation error 필드별 상세 등)
|
||||
}
|
||||
```
|
||||
|
||||
- `code`는 프론트 분기 기준이므로 신규/변경 API에서는 포함을 기본값으로 합니다.
|
||||
|
||||
## 5. 헤더 및 보안 (Headers & Security)
|
||||
|
||||
### 5.1 인증 (Authentication)
|
||||
|
||||
12
docs/i18n.md
12
docs/i18n.md
@@ -165,6 +165,18 @@ TOML에서는 `[Section]`을 사용하여 계층을 표현합니다.
|
||||
#### 5.2.4 Flutter (User) 구현 가이드
|
||||
Flutter는 런타임에 TOML을 파싱하기 위해 `toml` 패키지와 `easy_localization`의 커스텀 로더를 사용합니다.
|
||||
|
||||
#### 5.2.5 UserFront 에러 표시 정책 (Production)
|
||||
UserFront(`/error`)는 프로덕션에서 다음 규칙으로 에러를 표시합니다.
|
||||
|
||||
1. **Internal whitelist 코드**
|
||||
- `msg.userfront.error.whitelist.{code}` 메시지를 노출합니다.
|
||||
2. **ORY bypass 코드**
|
||||
- `msg.userfront.error.ory.{code}` 메시지를 노출합니다.
|
||||
3. **그 외 코드**
|
||||
- `unknown_error`로 처리하고 일반 안내 문구(`msg.userfront.error.detail_contact`)를 노출합니다.
|
||||
|
||||
코드 집합은 `userfront/lib/core/constants/error_whitelist.dart`를 단일 기준으로 유지합니다.
|
||||
|
||||
* **패키지 추가 (`pubspec.yaml`)**:
|
||||
```yaml
|
||||
dependencies:
|
||||
|
||||
72
docs/userfront_error_handling_policy.md
Normal file
72
docs/userfront_error_handling_policy.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# UserFront Error Handling Policy
|
||||
|
||||
## 1. 목적
|
||||
- UserFront의 `/error` 화면에서 에러 코드 노출 정책을 일관되게 유지합니다.
|
||||
- Ory에서 전달되는 표준 에러 코드는 별도 bypass 규칙으로 처리합니다.
|
||||
- 내부 코드와 번역 리소스의 누락을 자동 검증합니다.
|
||||
|
||||
## 2. 처리 원칙
|
||||
1. 프로덕션에서 에러 분기 기준은 `error` 문자열이 아니라 `code`입니다.
|
||||
2. `ORY error code`는 bypass 규칙으로 처리합니다.
|
||||
3. ORY 코드 외 항목은 내부 whitelist를 기준으로 노출합니다.
|
||||
4. whitelist/bypass에 없는 코드는 `unknown_error`로 처리합니다.
|
||||
|
||||
## 3. 코드 분류
|
||||
기준 파일: `userfront/lib/core/constants/error_whitelist.dart`
|
||||
|
||||
### 3.1 Internal whitelist
|
||||
- `settings_disabled`
|
||||
- `invalid_session`
|
||||
- `verification_required`
|
||||
- `recovery_expired`
|
||||
- `recovery_invalid`
|
||||
- `rate_limited`
|
||||
- `not_found`
|
||||
- `bad_request`
|
||||
- `password_or_email_mismatch`
|
||||
|
||||
### 3.2 ORY bypass
|
||||
- `access_denied`
|
||||
- `consent_required`
|
||||
- `interaction_required`
|
||||
- `invalid_client`
|
||||
- `invalid_grant`
|
||||
- `invalid_request`
|
||||
- `invalid_scope`
|
||||
- `login_required`
|
||||
- `request_forbidden`
|
||||
- `server_error`
|
||||
- `temporarily_unavailable`
|
||||
- `unauthorized_client`
|
||||
- `unsupported_response_type`
|
||||
|
||||
## 4. i18n 키 구조
|
||||
- 내부 whitelist: `msg.userfront.error.whitelist.{code}`
|
||||
- ORY bypass: `msg.userfront.error.ory.{code}`
|
||||
|
||||
리소스는 아래 파일 모두에 키가 있어야 합니다.
|
||||
- `locales/template.toml`
|
||||
- `locales/ko.toml`
|
||||
- `locales/en.toml`
|
||||
- `userfront/assets/translations/template.toml`
|
||||
- `userfront/assets/translations/ko.toml`
|
||||
- `userfront/assets/translations/en.toml`
|
||||
|
||||
## 5. 검증
|
||||
키 누락 검증 스크립트:
|
||||
|
||||
```bash
|
||||
./scripts/verify_userfront_error_i18n.sh
|
||||
```
|
||||
|
||||
화면 동작 테스트:
|
||||
|
||||
```bash
|
||||
cd userfront
|
||||
flutter test test/error_screen_test.dart
|
||||
```
|
||||
|
||||
## 6. 관련 이슈
|
||||
- `#164` `[UserFront] 에러 노출 whitelist 정의 및 적용`
|
||||
- `#259` `백엔드 i18n/에러 메시지 fallback 정책 재정리 및 반영 계획 수립`
|
||||
- `#260` `[Backend] 에러 응답 code 통일 구현 계획 (phase rollout)`
|
||||
@@ -356,6 +356,29 @@ type = "Type"
|
||||
|
||||
[msg.userfront.error.whitelist]
|
||||
settings_disabled = "Account settings are currently unavailable."
|
||||
invalid_session = "Your session has expired. Please sign in again."
|
||||
verification_required = "Additional verification is required. Please follow the instructions."
|
||||
recovery_expired = "The recovery link has expired. Please request a new one."
|
||||
recovery_invalid = "The recovery link is invalid."
|
||||
rate_limited = "Too many requests. Please try again later."
|
||||
not_found = "The requested page could not be found."
|
||||
bad_request = "Please check your input."
|
||||
password_or_email_mismatch = "Email or password does not match."
|
||||
|
||||
[msg.userfront.error.ory]
|
||||
access_denied = "The user denied the consent request."
|
||||
consent_required = "Consent is required to continue."
|
||||
interaction_required = "Additional interaction is required. Please try again."
|
||||
invalid_client = "Client authentication failed."
|
||||
invalid_grant = "The authorization grant is invalid or expired."
|
||||
invalid_request = "The request is invalid."
|
||||
invalid_scope = "The requested scope is invalid."
|
||||
login_required = "Login is required."
|
||||
request_forbidden = "The request was forbidden."
|
||||
server_error = "An authentication server error occurred."
|
||||
temporarily_unavailable = "The authentication server is temporarily unavailable."
|
||||
unauthorized_client = "The client is not authorized for this request."
|
||||
unsupported_response_type = "The response type is not supported."
|
||||
|
||||
[msg.userfront.forgot]
|
||||
description = "Description"
|
||||
|
||||
@@ -356,6 +356,29 @@ type = "오류 종류: {{type}}"
|
||||
|
||||
[msg.userfront.error.whitelist]
|
||||
settings_disabled = "현재 계정 설정 화면은 준비 중입니다."
|
||||
invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요."
|
||||
verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요."
|
||||
recovery_expired = "재설정 링크가 만료되었습니다. 다시 요청해 주세요."
|
||||
recovery_invalid = "재설정 링크가 유효하지 않습니다."
|
||||
rate_limited = "요청이 많습니다. 잠시 후 다시 시도해 주세요."
|
||||
not_found = "요청한 페이지를 찾을 수 없습니다."
|
||||
bad_request = "입력값을 확인해 주세요."
|
||||
password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다."
|
||||
|
||||
[msg.userfront.error.ory]
|
||||
access_denied = "사용자가 동의를 거부했습니다."
|
||||
consent_required = "앱 접근 동의가 필요합니다."
|
||||
interaction_required = "추가 상호작용이 필요합니다. 다시 시도해 주세요."
|
||||
invalid_client = "클라이언트 인증 정보가 유효하지 않습니다."
|
||||
invalid_grant = "인증 요청이 만료되었거나 유효하지 않습니다."
|
||||
invalid_request = "잘못된 요청입니다."
|
||||
invalid_scope = "요청한 권한 범위가 유효하지 않습니다."
|
||||
login_required = "로그인이 필요합니다."
|
||||
request_forbidden = "요청이 거부되었습니다."
|
||||
server_error = "인증 서버 오류가 발생했습니다."
|
||||
temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습니다."
|
||||
unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다."
|
||||
unsupported_response_type = "지원하지 않는 응답 타입입니다."
|
||||
|
||||
[msg.userfront.forgot]
|
||||
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
|
||||
|
||||
@@ -356,6 +356,29 @@ type = ""
|
||||
|
||||
[msg.userfront.error.whitelist]
|
||||
settings_disabled = ""
|
||||
invalid_session = ""
|
||||
verification_required = ""
|
||||
recovery_expired = ""
|
||||
recovery_invalid = ""
|
||||
rate_limited = ""
|
||||
not_found = ""
|
||||
bad_request = ""
|
||||
password_or_email_mismatch = ""
|
||||
|
||||
[msg.userfront.error.ory]
|
||||
access_denied = ""
|
||||
consent_required = ""
|
||||
interaction_required = ""
|
||||
invalid_client = ""
|
||||
invalid_grant = ""
|
||||
invalid_request = ""
|
||||
invalid_scope = ""
|
||||
login_required = ""
|
||||
request_forbidden = ""
|
||||
server_error = ""
|
||||
temporarily_unavailable = ""
|
||||
unauthorized_client = ""
|
||||
unsupported_response_type = ""
|
||||
|
||||
[msg.userfront.forgot]
|
||||
description = ""
|
||||
|
||||
98
scripts/verify_userfront_error_i18n.sh
Executable file
98
scripts/verify_userfront_error_i18n.sh
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
whitelist_file="$repo_root/userfront/lib/core/constants/error_whitelist.dart"
|
||||
|
||||
extract_internal_codes() {
|
||||
awk '
|
||||
/internalErrorWhitelistMessages/ { in_map=1; next }
|
||||
in_map && /\};/ { in_map=0 }
|
||||
in_map {
|
||||
if (match($0, /'\''([a-z0-9_]+)'\''[[:space:]]*:/, m)) {
|
||||
print m[1]
|
||||
}
|
||||
}
|
||||
' "$whitelist_file"
|
||||
}
|
||||
|
||||
extract_ory_codes() {
|
||||
awk '
|
||||
/oryBypassErrorCodes/ { in_set=1; next }
|
||||
in_set && /\};/ { in_set=0 }
|
||||
in_set {
|
||||
if (match($0, /'\''([a-z0-9_]+)'\''/, m)) {
|
||||
print m[1]
|
||||
}
|
||||
}
|
||||
' "$whitelist_file"
|
||||
}
|
||||
|
||||
extract_toml_section_keys() {
|
||||
local file="$1"
|
||||
local section="$2"
|
||||
awk -v section="$section" '
|
||||
/^[[:space:]]*\[[^]]+\][[:space:]]*$/ {
|
||||
current=$0
|
||||
gsub(/^[[:space:]]*\[/, "", current)
|
||||
gsub(/\][[:space:]]*$/, "", current)
|
||||
in_section=(current == section)
|
||||
next
|
||||
}
|
||||
|
||||
in_section {
|
||||
if (match($0, /^[[:space:]]*([a-zA-Z0-9_]+)[[:space:]]*=/, m)) {
|
||||
print m[1]
|
||||
}
|
||||
}
|
||||
' "$file"
|
||||
}
|
||||
|
||||
check_section_keys() {
|
||||
local expected_keys="$1"
|
||||
local section="$2"
|
||||
local file="$3"
|
||||
local missing
|
||||
|
||||
missing="$(comm -23 \
|
||||
<(printf '%s\n' "$expected_keys" | sed '/^$/d' | sort -u) \
|
||||
<(extract_toml_section_keys "$file" "$section" | sort -u) \
|
||||
)"
|
||||
|
||||
if [[ -n "$missing" ]]; then
|
||||
echo "[FAIL] ${file} -> [${section}] 누락 키:"
|
||||
printf '%s\n' "$missing" | sed 's/^/ - /'
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "[OK] ${file} -> [${section}]"
|
||||
}
|
||||
|
||||
internal_codes="$(extract_internal_codes)"
|
||||
ory_codes="$(extract_ory_codes)"
|
||||
|
||||
if [[ -z "$internal_codes" ]]; then
|
||||
echo "[FAIL] 내부 whitelist 코드를 찾지 못했습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$ory_codes" ]]; then
|
||||
echo "[FAIL] ORY bypass 코드를 찾지 못했습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
files=(
|
||||
"$repo_root/locales/template.toml"
|
||||
"$repo_root/locales/ko.toml"
|
||||
"$repo_root/locales/en.toml"
|
||||
"$repo_root/userfront/assets/translations/template.toml"
|
||||
"$repo_root/userfront/assets/translations/ko.toml"
|
||||
"$repo_root/userfront/assets/translations/en.toml"
|
||||
)
|
||||
|
||||
for file in "${files[@]}"; do
|
||||
check_section_keys "$internal_codes" "msg.userfront.error.whitelist" "$file"
|
||||
check_section_keys "$ory_codes" "msg.userfront.error.ory" "$file"
|
||||
done
|
||||
|
||||
echo "모든 에러 코드 i18n 키 검증이 완료되었습니다."
|
||||
@@ -80,6 +80,29 @@ type = "Type"
|
||||
|
||||
[msg.userfront.error.whitelist]
|
||||
settings_disabled = "Account settings are currently unavailable."
|
||||
invalid_session = "Your session has expired. Please sign in again."
|
||||
verification_required = "Additional verification is required. Please follow the instructions."
|
||||
recovery_expired = "The recovery link has expired. Please request a new one."
|
||||
recovery_invalid = "The recovery link is invalid."
|
||||
rate_limited = "Too many requests. Please try again later."
|
||||
not_found = "The requested page could not be found."
|
||||
bad_request = "Please check your input."
|
||||
password_or_email_mismatch = "Email or password does not match."
|
||||
|
||||
[msg.userfront.error.ory]
|
||||
access_denied = "The user denied the consent request."
|
||||
consent_required = "Consent is required to continue."
|
||||
interaction_required = "Additional interaction is required. Please try again."
|
||||
invalid_client = "Client authentication failed."
|
||||
invalid_grant = "The authorization grant is invalid or expired."
|
||||
invalid_request = "The request is invalid."
|
||||
invalid_scope = "The requested scope is invalid."
|
||||
login_required = "Login is required."
|
||||
request_forbidden = "The request was forbidden."
|
||||
server_error = "An authentication server error occurred."
|
||||
temporarily_unavailable = "The authentication server is temporarily unavailable."
|
||||
unauthorized_client = "The client is not authorized for this request."
|
||||
unsupported_response_type = "The response type is not supported."
|
||||
|
||||
[msg.userfront.forgot]
|
||||
description = "Description"
|
||||
@@ -96,7 +119,7 @@ link_send_failed = "Link Send Failed"
|
||||
link_sent_email = "Link Sent Email"
|
||||
link_sent_phone = "Link Sent Phone"
|
||||
link_timeout = "Time expired."
|
||||
no_account = "No Account"
|
||||
no_account = "New to Baron?"
|
||||
oidc_failed = "OIDC Failed"
|
||||
qr_expired = "Time expired."
|
||||
qr_init_failed = "QR Init Failed"
|
||||
@@ -297,7 +320,7 @@ save = "Save"
|
||||
search = "Search"
|
||||
show_more = "Show More"
|
||||
language = "Language"
|
||||
language_ko = "Korean"
|
||||
language_ko = "한국어"
|
||||
language_en = "English"
|
||||
theme_dark = "Dark"
|
||||
theme_light = "Light"
|
||||
@@ -534,3 +557,4 @@ verify = "Verify"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "Action"
|
||||
|
||||
|
||||
@@ -80,6 +80,29 @@ type = "오류 종류: {type}"
|
||||
|
||||
[msg.userfront.error.whitelist]
|
||||
settings_disabled = "현재 계정 설정 화면은 준비 중입니다."
|
||||
invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요."
|
||||
verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요."
|
||||
recovery_expired = "재설정 링크가 만료되었습니다. 다시 요청해 주세요."
|
||||
recovery_invalid = "재설정 링크가 유효하지 않습니다."
|
||||
rate_limited = "요청이 많습니다. 잠시 후 다시 시도해 주세요."
|
||||
not_found = "요청한 페이지를 찾을 수 없습니다."
|
||||
bad_request = "입력값을 확인해 주세요."
|
||||
password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다."
|
||||
|
||||
[msg.userfront.error.ory]
|
||||
access_denied = "사용자가 동의를 거부했습니다."
|
||||
consent_required = "앱 접근 동의가 필요합니다."
|
||||
interaction_required = "추가 상호작용이 필요합니다. 다시 시도해 주세요."
|
||||
invalid_client = "클라이언트 인증 정보가 유효하지 않습니다."
|
||||
invalid_grant = "인증 요청이 만료되었거나 유효하지 않습니다."
|
||||
invalid_request = "잘못된 요청입니다."
|
||||
invalid_scope = "요청한 권한 범위가 유효하지 않습니다."
|
||||
login_required = "로그인이 필요합니다."
|
||||
request_forbidden = "요청이 거부되었습니다."
|
||||
server_error = "인증 서버 오류가 발생했습니다."
|
||||
temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습니다."
|
||||
unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다."
|
||||
unsupported_response_type = "지원하지 않는 응답 타입입니다."
|
||||
|
||||
[msg.userfront.forgot]
|
||||
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
|
||||
@@ -298,7 +321,7 @@ search = "검색"
|
||||
show_more = "+ 더보기"
|
||||
language = "언어"
|
||||
language_ko = "한국어"
|
||||
language_en = "영어"
|
||||
language_en = "English"
|
||||
theme_dark = "Dark"
|
||||
theme_light = "Light"
|
||||
theme_toggle = "테마 전환"
|
||||
@@ -534,3 +557,4 @@ verify = "본인인증"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "로그인하기"
|
||||
|
||||
|
||||
@@ -80,6 +80,29 @@ type = ""
|
||||
|
||||
[msg.userfront.error.whitelist]
|
||||
settings_disabled = ""
|
||||
invalid_session = ""
|
||||
verification_required = ""
|
||||
recovery_expired = ""
|
||||
recovery_invalid = ""
|
||||
rate_limited = ""
|
||||
not_found = ""
|
||||
bad_request = ""
|
||||
password_or_email_mismatch = ""
|
||||
|
||||
[msg.userfront.error.ory]
|
||||
access_denied = ""
|
||||
consent_required = ""
|
||||
interaction_required = ""
|
||||
invalid_client = ""
|
||||
invalid_grant = ""
|
||||
invalid_request = ""
|
||||
invalid_scope = ""
|
||||
login_required = ""
|
||||
request_forbidden = ""
|
||||
server_error = ""
|
||||
temporarily_unavailable = ""
|
||||
unauthorized_client = ""
|
||||
unsupported_response_type = ""
|
||||
|
||||
[msg.userfront.forgot]
|
||||
description = ""
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
const Map<String, String> errorWhitelistMessages = {
|
||||
const Map<String, String> internalErrorWhitelistMessages = {
|
||||
'settings_disabled': '현재 계정 설정 화면은 준비 중입니다.',
|
||||
'invalid_session': '세션이 만료되었습니다. 다시 로그인해 주세요.',
|
||||
'verification_required': '추가 인증이 필요합니다. 안내에 따라 진행해 주세요.',
|
||||
'recovery_expired': '재설정 링크가 만료되었습니다. 다시 요청해 주세요.',
|
||||
'recovery_invalid': '재설정 링크가 유효하지 않습니다.',
|
||||
'consent_required': '앱 접근 동의가 필요합니다.',
|
||||
'rate_limited': '요청이 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||
'not_found': '요청한 페이지를 찾을 수 없습니다.',
|
||||
'bad_request': '입력값을 확인해 주세요.',
|
||||
'password_or_email_mismatch': '이메일 혹은 비밀번호가 일치하지 않습니다.',
|
||||
};
|
||||
|
||||
const Set<String> oryBypassErrorCodes = {
|
||||
'access_denied',
|
||||
'consent_required',
|
||||
'interaction_required',
|
||||
'invalid_client',
|
||||
'invalid_grant',
|
||||
'invalid_request',
|
||||
'invalid_scope',
|
||||
'login_required',
|
||||
'request_forbidden',
|
||||
'server_error',
|
||||
'temporarily_unavailable',
|
||||
'unauthorized_client',
|
||||
'unsupported_response_type',
|
||||
};
|
||||
|
||||
@@ -24,10 +24,13 @@ class ErrorScreen extends StatelessWidget {
|
||||
final isProd = isProdOverride ?? AuthProxyService.isProdEnv;
|
||||
final normalizedCode = (errorCode ?? '').trim();
|
||||
final hasCode = normalizedCode.isNotEmpty;
|
||||
final whitelistFallback = errorWhitelistMessages[normalizedCode];
|
||||
final isWhitelisted = whitelistFallback != null;
|
||||
final internalWhitelistFallback =
|
||||
internalErrorWhitelistMessages[normalizedCode];
|
||||
final isInternalWhitelisted = internalWhitelistFallback != null;
|
||||
final isOryBypass = hasCode && oryBypassErrorCodes.contains(normalizedCode);
|
||||
final isKnownProdCode = hasCode && (isInternalWhitelisted || isOryBypass);
|
||||
final errorType = isProd
|
||||
? (isWhitelisted && hasCode ? normalizedCode : 'unknown_error')
|
||||
? (isKnownProdCode ? normalizedCode : 'unknown_error')
|
||||
: (hasCode ? normalizedCode : 'unknown_error');
|
||||
final title = isProd
|
||||
? tr('msg.userfront.error.title')
|
||||
@@ -40,14 +43,22 @@ class ErrorScreen extends StatelessWidget {
|
||||
'msg.userfront.error.title_generic',
|
||||
));
|
||||
final detail = isProd
|
||||
? (isWhitelisted
|
||||
? (isInternalWhitelisted
|
||||
? tr(
|
||||
'msg.userfront.error.whitelist.$normalizedCode',
|
||||
fallback: whitelistFallback,
|
||||
fallback: internalWhitelistFallback,
|
||||
)
|
||||
: tr(
|
||||
'msg.userfront.error.detail_contact',
|
||||
))
|
||||
: (isOryBypass
|
||||
? tr(
|
||||
'msg.userfront.error.ory.$normalizedCode',
|
||||
fallback:
|
||||
(description?.isNotEmpty == true)
|
||||
? description
|
||||
: tr('msg.userfront.error.detail_request'),
|
||||
)
|
||||
: tr(
|
||||
'msg.userfront.error.detail_contact',
|
||||
)))
|
||||
: ((description?.isNotEmpty == true)
|
||||
? description!
|
||||
: (hasCode
|
||||
|
||||
@@ -74,7 +74,7 @@ void main() {
|
||||
);
|
||||
final detail = tr(
|
||||
'msg.userfront.error.whitelist.settings_disabled',
|
||||
fallback: errorWhitelistMessages['settings_disabled']!,
|
||||
fallback: internalErrorWhitelistMessages['settings_disabled']!,
|
||||
);
|
||||
final type = tr(
|
||||
'msg.userfront.error.type',
|
||||
@@ -88,6 +88,34 @@ void main() {
|
||||
expect(find.text(type), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('프로덕션은 ORY 코드를 bypass 처리한다', (WidgetTester tester) async {
|
||||
await _pumpErrorScreen(
|
||||
tester,
|
||||
errorCode: 'access_denied',
|
||||
description: '원문 메시지',
|
||||
isProdOverride: true,
|
||||
);
|
||||
|
||||
final title = tr(
|
||||
'msg.userfront.error.title',
|
||||
fallback: '인증 과정에서 오류가 발생했습니다',
|
||||
);
|
||||
final detail = tr(
|
||||
'msg.userfront.error.ory.access_denied',
|
||||
fallback: '사용자가 동의를 거부했습니다.',
|
||||
);
|
||||
final type = tr(
|
||||
'msg.userfront.error.type',
|
||||
fallback: '오류 종류: {{type}}',
|
||||
params: {'type': 'access_denied'},
|
||||
);
|
||||
|
||||
expect(find.text(title), findsOneWidget);
|
||||
expect(find.text(detail), findsOneWidget);
|
||||
expect(find.text('원문 메시지'), findsNothing);
|
||||
expect(find.text(type), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('프로덕션은 비허용 에러를 unknown_error로 처리한다', (WidgetTester tester) async {
|
||||
await _pumpErrorScreen(
|
||||
tester,
|
||||
|
||||
Reference in New Issue
Block a user