forked from baron/baron-sso
한 endpoint URL로 전체 서빙 #120
This commit is contained in:
38
.env.sample
38
.env.sample
@@ -57,17 +57,19 @@ ADMIN_EMAIL=admin@baron.co.kr
|
|||||||
ADMIN_PASSWORD=adminPasswordIsNotSimple
|
ADMIN_PASSWORD=adminPasswordIsNotSimple
|
||||||
|
|
||||||
# --- URLs for Proxy/Handoff ---
|
# --- URLs for Proxy/Handoff ---
|
||||||
USERFRONT_URL=https://sso.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용)
|
# Project Public Base URL (Served by UserFront Nginx)
|
||||||
BACKEND_URL=https://sso.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소
|
USERFRONT_URL=https://sso.hmac.kr
|
||||||
|
|
||||||
|
|
||||||
|
# Services proxied via Nginx
|
||||||
|
BACKEND_URL=${USERFRONT_URL}/api
|
||||||
|
OATHKEEPER_PUBLIC_URL=${USERFRONT_URL}
|
||||||
|
|
||||||
# ory-stack 변수들
|
# ory-stack 변수들
|
||||||
ORY_POSTGRES_TAG=17-trixie
|
ORY_POSTGRES_TAG=17-trixie
|
||||||
ORY_POSTGRES_USER=ory
|
ORY_POSTGRES_USER=ory
|
||||||
ORY_POSTGRES_PASSWORD=EuBV5ywvXFehkggHQrnYo5727MseEi6i9
|
ORY_POSTGRES_PASSWORD=EuBV5ywvXFehkggHQrnYo5727MseEi6i9
|
||||||
ORY_POSTGRES_DB=ory
|
ORY_POSTGRES_DB=ory
|
||||||
ORY_POSTGRES_PORT=5433
|
# ORY_POSTGRES_PORT=5433 # Internal only
|
||||||
|
|
||||||
KRATOS_DB=ory_kratos
|
KRATOS_DB=ory_kratos
|
||||||
HYDRA_DB=ory_hydra
|
HYDRA_DB=ory_hydra
|
||||||
@@ -75,31 +77,39 @@ KETO_DB=ory_keto
|
|||||||
|
|
||||||
# Ory Kratos Configuration
|
# Ory Kratos Configuration
|
||||||
KRATOS_VERSION=v25.4.0-distroless
|
KRATOS_VERSION=v25.4.0-distroless
|
||||||
KRATOS_PUBLIC_PORT=4433
|
# KRATOS_PUBLIC_PORT=4433 # Internal only
|
||||||
KRATOS_ADMINFRONT_PORT=4434
|
# KRATOS_ADMINFRONT_PORT=4434 # Internal only
|
||||||
|
|
||||||
KRATOS_UI_NODE_VERSION=v25.4.0
|
KRATOS_UI_NODE_VERSION=v25.4.0
|
||||||
KRATOS_UI_PORT=4455
|
# KRATOS_UI_PORT=4455 # Internal only
|
||||||
|
|
||||||
# Ory Hydra Configuration
|
# Ory Hydra Configuration
|
||||||
HYDRA_VERSION=v25.4.0-distroless
|
HYDRA_VERSION=v25.4.0-distroless
|
||||||
HYDRA_PUBLIC_PORT=4441
|
# HYDRA_PUBLIC_PORT=4441 # Internal only
|
||||||
HYDRA_ADMINFRONT_PORT=4445
|
# HYDRA_ADMINFRONT_PORT=4445 # Internal only
|
||||||
|
|
||||||
# Ory Keto Configuration
|
# Ory Keto Configuration
|
||||||
KETO_VERSION=v25.4.0-distroless
|
KETO_VERSION=v25.4.0-distroless
|
||||||
KETO_READ_PORT=4466
|
# KETO_READ_PORT=4466 # Internal only
|
||||||
KETO_WRITE_PORT=4467
|
# KETO_WRITE_PORT=4467 # Internal only
|
||||||
|
|
||||||
# Kratos Selfservice UI upstreams (override for deployments)
|
# Kratos Selfservice UI upstreams (override for deployments)
|
||||||
ORY_SDK_URL=http://kratos:4433
|
ORY_SDK_URL=http://kratos:4433
|
||||||
KRATOS_PUBLIC_URL=http://kratos:4433
|
KRATOS_PUBLIC_URL=http://kratos:4433
|
||||||
KRATOS_ADMIN_URL=http://kratos:4434
|
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
|
KRATOS_UI_URL=http://localhost:4455
|
||||||
|
|
||||||
HYDRA_ADMIN_URL=http://hydra:4445
|
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
|
JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json
|
||||||
|
|
||||||
# Oathkeeper 실행 사용자/프로브 설정
|
# Oathkeeper 실행 사용자/프로브 설정
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ Ory Stack과 애플리케이션 간 통신을 위한 도커 네트워크를 생
|
|||||||
docker network create -d bridge ory-net
|
docker network create -d bridge ory-net
|
||||||
docker network create hydranet
|
docker network create hydranet
|
||||||
docker network create kratosnet
|
docker network create kratosnet
|
||||||
|
docker network create public_net #서비스용
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. 인프라 및 Ory Stack 실행
|
#### 2. 인프라 및 Ory Stack 실행
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/descope/go-sdk/descope"
|
||||||
"github.com/descope/go-sdk/descope/client"
|
"github.com/descope/go-sdk/descope/client"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
@@ -1357,7 +1358,6 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
|||||||
// InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다.
|
// InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다.
|
||||||
func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
|
func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
|
||||||
pendingRef := GenerateSecureToken(16)
|
pendingRef := GenerateSecureToken(16)
|
||||||
userCode := GenerateUserCode()
|
|
||||||
|
|
||||||
// QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다.
|
// QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다.
|
||||||
userfrontURL := os.Getenv("USERFRONT_URL")
|
userfrontURL := os.Getenv("USERFRONT_URL")
|
||||||
@@ -1376,7 +1376,6 @@ func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
|
|||||||
"pendingRef": pendingRef,
|
"pendingRef": pendingRef,
|
||||||
"expiresIn": 300,
|
"expiresIn": 300,
|
||||||
"interval": int(minPollInterval.Seconds()),
|
"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)
|
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에서 세션 확인
|
// 1. Redis에서 세션 확인
|
||||||
val, err := h.RedisService.Get(prefixSession + req.PendingRef)
|
val, err := h.RedisService.Get(prefixSession + req.PendingRef)
|
||||||
if err != nil || val == "" {
|
if err != nil || val == "" {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Session expired or not found"})
|
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{
|
sessionData, _ := json.Marshal(map[string]string{
|
||||||
"status": statusSuccess,
|
"status": statusSuccess,
|
||||||
"jwt": req.Token,
|
"jwt": sessionToken,
|
||||||
})
|
})
|
||||||
h.RedisService.Set(prefixSession+req.PendingRef, string(sessionData), 5*time.Minute)
|
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
|
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) {
|
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) {
|
||||||
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
||||||
if kratosURL == "" {
|
if kratosURL == "" {
|
||||||
@@ -1944,6 +2054,49 @@ func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string
|
|||||||
return result.Identity.ID, result.Identity.Traits, nil
|
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) {
|
func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, error) {
|
||||||
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
||||||
if kratosURL == "" {
|
if kratosURL == "" {
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ services:
|
|||||||
entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"]
|
entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"]
|
||||||
networks:
|
networks:
|
||||||
- ory-net
|
- ory-net
|
||||||
|
- public_net
|
||||||
|
|
||||||
ory_clickhouse:
|
ory_clickhouse:
|
||||||
image: clickhouse/clickhouse-server:latest
|
image: clickhouse/clickhouse-server:latest
|
||||||
@@ -251,3 +252,6 @@ networks:
|
|||||||
kratosnet:
|
kratosnet:
|
||||||
external: true
|
external: true
|
||||||
name: kratosnet
|
name: kratosnet
|
||||||
|
public_net:
|
||||||
|
external: true
|
||||||
|
name: public_net
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ services:
|
|||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
networks:
|
networks:
|
||||||
- baron_net
|
- baron_net
|
||||||
|
|
||||||
userfront:
|
userfront:
|
||||||
build:
|
build:
|
||||||
context: ./userfront
|
context: ./userfront
|
||||||
@@ -97,6 +96,8 @@ services:
|
|||||||
- "${USERFRONT_PORT:-5000}:5000"
|
- "${USERFRONT_PORT:-5000}:5000"
|
||||||
networks:
|
networks:
|
||||||
- baron_net
|
- baron_net
|
||||||
|
- public_net
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -126,3 +127,6 @@ networks:
|
|||||||
ory-net:
|
ory-net:
|
||||||
external: true
|
external: true
|
||||||
name: ory-net
|
name: ory-net
|
||||||
|
public_net:
|
||||||
|
external: true
|
||||||
|
name: public_net
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
### 2.3 QR 로그인
|
### 2.3 QR 로그인
|
||||||
1. `POST /api/v1/auth/qr/init` → `qrCode`, `pendingRef` 수신
|
1. `POST /api/v1/auth/qr/init` → `qrCode`, `pendingRef` 수신
|
||||||
2. 웹은 `POST /api/v1/auth/qr/poll`로 폴링
|
2. 웹은 `POST /api/v1/auth/qr/poll`로 폴링
|
||||||
3. 모바일 앱은 `POST /api/v1/auth/qr/approve`로 승인
|
3. 모바일 앱은 `POST /api/v1/auth/qr/approve`로 승인 (모바일 세션 토큰은 승인 검증용)
|
||||||
4. Polling 응답에서 `sessionJwt` 수신
|
4. Polling 응답에서 `sessionJwt` 수신
|
||||||
|
|
||||||
### 2.4 SMS 코드 로그인
|
### 2.4 SMS 코드 로그인
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
- **ID/Password 로그인**: IDP 추상화 사용 (Ory/Descope) — 정상
|
- **ID/Password 로그인**: IDP 추상화 사용 (Ory/Descope) — 정상
|
||||||
- **Enchanted/Magic Link**: 현재는 Descope 기반 로직이 포함됨. Ory 전환 시 Kratos `code/link` 플로우로 교체 필요
|
- **Enchanted/Magic Link**: 현재는 Descope 기반 로직이 포함됨. Ory 전환 시 Kratos `code/link` 플로우로 교체 필요
|
||||||
- **SMS 코드**: 내부 토큰(placeholder). Kratos 세션 교환 로직 추가 필요
|
- **SMS 코드**: 내부 토큰(placeholder). Kratos 세션 교환 로직 추가 필요
|
||||||
- **QR 로그인**: 모바일 세션 토큰을 웹 세션으로 전달. Ory일 경우 Kratos 세션 토큰을 전달하도록 UI/토큰 저장 방식 정비 필요
|
- **QR 로그인**: 모바일 세션 토큰은 승인 검증용으로만 사용하고, 백엔드에서 웹 전용 세션을 새로 발급
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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=...
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# Stage 1: Build Flutter
|
# Stage 1: Build Flutter
|
||||||
FROM ghcr.io/cirruslabs/flutter:stable AS build
|
FROM ghcr.io/cirruslabs/flutter:stable AS build
|
||||||
|
ENV RUN_FLUTTER_AS_ROOT=true
|
||||||
# ENV RUN_FLUTTER_AS_ROOT=true
|
# ENV RUN_FLUTTER_AS_ROOT=true
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:descope/descope.dart';
|
import 'package:descope/descope.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../../core/services/auth_proxy_service.dart';
|
import '../../../../core/services/auth_proxy_service.dart';
|
||||||
|
import '../../../../core/services/auth_token_store.dart';
|
||||||
|
|
||||||
class ApproveQrScreen extends StatefulWidget {
|
class ApproveQrScreen extends StatefulWidget {
|
||||||
final String? pendingRef;
|
final String? pendingRef;
|
||||||
@@ -19,8 +20,9 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
|||||||
Future<void> _handleApprove() async {
|
Future<void> _handleApprove() async {
|
||||||
if (widget.pendingRef == null) return;
|
if (widget.pendingRef == null) return;
|
||||||
|
|
||||||
|
final storedToken = AuthTokenStore.getToken();
|
||||||
final session = Descope.sessionManager.session;
|
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.");
|
setState(() => _message = "Please log in on your phone first.");
|
||||||
context.go('/signin'); // Redirect to login
|
context.go('/signin'); // Redirect to login
|
||||||
return;
|
return;
|
||||||
@@ -32,9 +34,10 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
|||||||
});
|
});
|
||||||
// jwt 유효성 확인
|
// jwt 유효성 확인
|
||||||
try {
|
try {
|
||||||
|
final token = storedToken ?? session?.sessionToken.jwt ?? '';
|
||||||
await AuthProxyService.approveQrLogin(
|
await AuthProxyService.approveQrLogin(
|
||||||
widget.pendingRef!,
|
widget.pendingRef!,
|
||||||
session.sessionToken.jwt,
|
token,
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_success = true;
|
_success = true;
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
// QR Login Variables
|
// QR Login Variables
|
||||||
String? _qrImageBase64;
|
String? _qrImageBase64;
|
||||||
String? _qrPendingRef;
|
String? _qrPendingRef;
|
||||||
String? _qrUserCode;
|
|
||||||
bool _isQrLoading = false;
|
bool _isQrLoading = false;
|
||||||
Timer? _qrPollingTimer;
|
Timer? _qrPollingTimer;
|
||||||
int _qrRemainingSeconds = 0;
|
int _qrRemainingSeconds = 0;
|
||||||
@@ -207,7 +206,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isQrLoading = true;
|
_isQrLoading = true;
|
||||||
_qrImageBase64 = null;
|
_qrImageBase64 = null;
|
||||||
_qrUserCode = null;
|
|
||||||
_qrRemainingSeconds = 0;
|
_qrRemainingSeconds = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,7 +216,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_qrImageBase64 = res['qrCode'];
|
_qrImageBase64 = res['qrCode'];
|
||||||
_qrPendingRef = res['pendingRef'];
|
_qrPendingRef = res['pendingRef'];
|
||||||
_qrRemainingSeconds = res['expiresIn'] ?? 300;
|
_qrRemainingSeconds = res['expiresIn'] ?? 300;
|
||||||
_qrUserCode = res['userCode']?.toString();
|
|
||||||
final interval = res['interval'];
|
final interval = res['interval'];
|
||||||
if (interval is int && interval > 0) {
|
if (interval is int && interval > 0) {
|
||||||
_qrPollIntervalMs = interval * 1000;
|
_qrPollIntervalMs = interval * 1000;
|
||||||
@@ -992,7 +989,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
codeOnly: true,
|
codeOnly: true,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text("코드만 받기(${_formatTime(_linkResendSeconds)})"),
|
child: Text("코드만 받기(${_formatTime(_linkResendSeconds)})"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -1035,14 +1032,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (_qrUserCode != null) ...[
|
|
||||||
Text(
|
|
||||||
"코드: $_qrUserCode",
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
const Text(
|
const Text(
|
||||||
"모바일 앱으로 스캔하세요",
|
"모바일 앱으로 스캔하세요",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:descope/descope.dart';
|
import 'package:descope/descope.dart';
|
||||||
import '../../../core/services/auth_proxy_service.dart';
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
|
import '../../../core/services/auth_token_store.dart';
|
||||||
|
|
||||||
class QRScanScreen extends StatefulWidget {
|
class QRScanScreen extends StatefulWidget {
|
||||||
const QRScanScreen({super.key});
|
const QRScanScreen({super.key});
|
||||||
@@ -49,7 +50,8 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
|
|
||||||
_log.info('QR Code detected raw: $qrData, ref: $pendingRef');
|
_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 (sessionToken == null) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -119,4 +121,4 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ server {
|
|||||||
|
|
||||||
access_log /var/log/nginx/access.log json_combined;
|
access_log /var/log/nginx/access.log json_combined;
|
||||||
|
|
||||||
# Backend API Proxy
|
# --- Backend API Proxy ---
|
||||||
location /api {
|
location /api {
|
||||||
proxy_pass http://baron_backend:3000;
|
proxy_pass http://baron_backend:3000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -34,7 +34,55 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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 / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|||||||
Reference in New Issue
Block a user