1
0
forked from baron/baron-sso

gateway 분리 아키텍처

This commit is contained in:
Lectom C Han
2026-01-30 17:56:42 +09:00
parent 03a78d75ae
commit 9e9c622600
15 changed files with 691 additions and 55 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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