forked from baron/baron-sso
Merge pull request 'feature/df-content' (#253) from feature/df-content into dev
Reviewed-on: baron/baron-sso#253
This commit is contained in:
@@ -92,6 +92,8 @@ KETO_READ_URL=http://keto:4466
|
||||
KETO_WRITE_URL=http://keto:4467
|
||||
# KETO_READ_PORT=4466 # Internal only
|
||||
# KETO_WRITE_PORT=4467 # Internal only
|
||||
KETO_READ_URL=http://keto:4466
|
||||
KETO_WRITE_URL=http://keto:4467
|
||||
|
||||
# Kratos Selfservice UI upstreams (override for deployments)
|
||||
ORY_SDK_URL=http://kratos:4433
|
||||
|
||||
@@ -489,6 +489,7 @@ func main() {
|
||||
|
||||
// Auth Proxy Routes
|
||||
auth := api.Group("/auth")
|
||||
auth.All("/oidc/*", authHandler.ProxyOidc)
|
||||
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
||||
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
|
||||
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
|
||||
@@ -553,7 +554,7 @@ func main() {
|
||||
KetoService: ketoService,
|
||||
})
|
||||
requireAdmin := middleware.RequireRole(middleware.RBACConfig{
|
||||
AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin},
|
||||
AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin},
|
||||
AuthHandler: authHandler,
|
||||
KetoService: ketoService,
|
||||
})
|
||||
|
||||
@@ -34,6 +34,7 @@ func SeedAdminIdentity(idp domain.IdentityProvider) error {
|
||||
"affiliationType": "internal",
|
||||
"companyCode": "",
|
||||
"grade": "admin",
|
||||
"role": domain.RoleSuperAdmin,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1526,7 +1526,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
||||
loginID := strings.TrimSpace(req.LoginID)
|
||||
ale.LoginIDs["loginId"] = req.LoginID // 원문
|
||||
ale.LoginIDs["loginId_normalized"] = loginID
|
||||
ale.NewPassword = req.Password // For test only, logging password (sensitive)
|
||||
// ale.NewPassword = req.Password // For test only, logging password (sensitive)
|
||||
|
||||
ale.Log(slog.LevelInfo, "Attempting to login")
|
||||
|
||||
@@ -1568,22 +1568,25 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
||||
|
||||
// --- OIDC 로그인 흐름 처리 ---
|
||||
if req.LoginChallenge != "" {
|
||||
slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge)
|
||||
slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge, "subject", subject)
|
||||
|
||||
// Check if the client is active
|
||||
loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge)
|
||||
if err == nil && loginReq != nil && loginReq.Client.Metadata != nil {
|
||||
if status, ok := loginReq.Client.Metadata["status"].(string); ok {
|
||||
if strings.ToLower(status) == "inactive" {
|
||||
slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID)
|
||||
return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.")
|
||||
if err == nil && loginReq != nil {
|
||||
slog.Info("OIDC Client Info", "client_id", loginReq.Client.ClientID, "name", loginReq.Client.ClientName)
|
||||
if loginReq.Client.Metadata != nil {
|
||||
if status, ok := loginReq.Client.Metadata["status"].(string); ok {
|
||||
if strings.ToLower(status) == "inactive" {
|
||||
slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID)
|
||||
return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject)
|
||||
if err != nil {
|
||||
slog.Error("failed to accept hydra login request", "error", err)
|
||||
slog.Error("failed to accept hydra login request", "error", err, "challenge", req.LoginChallenge)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
|
||||
}
|
||||
slog.Info("Hydra login request accepted", "redirectTo", acceptResp.RedirectTo)
|
||||
@@ -1595,6 +1598,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
||||
|
||||
resp := fiber.Map{
|
||||
"sessionToken": authInfo.SessionToken.JWT,
|
||||
"sessionJwt": authInfo.SessionToken.JWT, // Frontend compatibility
|
||||
"status": "ok",
|
||||
"provider": h.IdpProvider.Name(),
|
||||
}
|
||||
@@ -2480,7 +2484,56 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string {
|
||||
return phone
|
||||
}
|
||||
|
||||
// GetMe - Returns current user's profile with enriched data from local DB
|
||||
// ProxyOidc - 프론트엔드의 OIDC 요청을 내부 Hydra 서비스로 프록시합니다.
|
||||
func (h *AuthHandler) ProxyOidc(c *fiber.Ctx) error {
|
||||
path := c.Params("*")
|
||||
// [Strict] Always use internal Docker network address for proxying to avoid external loops
|
||||
targetURL := "http://hydra:4444"
|
||||
|
||||
// 프록시 URL 구성
|
||||
u, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "invalid hydra public url")
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/") + "/" + path
|
||||
u.RawQuery = string(c.Request().URI().QueryString())
|
||||
|
||||
slog.Debug("Proxying OIDC request", "from", c.Path(), "to", u.String())
|
||||
|
||||
// 요청 준비
|
||||
req, err := http.NewRequestWithContext(c.Context(), c.Method(), u.String(), bytes.NewReader(c.Body()))
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to create proxy request")
|
||||
}
|
||||
|
||||
// 헤더 복사
|
||||
c.Request().Header.VisitAll(func(key, value []byte) {
|
||||
k := string(key)
|
||||
if k != "Host" && k != "Connection" {
|
||||
req.Header.Add(k, string(value))
|
||||
}
|
||||
})
|
||||
|
||||
// 요청 실행 (Hydra 내부 HttpClient 사용)
|
||||
resp, err := h.Hydra.HttpClient().Do(req)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusServiceUnavailable, "hydra public api unavailable")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 응답 헤더 복사
|
||||
for k, values := range resp.Header {
|
||||
for _, v := range values {
|
||||
c.Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 코드 및 바디 설정
|
||||
c.Status(resp.StatusCode)
|
||||
_, err = io.Copy(c.Response().BodyWriter(), resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
|
||||
profile, err := h.resolveCurrentProfile(c)
|
||||
if err != nil {
|
||||
@@ -4797,26 +4850,40 @@ func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string
|
||||
req.Header.Set("X-Session-Token", sessionToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var result struct {
|
||||
Identity struct {
|
||||
ID string `json:"id"`
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
} `json:"identity"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil {
|
||||
return result.Identity.ID, result.Identity.Traits, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Identity struct {
|
||||
ID string `json:"id"`
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
} `json:"identity"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", nil, err
|
||||
// 2. Kratos 실패 시 Hydra Introspection 시도 (OIDC Access Token 대응)
|
||||
if h.Hydra != nil {
|
||||
slog.Debug("[Auth] Kratos whoami failed, trying Hydra introspection", "token_prefix", sessionToken[:min(len(sessionToken), 10)])
|
||||
introspection, err := h.Hydra.IntrospectToken(context.Background(), sessionToken)
|
||||
if err == nil && introspection["active"] == true {
|
||||
subject, _ := introspection["sub"].(string)
|
||||
if subject != "" {
|
||||
// Hydra는 Traits를 직접 주지 않으므로, Kratos Admin API로 상세 정보를 가져옴
|
||||
identity, err := h.KratosAdmin.GetIdentity(context.Background(), subject)
|
||||
if err == nil && identity != nil {
|
||||
return identity.ID, identity.Traits, nil
|
||||
}
|
||||
// Identity 정보가 없더라도 최소한 Subject는 반환
|
||||
return subject, map[string]interface{}{}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.Identity.ID, result.Identity.Traits, nil
|
||||
return "", nil, fmt.Errorf("invalid session or token")
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package middleware
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -25,7 +26,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
||||
return func(c *fiber.Ctx) error {
|
||||
profile, err := config.AuthHandler.GetEnrichedProfile(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized (trace:rbac_keto)"})
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증에 실패했습니다. (rbac_keto)"})
|
||||
}
|
||||
|
||||
// Store profile in locals for further use in handlers
|
||||
@@ -43,7 +44,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
||||
}
|
||||
|
||||
if objectID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"})
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "권한 검증을 위한 대상 ID가 누락되었습니다."})
|
||||
}
|
||||
|
||||
// Set tenant_id for audit logging if namespace is Tenant
|
||||
@@ -55,7 +56,9 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
||||
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
|
||||
if err != nil || !allowed {
|
||||
slog.Warn("Keto permission denied", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation)
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied"})
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": fmt.Sprintf("접근 권한이 없습니다. 현재 '%s' 권한으로는 요청하신 리소스에 대한 상세 권한(Keto)이 부족합니다. 관리자에게 문의하세요.", profile.Role),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
@@ -73,7 +76,7 @@ func RequireRole(config RBACConfig) fiber.Handler {
|
||||
profile, err := config.AuthHandler.GetEnrichedProfile(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "unauthorized (trace:rbac_role): " + err.Error(),
|
||||
"error": "인증 정보 조회에 실패했습니다: " + err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -102,7 +105,7 @@ func RequireRole(config RBACConfig) fiber.Handler {
|
||||
"path", c.Path(),
|
||||
)
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "forbidden: insufficient permissions",
|
||||
"error": fmt.Sprintf("접근 권한이 없습니다. 현재 '%s' 권한으로는 이 기능을 사용할 수 없습니다. 관리자에게 문의하여 'rp_admin' 이상의 권한을 확보하세요.", profile.Role),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,7 +126,7 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler {
|
||||
|
||||
profile, err := config.AuthHandler.GetEnrichedProfile(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized (trace:rbac_match)"})
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증에 실패했습니다. (rbac_match)"})
|
||||
}
|
||||
|
||||
// Store profile in locals for further use in handlers
|
||||
@@ -143,12 +146,12 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler {
|
||||
|
||||
if profile.TenantID == nil || *profile.TenantID != targetTenantID {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "forbidden: you do not have access to this tenant",
|
||||
"error": fmt.Sprintf("해당 테넌트에 대한 접근 권한이 없습니다. 사용자님의 '%s' 권한은 소속된 테넌트의 리소스만 관리할 수 있습니다.", profile.Role),
|
||||
})
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"})
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "요청하신 리소스에 접근할 수 없습니다."})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func (s *HydraAdminService) ListClients(ctx context.Context, limit, offset int)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
resp, err := s.HttpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func (s *HydraAdminService) GetClient(ctx context.Context, clientID string) (*do
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
resp, err := s.HttpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, sta
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json-patch+json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
resp, err := s.HttpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func (s *HydraAdminService) CreateClient(ctx context.Context, client domain.Hydr
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
resp, err := s.HttpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -174,7 +174,7 @@ func (s *HydraAdminService) UpdateClient(ctx context.Context, clientID string, c
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
resp, err := s.HttpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -202,7 +202,7 @@ func (s *HydraAdminService) DeleteClient(ctx context.Context, clientID string) e
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
resp, err := s.HttpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -235,7 +235,7 @@ func (s *HydraAdminService) ListConsentSessions(ctx context.Context, subject, cl
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
resp, err := s.HttpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -276,7 +276,7 @@ func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject,
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
resp, err := s.HttpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -289,7 +289,7 @@ func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *HydraAdminService) httpClient() *http.Client {
|
||||
func (s *HydraAdminService) HttpClient() *http.Client {
|
||||
if s.HTTPClient != nil {
|
||||
return s.HTTPClient
|
||||
}
|
||||
@@ -367,7 +367,7 @@ func (s *HydraAdminService) GetConsentRequest(ctx context.Context, challenge str
|
||||
return nil, fmt.Errorf("hydra admin: create request for get consent failed: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
resp, err := s.HttpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hydra admin: get consent request failed: %w", err)
|
||||
}
|
||||
@@ -407,7 +407,7 @@ func (s *HydraAdminService) RejectConsentRequest(ctx context.Context, challenge
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
resp, err := s.HttpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hydra admin: reject consent request failed: %w", err)
|
||||
}
|
||||
@@ -449,7 +449,7 @@ func (s *HydraAdminService) RejectLoginRequest(ctx context.Context, challenge, e
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
resp, err := s.HttpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hydra admin: reject login request failed: %w", err)
|
||||
}
|
||||
@@ -484,7 +484,7 @@ func (s *HydraAdminService) GetLoginRequest(ctx context.Context, challenge strin
|
||||
return nil, fmt.Errorf("hydra admin: create request for get login failed: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
resp, err := s.HttpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hydra admin: get login request failed: %w", err)
|
||||
}
|
||||
@@ -532,7 +532,7 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
resp, err := s.HttpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hydra admin: accept consent request failed: %w", err)
|
||||
}
|
||||
@@ -576,7 +576,7 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
resp, err := s.HttpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hydra admin: accept login request failed: %w", err)
|
||||
}
|
||||
@@ -597,3 +597,34 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st
|
||||
|
||||
return &AcceptLoginRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil
|
||||
}
|
||||
|
||||
func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (map[string]interface{}, error) {
|
||||
endpoint := fmt.Sprintf("%s/admin/oauth2/introspect", strings.TrimRight(s.AdminURL, "/"))
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("token", token)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := s.HttpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("hydra admin: introspect failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
36
devfront/package-lock.json
generated
36
devfront/package-lock.json
generated
@@ -18,9 +18,11 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"oidc-client-ts": "^3.4.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-oidc-context": "^3.3.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^3.24.1"
|
||||
@@ -2332,6 +2334,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jwt-decode": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||
@@ -2774,6 +2785,18 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/oidc-client-ts": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz",
|
||||
"integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jwt-decode": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
@@ -3095,6 +3118,19 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-oidc-context": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.0.tgz",
|
||||
"integrity": "sha512-302T/ma4AOVAxrHdYctDSKXjCq9KNHT564XEO2yOPxRfxEP58xa4nz+GQinNl8x7CnEXECSM5JEjQJk3Cr5BvA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"oidc-client-ts": "^3.1.0",
|
||||
"react": ">=16.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
|
||||
@@ -22,9 +22,11 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"oidc-client-ts": "^3.4.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-oidc-context": "^3.3.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^3.24.1"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Navigate, createBrowserRouter } from "react-router-dom";
|
||||
import AppLayout from "../components/layout/AppLayout";
|
||||
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||
import AuthGuard from "../features/auth/AuthGuard";
|
||||
import LoginPage from "../features/auth/LoginPage";
|
||||
import ClientConsentsPage from "../features/clients/ClientConsentsPage";
|
||||
import ClientDetailsPage from "../features/clients/ClientDetailsPage";
|
||||
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
||||
@@ -7,16 +10,29 @@ import ClientsPage from "../features/clients/ClientsPage";
|
||||
|
||||
export const router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
path: "/login",
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: "/callback",
|
||||
element: <AuthCallbackPage />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <AppLayout />,
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/clients" replace /> },
|
||||
{ path: "clients", element: <ClientsPage /> },
|
||||
{ path: "clients/new", element: <ClientGeneralPage /> },
|
||||
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
||||
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
|
||||
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
|
||||
{
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/clients" replace /> },
|
||||
{ path: "clients", element: <ClientsPage /> },
|
||||
{ path: "clients/new", element: <ClientGeneralPage /> },
|
||||
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
||||
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
|
||||
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadgeCheck, Moon, ShieldHalf, Sun } from "lucide-react";
|
||||
import { BadgeCheck, LogOut, Moon, ShieldHalf, Sun } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { Toaster } from "../ui/toaster";
|
||||
@@ -14,10 +15,22 @@ const navItems = [
|
||||
];
|
||||
|
||||
function AppLayout() {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
const stored = window.localStorage.getItem("admin_theme");
|
||||
return stored === "dark" ? "dark" : "light";
|
||||
});
|
||||
const auth = useAuth();
|
||||
|
||||
// OIDC ID Token에서 프로필 정보 추출
|
||||
// auth.user?.profile에는 OIDC 표준 클레임(sub, email, name 등)이 포함됨
|
||||
const profile = auth.user?.profile ? {
|
||||
id: auth.user.profile.sub,
|
||||
email: auth.user.profile.email,
|
||||
name: (auth.user.profile.name as string) || (auth.user.profile.preferred_username as string) || auth.user.profile.email,
|
||||
tenantId: auth.user.profile.tenant_id as string,
|
||||
tenant: auth.user.profile.tenant as any,
|
||||
} : null;
|
||||
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
const stored = window.localStorage.getItem("admin_theme");
|
||||
return stored === "dark" ? "dark" : "light";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
@@ -30,9 +43,20 @@ function AppLayout() {
|
||||
window.localStorage.setItem("admin_theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
};
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
auth.signoutRedirect();
|
||||
};
|
||||
|
||||
// Set initial tenant ID if profile is loaded and no tenant is selected
|
||||
useEffect(() => {
|
||||
if (profile?.tenantId && !window.localStorage.getItem("dev_tenant_id")) {
|
||||
window.localStorage.setItem("dev_tenant_id", profile.tenantId);
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
||||
@@ -61,6 +85,11 @@ function AppLayout() {
|
||||
<span className="rounded-full border border-border px-3 py-1">
|
||||
{t("ui.dev.env_badge", "Env: dev")}
|
||||
</span>
|
||||
{profile?.tenant && (
|
||||
<span className="rounded-full bg-primary/10 px-3 py-1 text-primary border border-primary/20">
|
||||
Tenant: {profile.tenant.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
||||
@@ -116,6 +145,16 @@ function AppLayout() {
|
||||
? t("ui.common.theme_light", "Light")
|
||||
: t("ui.common.theme_dark", "Dark")}
|
||||
</button>
|
||||
{auth.isAuthenticated && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive transition hover:bg-destructive/10"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
Logout
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
|
||||
import { CheckCircle2, AlertCircle, Info } from "lucide-react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useToastState } from "./use-toast";
|
||||
|
||||
|
||||
19
devfront/src/features/auth/AuthCallbackPage.tsx
Normal file
19
devfront/src/features/auth/AuthCallbackPage.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
navigate("/", { replace: true });
|
||||
} else if (auth.error) {
|
||||
console.error("Auth Error:", auth.error);
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
}, [auth.isAuthenticated, auth.error, navigate]);
|
||||
|
||||
return <div>Loading Auth...</div>;
|
||||
}
|
||||
20
devfront/src/features/auth/AuthGuard.tsx
Normal file
20
devfront/src/features/auth/AuthGuard.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
|
||||
export default function AuthGuard() {
|
||||
const auth = useAuth();
|
||||
|
||||
if (auth.isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (auth.error) {
|
||||
return <div>Auth Error: {auth.error.message}</div>;
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
87
devfront/src/features/auth/LoginPage.tsx
Normal file
87
devfront/src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { ShieldHalf, LogIn, ExternalLink } from "lucide-react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
|
||||
function LoginPage() {
|
||||
const auth = useAuth();
|
||||
|
||||
const handleSSOLogin = () => {
|
||||
// OIDC client-side authentication flow started here
|
||||
auth.signinRedirect();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-[0_20px_50px_rgba(54,211,153,0.3)]">
|
||||
<ShieldHalf size={32} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Baron SSO</h1>
|
||||
<p className="text-sm text-muted-foreground uppercase tracking-[0.2em]">
|
||||
Developer Control Plane
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl flex items-center gap-2">
|
||||
<LogIn size={20} className="text-primary" />
|
||||
개발자 포털 로그인
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Baron 통합 인증(SSO)을 통해 개발자 포털에 접속합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 pb-8 space-y-3">
|
||||
<Button
|
||||
onClick={handleSSOLogin}
|
||||
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
|
||||
disabled={auth.isLoading}
|
||||
>
|
||||
{auth.isLoading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
로그인 진행 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldHalf size={22} />
|
||||
SSO 계정으로 로그인
|
||||
<ExternalLink size={16} className="opacity-50" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
|
||||
개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.<br />
|
||||
민감한 작업 시 재인증을 요구할 수 있습니다.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
|
||||
</div>
|
||||
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
인증 정보가 없거나 로그인이 되지 않는 경우<br />
|
||||
시스템 관리자에게 문의하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
22
devfront/src/features/auth/authApi.ts
Normal file
22
devfront/src/features/auth/authApi.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import apiClient from "../../lib/apiClient";
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
companyCode?: string;
|
||||
tenantId?: string;
|
||||
tenant?: Tenant;
|
||||
}
|
||||
|
||||
export async function fetchMe() {
|
||||
const { data } = await apiClient.get<UserProfile>("/user/me");
|
||||
return data;
|
||||
}
|
||||
@@ -211,14 +211,10 @@ function ClientsPage() {
|
||||
variant={
|
||||
item.tone === "up"
|
||||
? "success"
|
||||
: item.tone === "down"
|
||||
? "warning"
|
||||
: "muted"
|
||||
: "muted"
|
||||
}
|
||||
className={cn(
|
||||
"px-2",
|
||||
item.tone === "down" &&
|
||||
"bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-200",
|
||||
item.tone === "stable" && "bg-muted/40 text-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { userManager } from "./auth";
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL:
|
||||
@@ -7,15 +8,15 @@ const apiClient = axios.create({
|
||||
"/api/v1",
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
// TODO: IdP 중립 Auth 레이어 연동 시 세션 토큰을 주입한다.
|
||||
const sessionToken = window.localStorage.getItem("admin_session");
|
||||
if (sessionToken) {
|
||||
config.headers.Authorization = `Bearer ${sessionToken}`;
|
||||
apiClient.interceptors.request.use(async (config) => {
|
||||
// OIDC Access Token 주입
|
||||
const user = await userManager.getUser();
|
||||
if (user?.access_token) {
|
||||
config.headers.Authorization = `Bearer ${user.access_token}`;
|
||||
}
|
||||
|
||||
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
|
||||
const tenantId = window.localStorage.getItem("admin_tenant");
|
||||
const tenantId = window.localStorage.getItem("dev_tenant_id"); // 키 이름을 좀 더 명확하게 변경 고려
|
||||
if (tenantId) {
|
||||
config.headers["X-Tenant-ID"] = tenantId;
|
||||
}
|
||||
@@ -26,7 +27,13 @@ apiClient.interceptors.request.use((config) => {
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// TODO: 401/403 응답 시 로그인/재인증 플로우로 리다이렉션한다.
|
||||
if (error.response?.status === 401) {
|
||||
// 401 발생 시 로그인 페이지로 리다이렉트
|
||||
const isAuthPath = window.location.pathname.startsWith("/callback");
|
||||
if (!isAuthPath) {
|
||||
userManager.signinRedirect();
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
20
devfront/src/lib/auth.ts
Normal file
20
devfront/src/lib/auth.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||
import type { AuthProviderProps } from "react-oidc-context";
|
||||
|
||||
export const oidcConfig: AuthProviderProps = {
|
||||
authority: import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
|
||||
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront-client",
|
||||
redirect_uri: `${window.location.origin}/callback`,
|
||||
response_type: "code",
|
||||
scope: "openid offline_access profile email", // offline_access for refresh token
|
||||
post_logout_redirect_uri: window.location.origin,
|
||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||
automaticSilentRenew: true,
|
||||
};
|
||||
|
||||
export const userManager = new UserManager({
|
||||
...oidcConfig,
|
||||
authority: oidcConfig.authority || "",
|
||||
client_id: oidcConfig.client_id || "",
|
||||
redirect_uri: oidcConfig.redirect_uri || "",
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export type ClientSummary = {
|
||||
type: ClientType;
|
||||
status: ClientStatus;
|
||||
createdAt?: string;
|
||||
clientSecret?: string;
|
||||
redirectUris: string[];
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { AuthProvider } from "react-oidc-context";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { queryClient } from "./app/queryClient";
|
||||
import { router } from "./app/routes";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import "./index.css";
|
||||
import { oidcConfig } from "./lib/auth";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
@@ -15,9 +17,11 @@ if (!rootElement) {
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</QueryClientProvider>
|
||||
<AuthProvider {...oidcConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -13,4 +13,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
esbuild: {
|
||||
// drop: process.env.APP_ENV === "production" ? ["console", "debugger"] : [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,12 @@ serve:
|
||||
admin:
|
||||
base_url: http://localhost:4434/
|
||||
|
||||
session:
|
||||
cookie:
|
||||
domain: hmac.kr
|
||||
same_site: Lax
|
||||
path: /
|
||||
|
||||
selfservice:
|
||||
default_browser_return_url: http://localhost:5000/
|
||||
allowed_return_urls:
|
||||
|
||||
91
docs/devfront_auth_flow_explanation.md
Normal file
91
docs/devfront_auth_flow_explanation.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# DevFront OIDC 인증 흐름 및 무한 루프 해결 보고
|
||||
|
||||
이 문서는 `devfront` 개발자 포털의 OIDC 인증 구현 방식, 무한 루프 문제의 원인 및 해결 방법, 그리고 클라이언트 자동 등록 원리에 대해 설명합니다.
|
||||
|
||||
## 1. DevFront 로그인 동작 플로우 (OIDC Authorization Code Flow)
|
||||
|
||||
### 시퀀스 다이어그램
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant DF as DevFront (RP)
|
||||
participant HY as Ory Hydra (OP)
|
||||
participant UF as UserFront (Login UI)
|
||||
participant KR as Ory Kratos (Identity)
|
||||
|
||||
User->>DF: 로그인 버튼 클릭
|
||||
DF->>HY: 인증 요청 (/oauth2/auth)
|
||||
HY->>UF: 로그인 UI 리다이렉트
|
||||
UF->>User: 로그인 페이지 표시
|
||||
User->>UF: 자격 증명 입력 (Email/PW)
|
||||
UF->>KR: 인증 수행
|
||||
KR-->>UF: 인증 성공 (Session 생성)
|
||||
UF->>HY: 로그인 승인 요청
|
||||
HY->>User: 권한 동의(Consent) 화면 표시
|
||||
User->>HY: '허용' 클릭
|
||||
HY-->>DF: 인증 코드와 함께 리다이렉트 (/callback?code=...)
|
||||
DF->>HY: 토큰 교환 요청 (Code -> ID/Access Token)
|
||||
HY-->>DF: 토큰 발급
|
||||
Note over DF: [FIX] 백엔드 /api/me 호출 대신<br/>ID Token에서 프로필 정보 직접 추출
|
||||
DF->>User: 대시보드 및 프로필 표시
|
||||
```
|
||||
|
||||
### 단계별 설명
|
||||
|
||||
1. **인증 요청 (Login Request)**:
|
||||
* 사용자가 `devfront` (localhost:5174)의 로그인 버튼을 클릭합니다.
|
||||
* `react-oidc-context` 라이브러리가 Hydra의 `/oauth2/auth` 엔드포인트로 사용자를 리다이렉트합니다.
|
||||
* 이때 클라이언트 ID(`devfront`), 리다이렉트 URI, Scope 등을 파라미터로 전달합니다.
|
||||
|
||||
2. **사용자 인증 (Authentication)**:
|
||||
* Hydra는 현재 세션이 없음을 확인하고, 설정된 로그인 UI(`userfront`)로 사용자를 보냅니다.
|
||||
* 사용자는 `userfront`에서 아이디/비밀번호를 입력하여 Kratos를 통해 인증을 마칩니다.
|
||||
|
||||
3. **권한 동의 (Consent)**:
|
||||
* 인증이 완료되면 Hydra는 사용자에게 `devfront` 앱이 요청한 권한(openid, profile, email 등)을 허용할지 묻는 Consent 화면을 띄웁니다.
|
||||
* 사용자가 '허용'을 누르면 Hydra는 `devfront`가 신뢰할 수 있는 앱임을 기록합니다.
|
||||
|
||||
4. **인증 코드 전달 및 토큰 교환 (Callback)**:
|
||||
* Hydra는 사용자를 `devfront`의 콜백 페이지(`http://localhost:5174/callback?code=...`)로 보냅니다.
|
||||
* `devfront`는 이 코드를 Hydra의 토큰 엔드포인트로 보내 **ID Token**과 **Access Token**을 발급받습니다.
|
||||
|
||||
5. **사용자 정보 로드 (Profile Recovery)**:
|
||||
* `devfront`는 발급받은 **ID Token**의 Payload를 디코딩하여 사용자 이름, 이메일 등의 프로필 정보를 즉시 화면에 렌더링합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 클라이언트 자동 등록의 원리
|
||||
|
||||
사용자가 직접 `devfront`를 클라이언트로 등록하지 않았음에도 로그인이 가능한 이유는 인프라 설정 파일인 `compose.ory.yaml`에 정의된 **`init-rp` 컨테이너** 덕분입니다.
|
||||
|
||||
### `init-rp` 서비스의 역할
|
||||
* Ory 스택(Kratos, Hydra, Keto)이 모두 정상 가동된 직후 실행됩니다.
|
||||
* Hydra Admin API를 호출하여 서비스 운영에 필수적인 기본 클라이언트들을 자동으로 생성합니다.
|
||||
|
||||
### 자동 등록된 `devfront` 명세
|
||||
```bash
|
||||
hydra clients create
|
||||
--endpoint http://hydra:4445
|
||||
--id devfront
|
||||
--grant-types authorization_code,refresh_token
|
||||
--response-types code
|
||||
--scope openid,offline_access,profile,email
|
||||
--token-endpoint-auth-method none \ # Public Client (PKCE 사용)
|
||||
--callbacks http://localhost:5174/callback;
|
||||
```
|
||||
이 설정으로 인해 `devfront`라는 ID의 클라이언트가 미리 존재하게 되며, `localhost:5174`로의 리다이렉션이 안전하게 허용됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 무한 루프 문제 해결 분석
|
||||
|
||||
### 발생 원인 (Problem)
|
||||
* `devfront`가 로그인 성공 후 사용자 정보를 가져오기 위해 백엔드 API인 `/api/v1/user/me`를 호출했습니다.
|
||||
* 이 API는 백엔드 세션 쿠키를 기반으로 동작하도록 설계되어 있었습니다.
|
||||
* 하지만 브라우저 보안 정책(SameSite/Cross-Domain)으로 인해 `localhost`에서 보낸 요청에는 `sso-test.hmac.kr` 도메인의 쿠키가 포함되지 않았습니다.
|
||||
* **결과**: 백엔드는 401 Unauthorized를 반환 -> `devfront`는 401을 받으면 다시 로그인을 시도하도록 구현되어 있어 무한 루프가 발생했습니다.
|
||||
|
||||
### 해결 방법 (Solution)
|
||||
* **쿠키 의존성 제거**: `AppLayout.tsx`에서 백엔드 호출(`fetchMe`)을 삭제했습니다.
|
||||
* **ID Token 직접 활용**: 이미 OIDC 인증 성공 시점에 전달받은 **ID Token(`auth.user.profile`)**에 필요한 모든 사용자 정보가 들어있으므로, 이를 직접 사용하도록 수정했습니다.
|
||||
* **표준 RP 구조 확립**: 이제 `devfront`는 백엔드의 세션 쿠키를 전혀 사용하지 않으며, API 요청 시에도 `Authorization: Bearer <token>` 헤더를 사용하여 정당한 권한을 증명합니다.
|
||||
@@ -1193,7 +1193,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 31),
|
||||
color: statusColor,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
@@ -1203,9 +1203,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
'ui.userfront.dashboard.status.revoked',
|
||||
fallback: '해지됨',
|
||||
),
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: statusColor,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user