1
0
forked from baron/baron-sso

한 endpoint URL로 전체 서빙 #120

This commit is contained in:
Lectom C Han
2026-01-29 14:42:15 +09:00
parent 209314fea7
commit 77d4e9fd77
12 changed files with 254 additions and 73 deletions

View File

@@ -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 실행 사용자/프로브 설정

View File

@@ -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 실행

View File

@@ -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 == "" {

View File

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

View File

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

View File

@@ -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 로그인**: 모바일 세션 토큰은 승인 검증용으로만 사용하고, 백엔드에서 웹 전용 세션을 새로 발급
---

View File

@@ -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=...

View File

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

View File

@@ -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<ApproveQrScreen> {
Future<void> _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<ApproveQrScreen> {
});
// jwt 유효성 확인
try {
final token = storedToken ?? session?.sessionToken.jwt ?? '';
await AuthProxyService.approveQrLogin(
widget.pendingRef!,
session.sessionToken.jwt,
token,
);
setState(() {
_success = true;

View File

@@ -33,7 +33,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
// 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<LoginScreen>
setState(() {
_isQrLoading = true;
_qrImageBase64 = null;
_qrUserCode = null;
_qrRemainingSeconds = 0;
});
@@ -218,7 +216,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_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<LoginScreen>
codeOnly: true,
);
},
child: const Text("코드만 받기(${_formatTime(_linkResendSeconds)})"),
child: Text("코드만 받기(${_formatTime(_linkResendSeconds)})"),
),
],
],
@@ -1035,14 +1032,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
),
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,

View File

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

View File

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