forked from baron/baron-sso
구조 통합
This commit is contained in:
@@ -101,9 +101,8 @@ KRATOS_ADMIN_URL=http://kratos:4434
|
||||
# 브라우저가 접근할 Kratos Public/UI 외부 URL
|
||||
# Oathkeeper가 /auth 경로를 Kratos Public API로 라우팅합니다.
|
||||
KRATOS_BROWSER_URL=${OATHKEEPER_PUBLIC_URL}/auth
|
||||
# Kratos UI는 별도 서브도메인이 없으면 UserFront가 렌더링하거나 /kratos-ui 등으로 라우팅 필요
|
||||
# 현재는 예시로 로컬 포트 유지 (프로덕션에선 UserFront에 통합됨)
|
||||
KRATOS_UI_URL=http://localhost:4455
|
||||
# Kratos UI는 UserFront가 렌더링합니다.
|
||||
KRATOS_UI_URL=http://localhost:5000
|
||||
|
||||
HYDRA_ADMIN_URL=http://hydra:4445
|
||||
# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
|
||||
|
||||
@@ -197,7 +197,7 @@ docker compose -f docker-compose.yaml up -d
|
||||
- **ClickHouse**: http://localhost:8123
|
||||
- **Kratos Public**: http://localhost:4433
|
||||
- **Hydra Public**: http://localhost:4444
|
||||
- **Kratos UI**: http://localhost:4455
|
||||
- **Kratos UI (UserFront)**: http://localhost:5000
|
||||
|
||||
### MCP 서버 (Hydra/Kratos/Keto)
|
||||
MCP 서버는 기존 Hydra/Kratos에 연결하며 별도 Ory 스택이나 포트를 추가로 띄우지 않습니다.
|
||||
|
||||
@@ -484,6 +484,7 @@ func main() {
|
||||
user := api.Group("/user")
|
||||
user.Get("/me", authHandler.GetMe)
|
||||
user.Put("/me", authHandler.UpdateMe)
|
||||
user.Post("/me/password", authHandler.ChangeMyPassword)
|
||||
user.Post("/me/send-code", authHandler.SendUpdateCode)
|
||||
user.Post("/me/verify-code", authHandler.VerifyUpdateCode)
|
||||
user.Get("/rp/linked", authHandler.ListLinkedRps)
|
||||
|
||||
@@ -452,6 +452,24 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MessageResponse"
|
||||
|
||||
/api/v1/user/me/password:
|
||||
post:
|
||||
tags: [User]
|
||||
summary: 비밀번호 변경
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserPasswordChangeRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MessageResponse"
|
||||
|
||||
/api/v1/user/me/verify-code:
|
||||
post:
|
||||
tags: [User]
|
||||
@@ -1127,6 +1145,14 @@ components:
|
||||
companyCode:
|
||||
type: string
|
||||
|
||||
UserPasswordChangeRequest:
|
||||
type: object
|
||||
properties:
|
||||
currentPassword:
|
||||
type: string
|
||||
newPassword:
|
||||
type: string
|
||||
|
||||
UserProfileSendCodeRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -96,3 +96,9 @@ type PasswordResetCompleteRequest struct {
|
||||
LoginID string `json:"loginId"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
// PasswordChangeRequest는 로그인 상태에서 비밀번호 변경 요청을 표현합니다.
|
||||
type PasswordChangeRequest struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
@@ -3970,6 +3970,72 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ChangeMyPassword - 로그인 상태에서 현재 비밀번호를 확인한 뒤 변경합니다.
|
||||
func (h *AuthHandler) ChangeMyPassword(c *fiber.Ctx) error {
|
||||
var req domain.PasswordChangeRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
currentPassword := strings.TrimSpace(req.CurrentPassword)
|
||||
newPassword := strings.TrimSpace(req.NewPassword)
|
||||
if currentPassword == "" || newPassword == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Current password and new password are required"})
|
||||
}
|
||||
|
||||
policy := h.resolvePasswordPolicy()
|
||||
if err := validatePasswordWithPolicy(policy, newPassword); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
loginID := ""
|
||||
token := h.getBearerToken(c)
|
||||
if token != "" && looksLikeJWT(token) && h.DescopeClient != nil {
|
||||
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
||||
if err == nil && authorized {
|
||||
resolved, err := h.resolveDescopeLoginID(c.Context(), userToken)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Failed to resolve login ID"})
|
||||
}
|
||||
loginID = resolved
|
||||
}
|
||||
}
|
||||
|
||||
if loginID == "" && token != "" {
|
||||
if resolved, err := h.resolveKratosLoginID(token); err == nil {
|
||||
loginID = resolved
|
||||
}
|
||||
}
|
||||
|
||||
if loginID == "" {
|
||||
cookie := c.Get("Cookie")
|
||||
if cookie == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"})
|
||||
}
|
||||
_, traits, err := h.getKratosIdentityWithCookie(cookie)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
||||
}
|
||||
loginID = pickLoginIDFromTraits(traits)
|
||||
if loginID == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Login ID not found"})
|
||||
}
|
||||
if !strings.Contains(loginID, "@") {
|
||||
loginID = normalizePhoneForLoginID(loginID)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := h.IdpProvider.SignIn(loginID, currentPassword); err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Current password is invalid"})
|
||||
}
|
||||
|
||||
if err := h.IdpProvider.UpdateUserPassword(loginID, newPassword, nil); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update password"})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"message": "Password updated"})
|
||||
}
|
||||
|
||||
// SendUpdateCode - Sends OTP for phone number change
|
||||
func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error {
|
||||
token := h.getBearerToken(c)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
var sensitiveKeys = map[string]struct{}{
|
||||
"password": {},
|
||||
"currentpassword": {},
|
||||
"newpassword": {},
|
||||
"oldpassword": {},
|
||||
"token": {},
|
||||
|
||||
@@ -28,15 +28,15 @@ services:
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
|
||||
- KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL:-http://localhost:4433}
|
||||
- KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
|
||||
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:4455}
|
||||
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=["${KRATOS_UI_URL:-http://localhost:4455}","${USERFRONT_URL:-http://localhost:5000}"]
|
||||
- KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/error
|
||||
- KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/settings
|
||||
- KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/recovery
|
||||
- KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/verification
|
||||
- KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/login
|
||||
- KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/registration
|
||||
- KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:4455}/login
|
||||
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}
|
||||
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"]
|
||||
- KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error
|
||||
- KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled
|
||||
- KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/recovery
|
||||
- KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/verification
|
||||
- KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/login
|
||||
- KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/registration
|
||||
- KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login
|
||||
volumes:
|
||||
- ./docker/ory/kratos:/etc/config/kratos
|
||||
command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes
|
||||
@@ -56,15 +56,15 @@ services:
|
||||
- COOKIE_SECRET=${COOKIE_SECRET:-localcookie123}
|
||||
- KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL:-http://localhost:4433}
|
||||
- KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
|
||||
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:4455}
|
||||
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=["${KRATOS_UI_URL:-http://localhost:4455}","${USERFRONT_URL:-http://localhost:5000}"]
|
||||
- KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/error
|
||||
- KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/settings
|
||||
- KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/recovery
|
||||
- KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/verification
|
||||
- KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/login
|
||||
- KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/registration
|
||||
- KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:4455}/login
|
||||
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}
|
||||
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"]
|
||||
- KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error
|
||||
- KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled
|
||||
- KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/recovery
|
||||
- KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/verification
|
||||
- KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/login
|
||||
- KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/registration
|
||||
- KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login
|
||||
volumes:
|
||||
- ./docker/ory/kratos:/etc/config/kratos
|
||||
command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier
|
||||
@@ -75,21 +75,6 @@ services:
|
||||
- ory-net
|
||||
- kratosnet
|
||||
|
||||
kratos-ui:
|
||||
image: oryd/kratos-selfservice-ui-node:${KRATOS_UI_NODE_VERSION:-v25.4.0}
|
||||
container_name: ory_kratos_ui
|
||||
environment:
|
||||
- KRATOS_PUBLIC_URL=${KRATOS_PUBLIC_URL:-http://kratos:4433/}
|
||||
- KRATOS_BROWSER_URL=${KRATOS_BROWSER_URL:-http://localhost:${KRATOS_PUBLIC_PORT:-4433}}
|
||||
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434/}
|
||||
- NODE_ENV=development
|
||||
- PORT=${KRATOS_UI_PORT:-4455}
|
||||
- COOKIE_SECRET=${COOKIE_SECRET}
|
||||
- CSRF_COOKIE_NAME=${CSRF_COOKIE_NAME}
|
||||
- CSRF_COOKIE_SECRET=${CSRF_COOKIE_SECRET}
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
# --- Hydra ---
|
||||
hydra-migrate:
|
||||
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
|
||||
|
||||
@@ -11,9 +11,8 @@ serve:
|
||||
base_url: http://localhost:4434/
|
||||
|
||||
selfservice:
|
||||
default_browser_return_url: http://localhost:4455/
|
||||
default_browser_return_url: http://localhost:5000/
|
||||
allowed_return_urls:
|
||||
- http://localhost:4455
|
||||
- http://localhost:5000
|
||||
- https://sss.hmac.kr
|
||||
- https://sss.hmac.kr/
|
||||
@@ -33,24 +32,24 @@ selfservice:
|
||||
|
||||
flows:
|
||||
error:
|
||||
ui_url: http://localhost:4455/error
|
||||
ui_url: http://localhost:5000/error
|
||||
settings:
|
||||
ui_url: http://localhost:4455/settings
|
||||
ui_url: http://localhost:5000/error?error=settings_disabled
|
||||
privileged_session_max_age: 15m
|
||||
recovery:
|
||||
ui_url: http://localhost:4455/recovery
|
||||
ui_url: http://localhost:5000/recovery
|
||||
use: code
|
||||
verification:
|
||||
ui_url: http://localhost:4455/verification
|
||||
ui_url: http://localhost:5000/verification
|
||||
use: code
|
||||
logout:
|
||||
after:
|
||||
default_browser_return_url: http://localhost:4455/login
|
||||
default_browser_return_url: http://localhost:5000/login
|
||||
login:
|
||||
ui_url: http://localhost:4455/login
|
||||
ui_url: http://localhost:5000/login
|
||||
lifespan: 10m
|
||||
registration:
|
||||
ui_url: http://localhost:4455/registration
|
||||
ui_url: http://localhost:5000/registration
|
||||
lifespan: 10m
|
||||
|
||||
log:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[
|
||||
{
|
||||
"id": "public-health",
|
||||
"description": "공개 헬스체크 (STAGE 도메인)",
|
||||
"description": "공개 헬스체크",
|
||||
"match": {
|
||||
"url": "<.*>://sso-test.hmac.kr/health",
|
||||
"url": "<.*>://<.*>/health",
|
||||
"methods": ["GET"]
|
||||
},
|
||||
"upstream": {
|
||||
@@ -15,9 +15,9 @@
|
||||
},
|
||||
{
|
||||
"id": "public-preflight",
|
||||
"description": "CORS preflight (STAGE 도메인)",
|
||||
"description": "CORS preflight",
|
||||
"match": {
|
||||
"url": "<.*>://sso-test.hmac.kr/api/v1/<.*>",
|
||||
"url": "<.*>://<.*>/api/v1/<.*>",
|
||||
"methods": ["OPTIONS"]
|
||||
},
|
||||
"upstream": {
|
||||
@@ -29,9 +29,9 @@
|
||||
},
|
||||
{
|
||||
"id": "public-auth",
|
||||
"description": "인증/회원가입 등 공개 엔드포인트 (STAGE 도메인)",
|
||||
"description": "인증/회원가입 등 공개 엔드포인트",
|
||||
"match": {
|
||||
"url": "<.*>://sso-test.hmac.kr/api/v1/auth/<.*>",
|
||||
"url": "<.*>://<.*>/api/v1/auth/<.*>",
|
||||
"methods": ["GET", "POST", "OPTIONS"]
|
||||
},
|
||||
"upstream": {
|
||||
@@ -45,7 +45,7 @@
|
||||
"id": "backend-command",
|
||||
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
|
||||
"match": {
|
||||
"url": "<.*>://sso-test.hmac.kr/api/v1/<.*>",
|
||||
"url": "<.*>://<.*>/api/v1/<.*>",
|
||||
"methods": ["POST", "PUT", "PATCH", "DELETE"]
|
||||
},
|
||||
"upstream": {
|
||||
@@ -59,7 +59,7 @@
|
||||
"id": "backend-query",
|
||||
"description": "Backend Query (admin/dev 포함)",
|
||||
"match": {
|
||||
"url": "<.*>://sso-test.hmac.kr/api/v1/<.*>",
|
||||
"url": "<.*>://<.*>/api/v1/<.*>",
|
||||
"methods": ["GET"]
|
||||
},
|
||||
"upstream": {
|
||||
@@ -73,7 +73,7 @@
|
||||
"id": "hydra-well-known",
|
||||
"description": "Hydra OIDC Discovery & JWKS",
|
||||
"match": {
|
||||
"url": "<.*>://sso-test.hmac.kr/.well-known/<.*>",
|
||||
"url": "<.*>://<.*>/.well-known/<.*>",
|
||||
"methods": ["GET", "OPTIONS"]
|
||||
},
|
||||
"upstream": {
|
||||
@@ -87,7 +87,7 @@
|
||||
"id": "hydra-oauth2",
|
||||
"description": "Hydra OAuth2 Endpoints",
|
||||
"match": {
|
||||
"url": "<.*>://sso-test.hmac.kr/oauth2/<.*>",
|
||||
"url": "<.*>://<.*>/oauth2/<.*>",
|
||||
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
|
||||
},
|
||||
"upstream": {
|
||||
@@ -101,7 +101,7 @@
|
||||
"id": "hydra-userinfo",
|
||||
"description": "Hydra Userinfo",
|
||||
"match": {
|
||||
"url": "<.*>://sso-test.hmac.kr/userinfo",
|
||||
"url": "<.*>://<.*>/userinfo",
|
||||
"methods": ["GET", "POST", "OPTIONS"]
|
||||
},
|
||||
"upstream": {
|
||||
|
||||
@@ -107,12 +107,7 @@
|
||||
## 6) UserFront 주의사항
|
||||
|
||||
- `sessionJwt`가 **JWT 형식이 아닐 수 있음** (Kratos session token은 opaque 가능)
|
||||
- 현재 UserFront는 Descope SDK 기반 세션 처리 로직이 포함되어 있어, Ory 사용 시 이 부분은 분리/대체가 필요함
|
||||
- 현재 UserFront는 Descope SDK 기반 세션 처리 로직을 제거했으니 남아 있다면 제거 대상임. 즉시 사용자에게 알리고 이슈로 생성 바람.
|
||||
|
||||
---
|
||||
|
||||
## 7) 다음 액션 제안
|
||||
|
||||
1. **Kratos 세션 쿠키 전달 방식(A) 구현**
|
||||
2. Enchanted/Magic Link의 Ory 대응(로그인 코드/링크 방식) 설계
|
||||
3. SMS 코드/QR 플로우의 Kratos 세션 교환 정책 확정
|
||||
참고.
|
||||
- AdminFront, DevFront는 JWT 방식으로 RP중 하나인 것 처럼 동작시키는 설계. 따라서 JWT에 대한 대응도 내부적으로는 완벽히 진행해야 함.
|
||||
|
||||
@@ -24,19 +24,12 @@
|
||||
- Kratos Admin API를 대신 호출해주는 “자동화/툴링(LLM 연동 포함) 브리지”
|
||||
- 사람/브라우저가 직접 쓰는 서비스라기보다, 내부 도구가 붙어서 identity 관리 작업을 자동화할 때 사용
|
||||
|
||||
### 5) `kratos-ui`
|
||||
|
||||
- Kratos의 로그인/회원가입 등 Self-service 화면을 제공하는 UI 서버
|
||||
- Kratos public/admin URL을 환경변수로 받아서 UI가 Kratos와 통신함
|
||||
|
||||
---
|
||||
|
||||
### 6) `hydra-migrate`
|
||||
### 5) `hydra-migrate`
|
||||
|
||||
- Hydra DB 스키마 마이그레이션을 수행하는 1회성 컨테이너
|
||||
- Postgres가 healthy가 된 뒤 실행되고, 성공해야 Hydra가 뜸
|
||||
|
||||
### 7) `hydra`
|
||||
### 6) `hydra`
|
||||
|
||||
- **OAuth2 / OIDC Provider**: authorization code 발급, access/refresh token 발급 등
|
||||
- 포트
|
||||
@@ -45,7 +38,7 @@
|
||||
|
||||
- `URLS_SELF_ISSUER`, `URLS_LOGIN`, `URLS_CONSENT`로 “로그인/동의 화면을 어디서 처리할지”를 외부(backend)로 위임
|
||||
|
||||
### 8) `hydra-mcp-server` (지금은 profiles 제거되어 항상 뜸)
|
||||
### 7) `hydra-mcp-server` (지금은 profiles 제거되어 항상 뜸)
|
||||
|
||||
- Hydra Admin/Public API를 대신 호출해주는 “자동화/툴링(LLM 연동 포함) 브리지”
|
||||
- 주 용도는 OAuth 클라이언트 생성/수정/조회 자동화, 테스트 환경 세팅, 운영 자동화 등
|
||||
@@ -53,12 +46,12 @@
|
||||
|
||||
---
|
||||
|
||||
### 9) `keto-migrate`
|
||||
### 8) `keto-migrate`
|
||||
|
||||
- Keto(권한/관계 기반 접근제어) DB 마이그레이션 수행 1회성 컨테이너
|
||||
- Postgres가 healthy가 된 뒤 실행되고, 성공해야 Keto가 뜸
|
||||
|
||||
### 10) `keto`
|
||||
### 9) `keto`
|
||||
|
||||
- **권한/정책(관계 튜플) 기반 접근제어** 담당(Ory Keto)
|
||||
- 포트
|
||||
@@ -69,7 +62,7 @@
|
||||
|
||||
---
|
||||
|
||||
### 11) `oathkeeper`
|
||||
### 10) `oathkeeper`
|
||||
|
||||
- **Reverse proxy + Access rule enforcement**(인증/인가 게이트웨이)
|
||||
- 일반적으로 앞단에서 요청을 받아서 “인증 여부 확인 후” 백엔드로 프록시
|
||||
@@ -79,12 +72,12 @@
|
||||
|
||||
---
|
||||
|
||||
### 12) `ory_stack_check`
|
||||
### 11) `ory_stack_check`
|
||||
|
||||
- 알파인에서 curl로 Kratos/Hydra/Keto의 `/health/ready`를 폴링해서 “스택 준비 완료”를 확인하는 헬퍼
|
||||
- 준비가 끝나야 다음 단계(init-rp)가 안전하게 실행됨
|
||||
|
||||
### 13) `init-rp`
|
||||
### 12) `init-rp`
|
||||
|
||||
- Hydra Admin API로 **OAuth 클라이언트(Relying Party)를 자동 등록**하는 1회성 컨테이너
|
||||
- 여기서는 `adminfront`, `devfront` 클라이언트를 만들어 둠
|
||||
@@ -148,7 +141,7 @@ curl -i http://localhost:4456/health/ready
|
||||
### 화면이 떠야 하는 것 (UI)
|
||||
|
||||
```
|
||||
http://localhost:4455/... : Kratos UI (이미 OK)
|
||||
http://localhost:5000/... : Kratos UI(UserFront) (이미 OK)
|
||||
|
||||
http://localhost:5000, http://localhost:5174 : 프론트들 (이미 OK)
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
- **Kratos**: Identity/Session 관리(SoT)
|
||||
- **Hydra**: OAuth2/OIDC 토큰 엔진
|
||||
- **Keto**: 권한/정책
|
||||
- **Kratos UI**: Self-service UI (login/registration 등)
|
||||
- **Kratos UI**: UserFront가 self-service UI 역할 (login/registration 등)
|
||||
|
||||
## 2) 실행 방법
|
||||
```bash
|
||||
@@ -28,18 +28,18 @@ Ory 구성은 **컨테이너 내부 통신 URL**과 **브라우저 접근 URL**
|
||||
|
||||
### 브라우저 접근용 URL(외부 도메인/프록시)
|
||||
- `KRATOS_BROWSER_URL` : Kratos Public의 외부 URL
|
||||
- `KRATOS_UI_URL` : Kratos UI의 외부 URL
|
||||
- `KRATOS_UI_URL` : UserFront의 외부 URL (Kratos UI 역할)
|
||||
|
||||
예시(로컬):
|
||||
```env
|
||||
KRATOS_BROWSER_URL=http://localhost:4433
|
||||
KRATOS_UI_URL=http://localhost:4455
|
||||
KRATOS_UI_URL=http://localhost:5000
|
||||
```
|
||||
|
||||
예시(리버스 프록시/도메인):
|
||||
```env
|
||||
KRATOS_BROWSER_URL=https://sso.example.com
|
||||
KRATOS_UI_URL=https://sso-ui.example.com
|
||||
KRATOS_UI_URL=https://sso.example.com
|
||||
```
|
||||
|
||||
### 포트 노출 정책
|
||||
@@ -49,7 +49,7 @@ KRATOS_UI_URL=https://sso-ui.example.com
|
||||
- 브라우저/Frontend는 Backend API를 통해서만 IDP 기능을 호출
|
||||
|
||||
## 4) Kratos Self-service UI 리다이렉트 설정
|
||||
Kratos는 self-service UI URL을 설정값으로 사용합니다. 브라우저에서 접근 가능한 URL이어야 정상 동작합니다.
|
||||
Kratos는 self-service UI URL을 설정값으로 사용합니다. **UserFront의 브라우저 접근 URL**이어야 정상 동작합니다.
|
||||
|
||||
- `KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL`
|
||||
- `KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS`
|
||||
@@ -58,7 +58,7 @@ Kratos는 self-service UI URL을 설정값으로 사용합니다. 브라우저
|
||||
compose에서 기본적으로 다음과 같이 오버라이드합니다:
|
||||
- `KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login`
|
||||
- `KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration`
|
||||
- `KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/settings`
|
||||
- `KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/error?error=settings_disabled` (임시 비활성)
|
||||
- `KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery`
|
||||
- `KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification`
|
||||
|
||||
|
||||
117
userfront/lib/features/auth/presentation/error_screen.dart
Normal file
117
userfront/lib/features/auth/presentation/error_screen.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class ErrorScreen extends StatelessWidget {
|
||||
final String? errorId;
|
||||
final String? errorCode;
|
||||
final String? description;
|
||||
|
||||
const ErrorScreen({
|
||||
super.key,
|
||||
this.errorId,
|
||||
this.errorCode,
|
||||
this.description,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final errorType = (errorCode == null || errorCode!.isEmpty)
|
||||
? 'unknown_error'
|
||||
: errorCode!;
|
||||
final title = errorCode == null || errorCode!.isEmpty
|
||||
? '인증 과정에서 오류가 발생했습니다'
|
||||
: '오류: $errorCode';
|
||||
final detail = description?.isNotEmpty == true
|
||||
? description!
|
||||
: '요청을 처리하는 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF7F8FA),
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: const BorderSide(color: Color(0xFFE5E7EB)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF111827),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
detail,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: const Color(0xFF4B5563),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'오류 종류: $errorType',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
if (errorId != null && errorId!.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'오류 ID: $errorId',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () => context.go('/login'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF111827),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: const Text('로그인으로 이동'),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.go('/'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF111827),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
side: const BorderSide(color: Color(0xFFCBD5F5)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: const Text('홈으로 이동'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,37 @@ class ProfileRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> changePassword({
|
||||
required String currentPassword,
|
||||
required String newPassword,
|
||||
}) async {
|
||||
final token = await _getToken();
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) throw Exception('No active session');
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/password');
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode({
|
||||
'currentPassword': currentPassword,
|
||||
'newPassword': newPassword,
|
||||
}),
|
||||
);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to change password: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verifyUpdateCode(String phone, String code) async {
|
||||
final token = await _getToken();
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
|
||||
@@ -26,6 +26,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
TextEditingController? _phoneController;
|
||||
TextEditingController? _departmentController;
|
||||
TextEditingController? _codeController;
|
||||
TextEditingController? _currentPasswordController;
|
||||
TextEditingController? _newPasswordController;
|
||||
TextEditingController? _confirmPasswordController;
|
||||
final FocusNode _nameFocus = FocusNode();
|
||||
final FocusNode _departmentFocus = FocusNode();
|
||||
final FocusNode _phoneFocus = FocusNode();
|
||||
@@ -42,6 +45,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
bool _isCodeSent = false;
|
||||
bool _isVerifying = false;
|
||||
|
||||
bool _isPasswordSaving = false;
|
||||
String? _passwordError;
|
||||
String? _passwordSuccess;
|
||||
bool _showCurrentPassword = false;
|
||||
bool _showNewPassword = false;
|
||||
bool _showConfirmPassword = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -97,6 +107,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_phoneController?.dispose();
|
||||
_departmentController?.dispose();
|
||||
_codeController?.dispose();
|
||||
_currentPasswordController?.dispose();
|
||||
_newPasswordController?.dispose();
|
||||
_confirmPasswordController?.dispose();
|
||||
_nameFocus.dispose();
|
||||
_departmentFocus.dispose();
|
||||
_phoneFocus.dispose();
|
||||
@@ -113,6 +126,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_nameController ??= TextEditingController(text: profile.name);
|
||||
_departmentController ??= TextEditingController(text: profile.department);
|
||||
_codeController ??= TextEditingController();
|
||||
_currentPasswordController ??= TextEditingController();
|
||||
_newPasswordController ??= TextEditingController();
|
||||
_confirmPasswordController ??= TextEditingController();
|
||||
|
||||
if (_phoneController == null) {
|
||||
_phoneController = TextEditingController(text: profile.phone);
|
||||
@@ -256,6 +272,54 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _changePassword() async {
|
||||
if (_isPasswordSaving) return;
|
||||
final currentPassword = _currentPasswordController?.text.trim() ?? '';
|
||||
final newPassword = _newPasswordController?.text.trim() ?? '';
|
||||
final confirmPassword = _confirmPasswordController?.text.trim() ?? '';
|
||||
|
||||
if (currentPassword.isEmpty) {
|
||||
setState(() => _passwordError = '현재 비밀번호를 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
if (newPassword.isEmpty) {
|
||||
setState(() => _passwordError = '새 비밀번호를 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
if (newPassword != confirmPassword) {
|
||||
setState(() => _passwordError = '새 비밀번호가 일치하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_passwordError = null;
|
||||
_passwordSuccess = null;
|
||||
_isPasswordSaving = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await ref.read(profileRepositoryProvider).changePassword(
|
||||
currentPassword: currentPassword,
|
||||
newPassword: newPassword,
|
||||
);
|
||||
_currentPasswordController?.clear();
|
||||
_newPasswordController?.clear();
|
||||
_confirmPasswordController?.clear();
|
||||
setState(() {
|
||||
_passwordSuccess = '비밀번호가 변경되었습니다.';
|
||||
});
|
||||
} catch (e) {
|
||||
final message = e.toString().replaceFirst('Exception: ', '');
|
||||
setState(() {
|
||||
_passwordError = '비밀번호 변경 실패: $message';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isPasswordSaving = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _autoSaveIfEditing(UserProfile profile, String field) {
|
||||
if (_editingField != field) return;
|
||||
if (_isVerifying) return;
|
||||
@@ -693,6 +757,104 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPasswordSection() {
|
||||
return _buildCard(
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'비밀번호 변경',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'현재 비밀번호 확인 후 새 비밀번호로 변경합니다.',
|
||||
style: TextStyle(color: Color(0xFF6B7280)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _currentPasswordController,
|
||||
obscureText: !_showCurrentPassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: '현재 비밀번호',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_showCurrentPassword ? Icons.visibility_off : Icons.visibility),
|
||||
onPressed: () => setState(() {
|
||||
_showCurrentPassword = !_showCurrentPassword;
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _newPasswordController,
|
||||
obscureText: !_showNewPassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: '새 비밀번호',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_showNewPassword ? Icons.visibility_off : Icons.visibility),
|
||||
onPressed: () => setState(() {
|
||||
_showNewPassword = !_showNewPassword;
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: !_showConfirmPassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: '새 비밀번호 확인',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_showConfirmPassword ? Icons.visibility_off : Icons.visibility),
|
||||
onPressed: () => setState(() {
|
||||
_showConfirmPassword = !_showConfirmPassword;
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_passwordError != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_passwordError!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
if (_passwordSuccess != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_passwordSuccess!,
|
||||
style: const TextStyle(color: Colors.green),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: _isPasswordSaving ? null : _changePassword,
|
||||
child: _isPasswordSaving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('비밀번호 변경'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/recovery'),
|
||||
child: const Text('비밀번호를 잊으셨나요?'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(UserProfile profile, bool isUpdating) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(profileProvider.notifier).loadProfile(),
|
||||
@@ -754,6 +916,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
_buildSectionTitle('보안', '비밀번호를 안전하게 관리합니다.'),
|
||||
const SizedBox(height: 12),
|
||||
_buildPasswordSection(),
|
||||
if (isUpdating || _isVerifying) ...[
|
||||
const SizedBox(height: 24),
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'features/auth/presentation/approve_qr_screen.dart';
|
||||
import 'features/auth/presentation/qr_scan_screen.dart';
|
||||
import 'features/auth/presentation/forgot_password_screen.dart';
|
||||
import 'features/auth/presentation/reset_password_screen.dart';
|
||||
import 'features/auth/presentation/error_screen.dart';
|
||||
import 'features/dashboard/presentation/dashboard_screen.dart';
|
||||
import 'features/admin/presentation/user_management_screen.dart';
|
||||
import 'features/profile/presentation/pages/profile_page.dart';
|
||||
@@ -94,6 +95,13 @@ final _router = GoRouter(
|
||||
return LoginScreen(key: state.pageKey);
|
||||
}
|
||||
),
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
builder: (context, state) {
|
||||
_routerLogger.info("Navigating to /login");
|
||||
return LoginScreen(key: state.pageKey);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/signup',
|
||||
builder: (context, state) {
|
||||
@@ -101,6 +109,13 @@ final _router = GoRouter(
|
||||
return const SignupScreen();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/registration',
|
||||
builder: (context, state) {
|
||||
_routerLogger.info("Navigating to /registration");
|
||||
return const SignupScreen();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/verify',
|
||||
builder: (context, state) {
|
||||
@@ -116,6 +131,13 @@ final _router = GoRouter(
|
||||
return LoginScreen(key: state.pageKey, verificationToken: token);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/verification',
|
||||
builder: (context, state) {
|
||||
_routerLogger.info("Navigating to /verification");
|
||||
return LoginScreen(key: state.pageKey);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/l/:shortCode',
|
||||
builder: (context, state) {
|
||||
@@ -131,6 +153,13 @@ final _router = GoRouter(
|
||||
return const ForgotPasswordScreen();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/recovery',
|
||||
builder: (context, state) {
|
||||
_routerLogger.info("Navigating to /recovery");
|
||||
return const ForgotPasswordScreen();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
// Supports both /reset-password and /reset-password?token=...
|
||||
path: '/reset-password',
|
||||
@@ -141,6 +170,28 @@ final _router = GoRouter(
|
||||
return const ResetPasswordScreen();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/error',
|
||||
builder: (context, state) {
|
||||
_routerLogger.info("Navigating to /error");
|
||||
final params = state.uri.queryParameters;
|
||||
return ErrorScreen(
|
||||
errorId: params['id'],
|
||||
errorCode: params['error'],
|
||||
description: params['error_description'] ?? params['message'],
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
builder: (context, state) {
|
||||
_routerLogger.info("Navigating to /settings (disabled)");
|
||||
return const ErrorScreen(
|
||||
errorCode: 'settings_disabled',
|
||||
description: '현재 계정 설정 화면은 준비 중입니다.',
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/approve',
|
||||
builder: (context, state) {
|
||||
@@ -181,12 +232,18 @@ final _router = GoRouter(
|
||||
// Public paths that don't require login
|
||||
final isPublicPath = path == '/signin' ||
|
||||
path == '/signup' ||
|
||||
path == '/login' ||
|
||||
path == '/registration' ||
|
||||
path == '/verify' ||
|
||||
path == '/verification' ||
|
||||
path.startsWith('/verify/') ||
|
||||
path == '/approve' ||
|
||||
path.startsWith('/ql/') ||
|
||||
path == '/forgot-password' ||
|
||||
path == '/reset-password';
|
||||
path == '/recovery' ||
|
||||
path == '/reset-password' ||
|
||||
path == '/error' ||
|
||||
path == '/settings';
|
||||
|
||||
_routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user