From 77d4e9fd77cbb2785ee096d48f9e678bb1149289 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Thu, 29 Jan 2026 14:42:15 +0900 Subject: [PATCH] =?UTF-8?q?=ED=95=9C=20endpoint=20URL=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=84=9C=EB=B9=99=20#120?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 38 +++-- README.md | 1 + backend/internal/handler/auth_handler.go | 161 +++++++++++++++++- compose.ory.yaml | 4 + docker-compose.yaml | 6 +- docs/auth-flow.md | 4 +- userfront/.env.sample | 34 ---- userfront/Dockerfile | 1 + .../auth/presentation/approve_qr_screen.dart | 7 +- .../auth/presentation/login_screen.dart | 13 +- .../auth/presentation/qr_scan_screen.dart | 6 +- userfront/nginx.conf | 52 +++++- 12 files changed, 254 insertions(+), 73 deletions(-) delete mode 100644 userfront/.env.sample diff --git a/.env.sample b/.env.sample index b10cb5bf..63b4c206 100644 --- a/.env.sample +++ b/.env.sample @@ -57,17 +57,19 @@ ADMIN_EMAIL=admin@baron.co.kr ADMIN_PASSWORD=adminPasswordIsNotSimple # --- URLs for Proxy/Handoff --- -USERFRONT_URL=https://sso.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용) -BACKEND_URL=https://sso.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소 - +# Project Public Base URL (Served by UserFront Nginx) +USERFRONT_URL=https://sso.hmac.kr +# Services proxied via Nginx +BACKEND_URL=${USERFRONT_URL}/api +OATHKEEPER_PUBLIC_URL=${USERFRONT_URL} # ory-stack 변수들 ORY_POSTGRES_TAG=17-trixie ORY_POSTGRES_USER=ory ORY_POSTGRES_PASSWORD=EuBV5ywvXFehkggHQrnYo5727MseEi6i9 ORY_POSTGRES_DB=ory -ORY_POSTGRES_PORT=5433 +# ORY_POSTGRES_PORT=5433 # Internal only KRATOS_DB=ory_kratos HYDRA_DB=ory_hydra @@ -75,31 +77,39 @@ KETO_DB=ory_keto # Ory Kratos Configuration KRATOS_VERSION=v25.4.0-distroless -KRATOS_PUBLIC_PORT=4433 -KRATOS_ADMINFRONT_PORT=4434 +# KRATOS_PUBLIC_PORT=4433 # Internal only +# KRATOS_ADMINFRONT_PORT=4434 # Internal only KRATOS_UI_NODE_VERSION=v25.4.0 -KRATOS_UI_PORT=4455 +# KRATOS_UI_PORT=4455 # Internal only # Ory Hydra Configuration HYDRA_VERSION=v25.4.0-distroless -HYDRA_PUBLIC_PORT=4441 -HYDRA_ADMINFRONT_PORT=4445 +# HYDRA_PUBLIC_PORT=4441 # Internal only +# HYDRA_ADMINFRONT_PORT=4445 # Internal only # Ory Keto Configuration KETO_VERSION=v25.4.0-distroless -KETO_READ_PORT=4466 -KETO_WRITE_PORT=4467 +# KETO_READ_PORT=4466 # Internal only +# KETO_WRITE_PORT=4467 # Internal only # Kratos Selfservice UI upstreams (override for deployments) ORY_SDK_URL=http://kratos:4433 KRATOS_PUBLIC_URL=http://kratos:4433 KRATOS_ADMIN_URL=http://kratos:4434 -# 브라우저가 접근할 Kratos Public/UI 외부 URL (리버스 프록시/도메인 환경 고려) -KRATOS_BROWSER_URL=http://localhost:4433 + +# 브라우저가 접근할 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 + HYDRA_ADMIN_URL=http://hydra:4445 -HYDRA_PUBLIC_URL=http://hydra:4444 +# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다. +HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc + +# Oathkeeper JWKS (내부 통신용) JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json # Oathkeeper 실행 사용자/프로브 설정 diff --git a/README.md b/README.md index a21532fa..f6aeaa86 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,7 @@ Ory Stack과 애플리케이션 간 통신을 위한 도커 네트워크를 생 docker network create -d bridge ory-net docker network create hydranet docker network create kratosnet +docker network create public_net #서비스용 ``` #### 2. 인프라 및 Ory Stack 실행 diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index f9969d23..d7d43d40 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -22,6 +22,7 @@ import ( "strings" "time" + "github.com/descope/go-sdk/descope" "github.com/descope/go-sdk/descope/client" "github.com/gofiber/fiber/v2" ) @@ -1357,7 +1358,6 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { // InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다. func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error { pendingRef := GenerateSecureToken(16) - userCode := GenerateUserCode() // QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다. userfrontURL := os.Getenv("USERFRONT_URL") @@ -1376,7 +1376,6 @@ func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error { "pendingRef": pendingRef, "expiresIn": 300, "interval": int(minPollInterval.Seconds()), - "userCode": userCode, }) } @@ -1433,17 +1432,26 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error { slog.Info("[QR] Scan & Approve", "pendingRef", req.PendingRef) + if req.Token == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing session token"}) + } + // 1. Redis에서 세션 확인 val, err := h.RedisService.Get(prefixSession + req.PendingRef) if err != nil || val == "" { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Session expired or not found"}) } - // 2. 모바일 유저의 토큰으로 새 세션 토큰(웹용)을 발행하거나 그대로 전달 + // 2. 모바일 토큰은 승인 검증용으로만 사용하고, 웹 전용 세션을 새로 발급 + sessionToken, err := h.issueQRWebSession(c, req.Token) + if err != nil { + slog.Error("[QR] Issue web session failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"}) + } sessionData, _ := json.Marshal(map[string]string{ "status": statusSuccess, - "jwt": req.Token, + "jwt": sessionToken, }) h.RedisService.Set(prefixSession+req.PendingRef, string(sessionData), 5*time.Minute) @@ -1910,6 +1918,108 @@ func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, err return id, err } +func (h *AuthHandler) issueQRWebSession(c *fiber.Ctx, token string) (string, error) { + if looksLikeJWT(token) && h.DescopeClient != nil { + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if err == nil && authorized { + loginID, err := h.resolveDescopeLoginID(c.Context(), userToken) + if err != nil { + return "", err + } + authInfo, err := h.IdpProvider.IssueSession(loginID) + if err != nil { + return "", err + } + if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { + return "", fmt.Errorf("descope issue session returned empty token") + } + return authInfo.SessionToken.JWT, nil + } + } + + identityID, _, err := h.getKratosIdentity(token) + if err != nil { + return "", err + } + return h.issueKratosSession(c.Context(), identityID) +} + +func (h *AuthHandler) resolveDescopeLoginID(ctx context.Context, token *descope.Token) (string, error) { + if token == nil { + return "", fmt.Errorf("descope token is nil") + } + + if loginID := extractLoginIDFromClaims(token.Claims); loginID != "" { + return loginID, nil + } + + if h.DescopeClient == nil { + return "", fmt.Errorf("descope client is nil") + } + + user, err := h.DescopeClient.Management.User().Load(ctx, token.ID) + if err != nil { + return "", err + } + if user == nil { + return "", fmt.Errorf("descope user not found") + } + if loginID := pickPrimaryLoginID(user.LoginIDs); loginID != "" { + return loginID, nil + } + if user.Email != "" { + return user.Email, nil + } + if user.Phone != "" { + return user.Phone, nil + } + return "", fmt.Errorf("descope login id not found") +} + +func pickPrimaryLoginID(loginIDs []string) string { + for _, id := range loginIDs { + if strings.Contains(id, "@") { + return id + } + } + if len(loginIDs) > 0 { + return loginIDs[0] + } + return "" +} + +func extractLoginIDFromClaims(claims map[string]any) string { + if claims == nil { + return "" + } + + candidateKeys := []string{"loginId", "login_id", "email", "phone_number", "phone", "phoneNumber"} + for _, key := range candidateKeys { + if raw, ok := claims[key]; ok { + if value, ok := raw.(string); ok && value != "" { + return value + } + } + } + + if raw, ok := claims["loginIds"]; ok { + switch ids := raw.(type) { + case []string: + return pickPrimaryLoginID(ids) + case []any: + casted := make([]string, 0, len(ids)) + for _, item := range ids { + if value, ok := item.(string); ok && value != "" { + casted = append(casted, value) + } + } + return pickPrimaryLoginID(casted) + } + } + + return "" +} + func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) { kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") if kratosURL == "" { @@ -1944,6 +2054,49 @@ func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string return result.Identity.ID, result.Identity.Traits, nil } +func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string) (string, error) { + if identityID == "" { + return "", fmt.Errorf("kratos identity id is empty") + } + + kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/") + if kratosAdminURL == "" { + kratosAdminURL = "http://kratos:4434" + } + + payload := map[string]interface{}{ + "identity_id": identityID, + } + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, kratosAdminURL+"/admin/sessions", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode >= 300 { + return "", fmt.Errorf("kratos admin create session failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + var parsed struct { + SessionToken string `json:"session_token"` + } + if err := json.Unmarshal(respBody, &parsed); err != nil { + return "", err + } + if parsed.SessionToken == "" { + return "", fmt.Errorf("kratos admin session token missing: %s", string(respBody)) + } + return parsed.SessionToken, nil +} + func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, error) { kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") if kratosURL == "" { diff --git a/compose.ory.yaml b/compose.ory.yaml index 53ffae17..1743a1c5 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -162,6 +162,7 @@ services: entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"] networks: - ory-net + - public_net ory_clickhouse: image: clickhouse/clickhouse-server:latest @@ -251,3 +252,6 @@ networks: kratosnet: external: true name: kratosnet + public_net: + external: true + name: public_net diff --git a/docker-compose.yaml b/docker-compose.yaml index 23d59b02..8ff21446 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -80,7 +80,6 @@ services: - /app/node_modules networks: - baron_net - userfront: build: context: ./userfront @@ -97,6 +96,8 @@ services: - "${USERFRONT_PORT:-5000}:5000" networks: - baron_net + - public_net + depends_on: backend: condition: service_healthy @@ -126,3 +127,6 @@ networks: ory-net: external: true name: ory-net + public_net: + external: true + name: public_net diff --git a/docs/auth-flow.md b/docs/auth-flow.md index 95c1be59..45ba01e0 100644 --- a/docs/auth-flow.md +++ b/docs/auth-flow.md @@ -33,7 +33,7 @@ ### 2.3 QR 로그인 1. `POST /api/v1/auth/qr/init` → `qrCode`, `pendingRef` 수신 2. 웹은 `POST /api/v1/auth/qr/poll`로 폴링 -3. 모바일 앱은 `POST /api/v1/auth/qr/approve`로 승인 +3. 모바일 앱은 `POST /api/v1/auth/qr/approve`로 승인 (모바일 세션 토큰은 승인 검증용) 4. Polling 응답에서 `sessionJwt` 수신 ### 2.4 SMS 코드 로그인 @@ -71,7 +71,7 @@ - **ID/Password 로그인**: IDP 추상화 사용 (Ory/Descope) — 정상 - **Enchanted/Magic Link**: 현재는 Descope 기반 로직이 포함됨. Ory 전환 시 Kratos `code/link` 플로우로 교체 필요 - **SMS 코드**: 내부 토큰(placeholder). Kratos 세션 교환 로직 추가 필요 -- **QR 로그인**: 모바일 세션 토큰을 웹 세션으로 전달. Ory일 경우 Kratos 세션 토큰을 전달하도록 UI/토큰 저장 방식 정비 필요 +- **QR 로그인**: 모바일 세션 토큰은 승인 검증용으로만 사용하고, 백엔드에서 웹 전용 세션을 새로 발급 --- diff --git a/userfront/.env.sample b/userfront/.env.sample deleted file mode 100644 index 5317d76e..00000000 --- a/userfront/.env.sample +++ /dev/null @@ -1,34 +0,0 @@ -# ========================================== -# Baron SSO - Unified Environment Configuration -# ========================================== - -# --- General System --- -APP_ENV=development -TZ=Asia/Seoul - -# --- Infrastructure Ports --- -DB_PORT=5432 -CLICKHOUSE_PORT_HTTP=8123 -CLICKHOUSE_PORT_NATIVE=9000 -BACKEND_PORT=3000 -USERFRONT_PORT=5000 - -# --- Database Credentials (PostgreSQL) --- -DB_USER=baron -DB_PASSWORD=password -DB_NAME=baron_sso - -# --- Backend Configuration --- -# Must be 32 bytes. Generate with `openssl rand -hex 32` -COOKIE_SECRET=super-secret-key-must-be-32-bytes! -REDIS_ADDR=redis:6379 - -# --- Frontend Configuration --- -# Descope Project ID (Required for Auth) -DESCOPE_PROJECT_ID=P2t...your_descope_project_id - -# --- Naver Cloud Services --- -NAVER_CLOUD_ACCESS_KEY=ncp_iam_... -NAVER_CLOUD_SECRET_KEY=ncp_iam_... -NAVER_CLOUD_SERVICE_ID=ncp:sms:kr:...:... -NAVER_SENDER_PHONE_NUMBER=... diff --git a/userfront/Dockerfile b/userfront/Dockerfile index 1e8a7b95..47052a28 100644 --- a/userfront/Dockerfile +++ b/userfront/Dockerfile @@ -1,5 +1,6 @@ # Stage 1: Build Flutter FROM ghcr.io/cirruslabs/flutter:stable AS build +ENV RUN_FLUTTER_AS_ROOT=true # ENV RUN_FLUTTER_AS_ROOT=true WORKDIR /app COPY . . diff --git a/userfront/lib/features/auth/presentation/approve_qr_screen.dart b/userfront/lib/features/auth/presentation/approve_qr_screen.dart index 2422f006..744e8abf 100644 --- a/userfront/lib/features/auth/presentation/approve_qr_screen.dart +++ b/userfront/lib/features/auth/presentation/approve_qr_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:descope/descope.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/services/auth_proxy_service.dart'; +import '../../../../core/services/auth_token_store.dart'; class ApproveQrScreen extends StatefulWidget { final String? pendingRef; @@ -19,8 +20,9 @@ class _ApproveQrScreenState extends State { Future _handleApprove() async { if (widget.pendingRef == null) return; + final storedToken = AuthTokenStore.getToken(); final session = Descope.sessionManager.session; - if (session == null || session.refreshToken.isExpired) { + if (storedToken == null && (session == null || session.refreshToken.isExpired)) { setState(() => _message = "Please log in on your phone first."); context.go('/signin'); // Redirect to login return; @@ -32,9 +34,10 @@ class _ApproveQrScreenState extends State { }); // jwt 유효성 확인 try { + final token = storedToken ?? session?.sessionToken.jwt ?? ''; await AuthProxyService.approveQrLogin( widget.pendingRef!, - session.sessionToken.jwt, + token, ); setState(() { _success = true; diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 1294efe7..ea3bb2cb 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -33,7 +33,6 @@ class _LoginScreenState extends ConsumerState // QR Login Variables String? _qrImageBase64; String? _qrPendingRef; - String? _qrUserCode; bool _isQrLoading = false; Timer? _qrPollingTimer; int _qrRemainingSeconds = 0; @@ -207,7 +206,6 @@ class _LoginScreenState extends ConsumerState setState(() { _isQrLoading = true; _qrImageBase64 = null; - _qrUserCode = null; _qrRemainingSeconds = 0; }); @@ -218,7 +216,6 @@ class _LoginScreenState extends ConsumerState _qrImageBase64 = res['qrCode']; _qrPendingRef = res['pendingRef']; _qrRemainingSeconds = res['expiresIn'] ?? 300; - _qrUserCode = res['userCode']?.toString(); final interval = res['interval']; if (interval is int && interval > 0) { _qrPollIntervalMs = interval * 1000; @@ -992,7 +989,7 @@ class _LoginScreenState extends ConsumerState codeOnly: true, ); }, - child: const Text("코드만 받기(${_formatTime(_linkResendSeconds)})"), + child: Text("코드만 받기(${_formatTime(_linkResendSeconds)})"), ), ], ], @@ -1035,14 +1032,6 @@ class _LoginScreenState extends ConsumerState ), ), const SizedBox(height: 8), - if (_qrUserCode != null) ...[ - Text( - "코드: $_qrUserCode", - textAlign: TextAlign.center, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - ], const Text( "모바일 앱으로 스캔하세요", textAlign: TextAlign.center, diff --git a/userfront/lib/features/auth/presentation/qr_scan_screen.dart b/userfront/lib/features/auth/presentation/qr_scan_screen.dart index d750808d..43aac077 100644 --- a/userfront/lib/features/auth/presentation/qr_scan_screen.dart +++ b/userfront/lib/features/auth/presentation/qr_scan_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:descope/descope.dart'; import '../../../core/services/auth_proxy_service.dart'; +import '../../../core/services/auth_token_store.dart'; class QRScanScreen extends StatefulWidget { const QRScanScreen({super.key}); @@ -49,7 +50,8 @@ class _QRScanScreenState extends State { _log.info('QR Code detected raw: $qrData, ref: $pendingRef'); - final sessionToken = Descope.sessionManager.session?.sessionToken.jwt; + final sessionToken = AuthTokenStore.getToken() ?? + Descope.sessionManager.session?.sessionToken.jwt; if (sessionToken == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -119,4 +121,4 @@ class _QRScanScreenState extends State { ), ); } -} \ No newline at end of file +} diff --git a/userfront/nginx.conf b/userfront/nginx.conf index fd8da73a..900d1b56 100644 --- a/userfront/nginx.conf +++ b/userfront/nginx.conf @@ -25,7 +25,7 @@ server { access_log /var/log/nginx/access.log json_combined; - # Backend API Proxy + # --- Backend API Proxy --- location /api { proxy_pass http://baron_backend:3000; proxy_set_header Host $host; @@ -34,7 +34,55 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # Frontend Static Files + # --- Ory Stack Proxy (via Oathkeeper) --- + # Kratos Public API + location /auth { + proxy_pass http://oathkeeper:4455; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Hydra Public API + location /oidc { + proxy_pass http://oathkeeper:4455; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # --- Internal Web Apps Proxy --- 초반에는 외부 오픈 없이 Private Net 내부에서만 운영 + # AdminFront (Vite Dev Server or Nginx) + # location /admin { + # proxy_pass http://baron_adminfront:5173; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + + # # WebSocket support (for Vite HMR) + # proxy_http_version 1.1; + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection "upgrade"; + # } + + # # DevFront (Vite Dev Server or Nginx) + # location /dev { + # proxy_pass http://baron_devfront:5173; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + + # # WebSocket support (for Vite HMR) + # proxy_http_version 1.1; + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection "upgrade"; + # } + + # --- UserFront Static Files --- location / { root /usr/share/nginx/html; index index.html;