1
0
forked from baron/baron-sso

구조 통합

This commit is contained in:
Lectom C Han
2026-02-02 16:22:23 +09:00
parent a54c2ab138
commit 39296ca522
17 changed files with 531 additions and 89 deletions

View File

@@ -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로 라우팅합니다.

View File

@@ -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 스택이나 포트를 추가로 띄우지 않습니다.

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import (
var sensitiveKeys = map[string]struct{}{
"password": {},
"currentpassword": {},
"newpassword": {},
"oldpassword": {},
"token": {},

View File

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

View File

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

View File

@@ -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": {
@@ -111,4 +111,4 @@
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
}
]
]

View File

@@ -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에 대한 대응도 내부적으로는 완벽히 진행해야 함.

View File

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

View File

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

View 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('홈으로 이동'),
),
],
),
],
),
),
),
),
),
);
}
}

View File

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

View File

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

View File

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