forked from baron/baron-sso
gateway 분리 아키텍처
This commit is contained in:
@@ -192,7 +192,7 @@ docker compose -f docker-compose.yaml up -d
|
||||
```
|
||||
(또는 한번에 실행: `docker compose -f compose.infra.yaml -f compose.ory.yaml -f docker-compose.yaml up -d`)
|
||||
|
||||
- **userfront**: http://localhost:5000 접속
|
||||
- **gateway (UserFront 프록시)**: http://localhost:5000 접속
|
||||
- **backend**: http://localhost:3000 (API)
|
||||
- **ClickHouse**: http://localhost:8123
|
||||
- **Kratos Public**: http://localhost:4433
|
||||
@@ -283,6 +283,7 @@ baron_sso/
|
||||
├── adminfront/ # React 기반 관리
|
||||
│ ├── src/ # UI 및 로직
|
||||
│ └── pubspec.yaml
|
||||
├── gateway/ # Nginx 기반 Gateway (UserFront 프록시)
|
||||
├── compose.ory-stack.yaml # DB 서비스 (Postgres, ClickHouse)
|
||||
├── compose.infra.yaml # DB 서비스 (Postgres, ClickHouse)
|
||||
├── docker-compose.yaml # 앱 서비스 (Front, Back)
|
||||
|
||||
@@ -39,6 +39,45 @@ func getEnv(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
func normalizeDocsPrefix(prefix string) string {
|
||||
trimmed := strings.TrimSpace(prefix)
|
||||
if trimmed == "" || trimmed == "/" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, "/") {
|
||||
trimmed = "/" + trimmed
|
||||
}
|
||||
return strings.TrimRight(trimmed, "/")
|
||||
}
|
||||
|
||||
func registerDocsRoutes(app *fiber.App, prefix string) {
|
||||
base := normalizeDocsPrefix(prefix)
|
||||
docsPath := base + "/docs"
|
||||
redocPath := base + "/redoc"
|
||||
openapiPath := base + "/openapi.yaml"
|
||||
|
||||
app.Get(docsPath, func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/swagger-ui/index.html")
|
||||
})
|
||||
app.Get(docsPath+"/", func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/swagger-ui/index.html")
|
||||
})
|
||||
app.Static(docsPath, "./docs/swagger-ui")
|
||||
|
||||
app.Get(redocPath, func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/redoc/index.html")
|
||||
})
|
||||
app.Get(redocPath+"/", func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/redoc/index.html")
|
||||
})
|
||||
app.Static(redocPath, "./docs/redoc")
|
||||
|
||||
app.Get(openapiPath, func(c *fiber.Ctx) error {
|
||||
c.Type("yaml")
|
||||
return c.SendFile("./docs/openapi.yaml")
|
||||
})
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Load .env file from possible paths
|
||||
// 1. .env (Current Directory)
|
||||
@@ -304,25 +343,12 @@ func main() {
|
||||
|
||||
// [Security] Disable Swagger/ReDoc in Production
|
||||
if appEnv != "production" {
|
||||
app.Get("/docs", func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/swagger-ui/index.html")
|
||||
})
|
||||
app.Get("/docs/", func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/swagger-ui/index.html")
|
||||
})
|
||||
app.Static("/docs", "./docs/swagger-ui")
|
||||
app.Get("/redoc", func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/redoc/index.html")
|
||||
})
|
||||
app.Get("/redoc/", func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/redoc/index.html")
|
||||
})
|
||||
app.Static("/redoc", "./docs/redoc")
|
||||
app.Get("/openapi.yaml", func(c *fiber.Ctx) error {
|
||||
c.Type("yaml")
|
||||
return c.SendFile("./docs/openapi.yaml")
|
||||
})
|
||||
slog.Info("📚 API Docs enabled", "swagger", "/docs", "redoc", "/redoc")
|
||||
docsPrefix := getEnv("DOCS_BASE_PATH", "/api")
|
||||
registerDocsRoutes(app, "")
|
||||
if normalized := normalizeDocsPrefix(docsPrefix); normalized != "" {
|
||||
registerDocsRoutes(app, normalized)
|
||||
}
|
||||
slog.Info("📚 API Docs enabled", "swagger", "/docs", "redoc", "/redoc", "docs_prefix", docsPrefix)
|
||||
} else {
|
||||
slog.Info("🔒 API Docs disabled in production")
|
||||
}
|
||||
|
||||
@@ -470,6 +470,24 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MessageResponse"
|
||||
|
||||
/api/v1/user/rp/linked:
|
||||
get:
|
||||
tags: [User]
|
||||
summary: 연동된 RP 목록 조회
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/LinkedRpListResponse"
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/sessions:
|
||||
get:
|
||||
tags: [Session]
|
||||
@@ -1117,6 +1135,32 @@ components:
|
||||
code:
|
||||
type: string
|
||||
|
||||
LinkedRpSummary:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
logo:
|
||||
type: string
|
||||
lastAuthenticatedAt:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
scopes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
LinkedRpListResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/LinkedRpSummary"
|
||||
|
||||
SessionSummary:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -12,7 +12,26 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="/openapi.yaml"></redoc>
|
||||
<script src="/redoc/redoc.standalone.js"></script>
|
||||
<div id="redoc-root"></div>
|
||||
<script>
|
||||
(function () {
|
||||
const path = window.location.pathname;
|
||||
const docsBase = path.replace(/\/redoc\/?$/, "/redoc/");
|
||||
const assetBase = docsBase.endsWith("/") ? docsBase : docsBase + "/";
|
||||
const openapiUrl = assetBase.replace(/redoc\/$/, "openapi.yaml");
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = assetBase + "redoc.standalone.js";
|
||||
script.onload = () => {
|
||||
if (window.Redoc) {
|
||||
window.Redoc.init(openapiUrl, {}, document.getElementById("redoc-root"));
|
||||
}
|
||||
};
|
||||
script.onerror = (err) => {
|
||||
console.error("ReDoc load failed", err);
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Baron SSO Swagger UI</title>
|
||||
<link rel="stylesheet" href="/docs/swagger-ui.css" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
@@ -17,22 +16,48 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="/docs/swagger-ui-bundle.js"></script>
|
||||
<script src="/docs/swagger-ui-standalone-preset.js"></script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
SwaggerUIBundle({
|
||||
url: "/openapi.yaml",
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
persistAuthorization: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset,
|
||||
],
|
||||
layout: "StandaloneLayout",
|
||||
});
|
||||
};
|
||||
(function () {
|
||||
const path = window.location.pathname;
|
||||
const docsBase = path.replace(/\/docs\/?$/, "/docs/");
|
||||
const assetBase = docsBase.endsWith("/") ? docsBase : docsBase + "/";
|
||||
|
||||
const css = document.createElement("link");
|
||||
css.rel = "stylesheet";
|
||||
css.href = assetBase + "swagger-ui.css";
|
||||
document.head.appendChild(css);
|
||||
|
||||
const loadScript = (src) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
|
||||
Promise.all([
|
||||
loadScript(assetBase + "swagger-ui-bundle.js"),
|
||||
loadScript(assetBase + "swagger-ui-standalone-preset.js"),
|
||||
])
|
||||
.then(() => {
|
||||
const openapiUrl = assetBase.replace(/docs\/$/, "openapi.yaml");
|
||||
SwaggerUIBundle({
|
||||
url: openapiUrl,
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
persistAuthorization: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset,
|
||||
],
|
||||
layout: "StandaloneLayout",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Swagger UI load failed", err);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -74,6 +74,7 @@ type AuthHandler struct {
|
||||
EmailService domain.EmailService
|
||||
RedisService *service.RedisService
|
||||
DescopeClient *client.DescopeClient
|
||||
KratosAdmin *service.KratosAdminService
|
||||
IdpProvider domain.IdentityProvider
|
||||
AuditRepo domain.AuditRepository
|
||||
Hydra *service.HydraAdminService
|
||||
@@ -156,6 +157,7 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident
|
||||
EmailService: service.NewEmailService(),
|
||||
RedisService: redisService,
|
||||
DescopeClient: descopeClient,
|
||||
KratosAdmin: service.NewKratosAdminService(),
|
||||
IdpProvider: idpProvider,
|
||||
AuditRepo: auditRepo,
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
@@ -823,13 +825,20 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
|
||||
sessionToken := authInfo.SessionToken.JWT
|
||||
c.Locals("login_id", loginID)
|
||||
setSessionIDLocal(c, authInfo.SessionToken)
|
||||
sessionID := extractSessionIDFromToken(authInfo.SessionToken)
|
||||
|
||||
slog.Info("[Verify] Success! Updating Redis session", "pendingRef", pendingRef)
|
||||
sessionData, _ := json.Marshal(map[string]string{
|
||||
sessionData := map[string]string{
|
||||
"status": statusSuccess,
|
||||
"jwt": sessionToken,
|
||||
})
|
||||
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration)
|
||||
}
|
||||
if sessionID != "" {
|
||||
sessionData["session_id"] = sessionID
|
||||
}
|
||||
sessionDataJSON, _ := json.Marshal(sessionData)
|
||||
h.RedisService.Set(prefixSession+pendingRef, string(sessionDataJSON), defaultExpiration)
|
||||
|
||||
h.writeLinkAuditLog(loginID, pendingRef, authInfo.SessionToken, c)
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"token": sessionToken,
|
||||
@@ -2226,6 +2235,43 @@ func (h *AuthHandler) writeQrAuditLog(loginID, pendingRef string, sessionToken *
|
||||
_ = h.AuditRepo.Create(log)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) writeLinkAuditLog(loginID, pendingRef string, sessionToken *domain.Token, c *fiber.Ctx) {
|
||||
if h.AuditRepo == nil {
|
||||
return
|
||||
}
|
||||
meta := qrMeta{
|
||||
IPAddress: extractClientIPFromHeaders(c),
|
||||
UserAgent: "",
|
||||
}
|
||||
if c != nil {
|
||||
meta.UserAgent = c.Get("User-Agent")
|
||||
}
|
||||
sessionID := extractSessionIDFromToken(sessionToken)
|
||||
details := map[string]any{
|
||||
"path": "/api/v1/auth/magic-link/verify",
|
||||
"login_id": loginID,
|
||||
"pending_ref": pendingRef,
|
||||
}
|
||||
if sessionID != "" {
|
||||
details["session_id"] = sessionID
|
||||
}
|
||||
detailsJSON, _ := json.Marshal(details)
|
||||
|
||||
log := &domain.AuditLog{
|
||||
EventID: GenerateSecureToken(16),
|
||||
Timestamp: time.Now(),
|
||||
UserID: "",
|
||||
SessionID: sessionID,
|
||||
EventType: "POST /api/v1/auth/magic-link/verify",
|
||||
Status: "success",
|
||||
IPAddress: meta.IPAddress,
|
||||
UserAgent: meta.UserAgent,
|
||||
Details: string(detailsJSON),
|
||||
AuthMethod: "링크",
|
||||
}
|
||||
_ = h.AuditRepo.Create(log)
|
||||
}
|
||||
|
||||
func extractClientIPFromHeaders(c *fiber.Ctx) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
@@ -2451,6 +2497,26 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
||||
func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) {
|
||||
token := h.getBearerToken(c)
|
||||
if token != "" {
|
||||
if looksLikeJWT(token) && h.DescopeClient != nil && h.KratosAdmin != nil {
|
||||
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
||||
if err == nil && authorized {
|
||||
userResponse, loadErr := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
|
||||
if loadErr == nil {
|
||||
if email := strings.TrimSpace(userResponse.Email); email != "" {
|
||||
if identityID, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), email); err == nil && identityID != "" {
|
||||
return identityID, nil
|
||||
}
|
||||
}
|
||||
if phone := strings.TrimSpace(userResponse.Phone); phone != "" {
|
||||
normalized := normalizePhoneForLoginID(phone)
|
||||
if identityID, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), normalized); err == nil && identityID != "" {
|
||||
return identityID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return userToken.ID, nil
|
||||
}
|
||||
}
|
||||
return h.resolveIdentityID(c, token)
|
||||
}
|
||||
cookie := c.Get("Cookie")
|
||||
|
||||
@@ -57,6 +57,48 @@ func (s *KratosAdminService) ListIdentities(ctx context.Context) ([]KratosIdenti
|
||||
return identities, nil
|
||||
}
|
||||
|
||||
func (s *KratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
if identifier == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
query := req.URL.Query()
|
||||
query.Set("credentials_identifier", identifier)
|
||||
req.URL.RawQuery = query.Encode()
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return "", nil
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return "", fmt.Errorf("kratos admin search failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var identities []struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(identities) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return identities[0].ID, nil
|
||||
}
|
||||
|
||||
func (s *KratosAdminService) GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error) {
|
||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
|
||||
@@ -77,7 +77,9 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
||||
traits := map[string]interface{}{
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"phone_number": user.PhoneNumber,
|
||||
}
|
||||
if user.PhoneNumber != "" {
|
||||
traits["phone_number"] = user.PhoneNumber
|
||||
}
|
||||
for k, v := range user.Attributes {
|
||||
traits[k] = v
|
||||
|
||||
@@ -25,7 +25,7 @@ services:
|
||||
kratos-migrate:
|
||||
image: oryd/kratos:${KRATOS_VERSION:-v25.4.0}
|
||||
environment:
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB}?sslmode=disable&max_conns=20
|
||||
- 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}
|
||||
@@ -52,7 +52,7 @@ services:
|
||||
ports:
|
||||
- "${KRATOS_PUBLIC_PORT:-4433}:4433"
|
||||
environment:
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB}?sslmode=disable&max_conns=20
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
|
||||
- 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}
|
||||
@@ -94,7 +94,7 @@ services:
|
||||
hydra-migrate:
|
||||
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
|
||||
environment:
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20
|
||||
command: migrate sql up -e --yes
|
||||
depends_on:
|
||||
postgres_ory:
|
||||
@@ -106,10 +106,10 @@ services:
|
||||
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
|
||||
container_name: ory_hydra
|
||||
environment:
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20
|
||||
- URLS_SELF_ISSUER=${BACKEND_URL:-http://127.0.0.1:3000}
|
||||
- URLS_LOGIN=${BACKEND_URL:-http://127.0.0.1:3000}/login
|
||||
- URLS_CONSENT=${BACKEND_URL:-http://127.0.0.1:3000}/consent
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20
|
||||
- URLS_SELF_ISSUER=${HYDRA_PUBLIC_URL:-http://localhost:5000/oidc}
|
||||
- URLS_LOGIN=${USERFRONT_URL:-http://localhost:5000}/login
|
||||
- URLS_CONSENT=${USERFRONT_URL:-http://localhost:5000}/consent
|
||||
- SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- ./docker/ory/hydra:/etc/config/hydra
|
||||
@@ -127,7 +127,7 @@ services:
|
||||
keto-migrate:
|
||||
image: oryd/keto:${KETO_VERSION:-v25.4.0}
|
||||
environment:
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20
|
||||
volumes:
|
||||
- ./docker/ory/keto:/etc/config/keto
|
||||
command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"]
|
||||
@@ -141,7 +141,7 @@ services:
|
||||
image: oryd/keto:${KETO_VERSION:-v25.4.0}
|
||||
container_name: ory_keto
|
||||
environment:
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20
|
||||
volumes:
|
||||
- ./docker/ory/keto:/etc/config/keto
|
||||
command: serve -c /etc/config/keto/keto.yml
|
||||
|
||||
188
devfront/hydra-rp-dummy.py
Normal file
188
devfront/hydra-rp-dummy.py
Normal file
@@ -0,0 +1,188 @@
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from http import cookiejar
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
CLIENT_ID = os.environ["CLIENT_ID"]
|
||||
SUBJECT = os.environ["SUBJECT"]
|
||||
REDIRECT_URI = os.environ["REDIRECT_URI"]
|
||||
SCOPE = os.environ["SCOPE"]
|
||||
STATE = os.environ["STATE"]
|
||||
NONCE = os.environ["NONCE"]
|
||||
ADMIN_BASE = os.environ.get("HYDRA_ADMIN_URL", "http://127.0.0.1:4445")
|
||||
PUBLIC_BASE = os.environ.get("HYDRA_PUBLIC_URL", "http://127.0.0.1:4444")
|
||||
|
||||
|
||||
def _put_json(url: str, payload: dict) -> dict:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, method="PUT")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
return json.loads(body) if body else {}
|
||||
|
||||
|
||||
def accept_login(challenge: str) -> str:
|
||||
url = f"{ADMIN_BASE}/oauth2/auth/requests/login/accept?login_challenge={urllib.parse.quote(challenge)}"
|
||||
payload = {"subject": SUBJECT, "remember": True, "remember_for": 3600}
|
||||
data = _put_json(url, payload)
|
||||
return data.get("redirect_to", "")
|
||||
|
||||
|
||||
def accept_consent(challenge: str) -> str:
|
||||
url = f"{ADMIN_BASE}/oauth2/auth/requests/consent/accept?consent_challenge={urllib.parse.quote(challenge)}"
|
||||
payload = {"grant_scope": ["openid", "profile", "email"], "remember": True, "remember_for": 3600}
|
||||
data = _put_json(url, payload)
|
||||
return data.get("redirect_to", "")
|
||||
|
||||
|
||||
def _location_from_response(url: str, cookie_header: str | None) -> str:
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
if cookie_header:
|
||||
req.add_header("Cookie", cookie_header)
|
||||
opener = urllib.request.build_opener(NoRedirect())
|
||||
try:
|
||||
opener.open(req, timeout=5)
|
||||
except urllib.error.HTTPError as err:
|
||||
return err.headers.get("Location", "")
|
||||
return ""
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
raise urllib.error.HTTPError(newurl, code, msg, headers, fp)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
login_challenge = (params.get("login_challenge") or [""])[0]
|
||||
consent_challenge = (params.get("consent_challenge") or [""])[0]
|
||||
login_verifier = (params.get("login_verifier") or [""])[0]
|
||||
consent_verifier = (params.get("consent_verifier") or [""])[0]
|
||||
|
||||
if parsed.path == "/oauth2/auth" and consent_verifier:
|
||||
query = urllib.parse.urlencode({
|
||||
"consent_verifier": consent_verifier,
|
||||
"client_id": (params.get("client_id") or [""])[0],
|
||||
"redirect_uri": (params.get("redirect_uri") or [""])[0],
|
||||
"response_type": (params.get("response_type") or [""])[0],
|
||||
"scope": (params.get("scope") or [""])[0],
|
||||
"state": (params.get("state") or [""])[0],
|
||||
"nonce": (params.get("nonce") or [""])[0],
|
||||
})
|
||||
public_url = f"{PUBLIC_BASE}/oauth2/auth?{query}"
|
||||
location = _location_from_response(public_url, self.headers.get("Cookie"))
|
||||
print(f"consent_verifier_location={location}")
|
||||
if not location:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"missing redirect location")
|
||||
return
|
||||
self.send_response(302)
|
||||
self.send_header("Location", location)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
if parsed.path == "/oauth2/auth" and login_verifier:
|
||||
query = urllib.parse.urlencode({
|
||||
"login_verifier": login_verifier,
|
||||
"client_id": (params.get("client_id") or [""])[0],
|
||||
"redirect_uri": (params.get("redirect_uri") or [""])[0],
|
||||
"response_type": (params.get("response_type") or [""])[0],
|
||||
"scope": (params.get("scope") or [""])[0],
|
||||
"state": (params.get("state") or [""])[0],
|
||||
"nonce": (params.get("nonce") or [""])[0],
|
||||
})
|
||||
public_url = f"{PUBLIC_BASE}/oauth2/auth?{query}"
|
||||
location = _location_from_response(public_url, self.headers.get("Cookie"))
|
||||
print(f"login_verifier_location={location}")
|
||||
if not location:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"missing redirect location")
|
||||
return
|
||||
consent_challenge = urllib.parse.parse_qs(urllib.parse.urlparse(location).query).get(
|
||||
"consent_challenge",
|
||||
[""],
|
||||
)[0]
|
||||
if not consent_challenge:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
self.wfile.write(f"missing consent_challenge location={location}".encode("utf-8"))
|
||||
return
|
||||
redirect_to = accept_consent(consent_challenge)
|
||||
if not redirect_to:
|
||||
self.send_response(500)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"consent accept failed")
|
||||
return
|
||||
self.send_response(302)
|
||||
self.send_header("Location", redirect_to)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
if login_challenge:
|
||||
redirect_to = accept_login(login_challenge)
|
||||
elif consent_challenge:
|
||||
redirect_to = accept_consent(consent_challenge)
|
||||
else:
|
||||
redirect_to = ""
|
||||
|
||||
if not redirect_to:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"missing challenge")
|
||||
return
|
||||
|
||||
self.send_response(302)
|
||||
self.send_header("Location", redirect_to)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
return
|
||||
|
||||
|
||||
class StopAtRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if newurl.startswith(REDIRECT_URI):
|
||||
raise urllib.error.HTTPError(newurl, code, msg, headers, fp)
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def main():
|
||||
server = HTTPServer(("127.0.0.1", 3000), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
encoded_redirect = urllib.parse.quote(REDIRECT_URI, safe="")
|
||||
encoded_scope = urllib.parse.quote(SCOPE, safe="")
|
||||
auth_url = (
|
||||
f"{PUBLIC_BASE}/oauth2/auth?response_type=code"
|
||||
f"&client_id={CLIENT_ID}"
|
||||
f"&redirect_uri={encoded_redirect}"
|
||||
f"&scope={encoded_scope}"
|
||||
f"&state={STATE}"
|
||||
f"&nonce={NONCE}"
|
||||
)
|
||||
|
||||
jar = cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar), StopAtRedirect())
|
||||
try:
|
||||
opener.open(auth_url, timeout=10)
|
||||
except urllib.error.HTTPError as err:
|
||||
body = err.read().decode("utf-8") if hasattr(err, "read") else ""
|
||||
print(f"error_url={err.geturl()}")
|
||||
print(f"error_code={err.code}")
|
||||
if body:
|
||||
print(f"error_body={body}")
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -81,6 +81,29 @@ services:
|
||||
- /app/node_modules
|
||||
networks:
|
||||
- baron_net
|
||||
|
||||
gateway:
|
||||
build:
|
||||
context: ./gateway
|
||||
dockerfile: Dockerfile
|
||||
container_name: baron_gateway
|
||||
restart: always
|
||||
ports:
|
||||
- "${USERFRONT_PORT:-5000}:5000"
|
||||
networks:
|
||||
- baron_net
|
||||
- public_net
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
userfront:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
userfront:
|
||||
build:
|
||||
context: ./userfront
|
||||
@@ -92,12 +115,8 @@ services:
|
||||
- BACKEND_URL=${BACKEND_URL}
|
||||
- USERFRONT_URL=${USERFRONT_URL}
|
||||
- APP_ENV=${APP_ENV}
|
||||
ports:
|
||||
- "${USERFRONT_PORT:-5000}:5000"
|
||||
networks:
|
||||
- baron_net
|
||||
- public_net
|
||||
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
@@ -108,6 +127,12 @@ services:
|
||||
echo \"APP_ENV=$${APP_ENV}\" >> /usr/share/nginx/html/assets/.env &&
|
||||
cp /usr/share/nginx/html/assets/.env /usr/share/nginx/html/.env &&
|
||||
nginx -g 'daemon off;'"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Dummy service to wait for infra network if needed,
|
||||
# but essentially we assume infra is running.
|
||||
|
||||
@@ -37,3 +37,41 @@
|
||||
2. `/oauth2/auth/requests/login/accept` → `login_verifier`
|
||||
3. `/oauth2/auth?client_id=...&login_verifier=...` 호출 → Location 헤더에서 `consent_challenge` 추출
|
||||
4. `/oauth2/auth/requests/consent/accept` 호출
|
||||
|
||||
## 추가 시도 및 결과
|
||||
|
||||
### 5) login_verifier를 이용한 consent_challenge 생성 (public /oauth2/auth 호출)
|
||||
- 시도: `login_verifier`로 `/oauth2/auth?login_verifier=...` 호출 → Location에서 `consent_challenge` 추출 시도
|
||||
- 실패: `login_verifier has already been used` 또는 `invalid_client` 등으로 예제 redirect(`https://example.com/callback?...`) 에러 반환
|
||||
- 원인 추정:
|
||||
- `login_verifier`는 1회성으로, 기존 흐름(redirect_to 또는 consent app)에 의해 이미 사용됨
|
||||
- `login_verifier`만으로는 client 맥락이 부족하여 invalid_client 발생 가능
|
||||
|
||||
### 6) 임시 consent app(127.0.0.1:3000) 없이 redirect_to 직접 호출
|
||||
- 실패: consent app 미기동으로 연결 불가
|
||||
- 원인: login/consent UI URL이 기본값(127.0.0.1:3000)이라 실제 서비스 필요
|
||||
|
||||
### 7) Python 컨테이너에서 임시 consent app + 클라이언트 플로우 실행
|
||||
- 방법: python:3.12-alpine 컨테이너에서
|
||||
- 127.0.0.1:3000에 최소 consent 앱 실행
|
||||
- `/oauth2/auth` 호출을 따라가며 login/consent 자동 수락
|
||||
- 결과:
|
||||
- 첫 시도에서 `state` 길이 부족으로 `invalid_state` 발생
|
||||
- 이후 `state/nonce` 길이 충분히 늘려 재시도 중
|
||||
|
||||
## 현재 결론
|
||||
- 실제 consent 앱이 없으면 Hydra는 consent_challenge를 만들 수 없고 흐름이 중단됨.
|
||||
- 임시 consent 앱을 컨테이너 내부에서 띄우는 방식이 가장 현실적이며, 이 흐름으로 계속 진행 중.
|
||||
|
||||
### 8) devfront/hydra-rp-dummy.py + docker mount 실행
|
||||
- 방식: `hydra-rp-dummy.py`를 컨테이너에 마운트하여 임시 consent app(127.0.0.1:3000)으로 로그인/동의 자동 수락
|
||||
- 결과: 최종 리다이렉트가 `request_forbidden` (CSRF 쿠키 없음) 에러로 종료됨
|
||||
- 하지만 Hydra Admin 조회 결과 consent 세션은 생성됨
|
||||
- `handled_at`: 2026-01-30T05:01:46.770699Z
|
||||
- subject: `22607c1b-bfbf-4a90-9505-36b348472e7a`
|
||||
- client_id: `52a597f0-5b06-4fcb-b804-93e88a56a75a`
|
||||
- grant_scope: `openid profile email`
|
||||
|
||||
### 상태
|
||||
- consent 세션 생성 완료(확인됨)
|
||||
- 최종 리다이렉트 단계에서 CSRF 오류는 남아 있으나, 목적(연동/동의 저장)은 달성됨
|
||||
|
||||
51
docs/hydra-rp-dummy.md
Normal file
51
docs/hydra-rp-dummy.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# hydra-rp-dummy 사용 기록
|
||||
|
||||
## 목적
|
||||
`devfront/hydra-rp-dummy.py`를 이용해 Hydra에 더미 RP consent를 생성하고, UserFront 활동상황 카드에 반영되는지 확인합니다.
|
||||
|
||||
## 사전 조건
|
||||
- Hydra/크라토스 스택이 실행 중이어야 합니다.
|
||||
- `ory_hydra` 컨테이너가 존재해야 합니다.
|
||||
- Docker 이미지 `python:3.12-alpine`가 필요합니다.
|
||||
|
||||
## 입력 값
|
||||
- `CLIENT_ID`: 더미 RP의 client_id
|
||||
- `SUBJECT`: Kratos identity id (예: `22607c1b-bfbf-4a90-9505-36b348472e7a`)
|
||||
- `REDIRECT_URI`: client에 등록된 redirect_uri
|
||||
- `SCOPE`: `openid profile email`
|
||||
- `STATE`, `NONCE`: 충분히 긴 랜덤 값
|
||||
|
||||
## 실행 방법
|
||||
다음 명령으로 `hydra-rp-dummy.py`를 컨테이너에 마운트해 실행합니다.
|
||||
|
||||
```bash
|
||||
docker run --rm --network container:ory_hydra \
|
||||
-v /home/lectom/repos/baron-sso/devfront/hydra-rp-dummy.py:/tmp/hydra-rp-dummy.py:ro \
|
||||
-e CLIENT_ID=52a597f0-5b06-4fcb-b804-93e88a56a75a \
|
||||
-e SUBJECT=22607c1b-bfbf-4a90-9505-36b348472e7a \
|
||||
-e REDIRECT_URI=https://example.com/callback \
|
||||
-e SCOPE='openid profile email' \
|
||||
-e STATE=state-$(date +%s%N) \
|
||||
-e NONCE=nonce-$(date +%s%N) \
|
||||
python:3.12-alpine python /tmp/hydra-rp-dummy.py
|
||||
```
|
||||
|
||||
## 동작 방식 요약
|
||||
- `hydra-rp-dummy.py`가 127.0.0.1:3000에 임시 consent app을 띄웁니다.
|
||||
- `/oauth2/auth` 호출로 login_challenge를 받고 자동 수락합니다.
|
||||
- 이어서 consent_challenge를 받아 자동 수락합니다.
|
||||
- 마지막 redirect 단계에서 CSRF 에러가 발생할 수 있으나, **consent 세션 자체는 생성됩니다.**
|
||||
|
||||
## 생성 확인 방법
|
||||
Hydra Admin API에서 consent 세션을 확인합니다.
|
||||
|
||||
```bash
|
||||
docker run --rm --network container:ory_hydra curlimages/curl:8.11.1 \
|
||||
sh -lc 'curl -s "http://127.0.0.1:4445/oauth2/auth/sessions/consent?subject=22607c1b-bfbf-4a90-9505-36b348472e7a&client=52a597f0-5b06-4fcb-b804-93e88a56a75a"'
|
||||
```
|
||||
|
||||
예시 응답에 `grant_scope`, `handled_at`, `client_id` 등이 포함되면 성공입니다.
|
||||
|
||||
## 참고
|
||||
- `URLS_LOGIN`, `URLS_CONSENT`는 현재 `http://127.0.0.1:3000`으로 설정되어 있습니다.
|
||||
- 따라서 임시 consent app 없이는 consent 생성이 진행되지 않습니다.
|
||||
6
gateway/Dockerfile
Normal file
6
gateway/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 5000
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
103
gateway/nginx.conf
Normal file
103
gateway/nginx.conf
Normal file
@@ -0,0 +1,103 @@
|
||||
# ISO8601 시간을 "YYYY-MM-DD HH:mm:ss" 형식으로 변환
|
||||
map $time_iso8601 $time_custom {
|
||||
"~^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})" "$1-$2-$3 $4:$5:$6";
|
||||
}
|
||||
|
||||
# Go slog 포맷과 맞춘 JSON 액세스 로그
|
||||
log_format json_combined escape=json
|
||||
'{'
|
||||
'"time":"$time_custom",'
|
||||
'"level":"INFO",'
|
||||
'"msg":"http_access",'
|
||||
'"svc":"baron-gateway",'
|
||||
'"status":$status,'
|
||||
'"method":"$request_method",'
|
||||
'"path":"$request_uri",'
|
||||
'"latency":"${request_time}s",'
|
||||
'"ip":"$remote_addr",'
|
||||
'"forwarded_for":"$http_x_forwarded_for",'
|
||||
'"user_agent":"$http_user_agent"'
|
||||
'}';
|
||||
|
||||
server {
|
||||
listen 5000;
|
||||
include /etc/nginx/mime.types;
|
||||
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $backend_upstream http://baron_backend:3000;
|
||||
set $userfront_upstream http://baron_userfront:5000;
|
||||
set $oathkeeper_upstream http://oathkeeper:4455;
|
||||
|
||||
error_log /dev/stderr warn;
|
||||
access_log /var/log/nginx/access.log json_combined;
|
||||
|
||||
# 안정성 튜닝
|
||||
client_max_body_size 10m;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# --- Backend API Proxy ---
|
||||
location /api {
|
||||
proxy_pass $backend_upstream;
|
||||
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;
|
||||
}
|
||||
|
||||
# --- Ory Stack Proxy (via Oathkeeper) ---
|
||||
# Kratos Public API
|
||||
location /auth {
|
||||
proxy_pass $oathkeeper_upstream;
|
||||
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 $oathkeeper_upstream;
|
||||
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;
|
||||
}
|
||||
|
||||
# --- 내부 웹앱 프록시 (초기에는 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 지원 (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 지원 (Vite HMR)
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Upgrade $http_upgrade;
|
||||
# proxy_set_header Connection "upgrade";
|
||||
# }
|
||||
|
||||
# --- UserFront 정적 파일 프록시 ---
|
||||
location / {
|
||||
proxy_pass $userfront_upstream;
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user