1
0
forked from baron/baron-sso

refactor: backend tenant_group 제거 및 리팩터 반영

This commit is contained in:
Lectom C Han
2026-02-12 22:14:34 +09:00
parent b0792113ae
commit a8a219d7ef
26 changed files with 494 additions and 1001 deletions

View File

@@ -125,11 +125,10 @@ func GenerateSecureAlnumToken(length int) string {
func GenerateUserCode() string {
const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ"
// [Fixed] 요청하신 포맷 (영문 2자리 + 숫자 6자리, 하이픈 없음)으로 변경
return fmt.Sprintf("%c%c%06d",
return fmt.Sprintf("%c%c-%03d",
letters[rand.Intn(len(letters))],
letters[rand.Intn(len(letters))],
rand.Intn(1000000),
rand.Intn(1000),
)
}
@@ -455,7 +454,8 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType, "provider", h.IdpProvider.Name(), "subject", providerID)
// [New] Local DB Sync
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
// 로컬 DB 저장이 실패하더라도 회원가입 프로세스는 성공으로 간주합니다.
localUser := &domain.User{
ID: providerID, // Match IDP Subject
Email: req.Email,
@@ -471,9 +471,17 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
}
if h.UserRepo != nil {
if err := h.UserRepo.Create(c.Context(), localUser); err != nil {
slog.Error("[Signup] Failed to sync user to local DB", "email", req.Email, "error", err)
}
go func(u *domain.User) {
// 요청 Context가 취소될 수 있으므로 Background Context 사용
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.UserRepo.Create(ctx, u); err != nil {
slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err)
} else {
slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email)
}
}(localUser)
}
// [Keto] Sync user-tenant relationship
@@ -959,20 +967,13 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
}
// [Changed] 토큰 길이를 사용자의 요청에 맞춰 6글자(3바이트)로, pendingRef를 8글자(4바이트)로 조정
userCode := GenerateUserCode()
token := GenerateSecureToken(3)
pendingRef := GenerateSecureToken(3)
slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef)
// [Added] 사용자가 입력할 간편 코드를 Redis에 저장합니다. (이게 없으면 인증이 안 됩니다)
shortCodePayload, _ := json.Marshal(shortLoginCodePayload{
LoginID: lookupLoginID,
Code: token,
PendingRef: pendingRef,
})
h.RedisService.Set(prefixLoginCodeShort+userCode, string(shortCodePayload), defaultExpiration)
// Store in Redis
sessionData, _ := json.Marshal(map[string]string{
"status": statusPending,
@@ -1026,13 +1027,12 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
}
} else {
// Send SMS
phone := sanitizePhoneForSms(loginID)
content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s | 간편 코드: %s", link, userCode)
content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s | 코드: %s", link, userCode)
if drySend {
slog.Info("[Enchanted][DrySend] SMS send skipped", "loginID", phone, "content", content)
slog.Info("[Enchanted][DrySend] SMS send skipped", "loginID", loginID, "content", content)
} else {
slog.Info("[Enchanted] Sending SMS via Naver Cloud", "to", phone)
if err := h.SmsService.SendSms(phone, content); err != nil {
slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID)
if err := h.SmsService.SendSms(loginID, content); err != nil {
slog.Error("[Enchanted] SMS Failed", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
}
@@ -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,25 +1568,22 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
// --- OIDC 로그인 흐름 처리 ---
if req.LoginChallenge != "" {
slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge, "subject", subject)
slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge)
// Check if the client is active
loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge)
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.")
}
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.")
}
}
}
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject)
if err != nil {
slog.Error("failed to accept hydra login request", "error", err, "challenge", req.LoginChallenge)
slog.Error("failed to accept hydra login request", "error", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
}
slog.Info("Hydra login request accepted", "redirectTo", acceptResp.RedirectTo)
@@ -1597,13 +1594,12 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
// --- OIDC 로그인 흐름 처리 끝 ---
resp := fiber.Map{
"sessionToken": authInfo.SessionToken.JWT,
"sessionJwt": authInfo.SessionToken.JWT, // Frontend compatibility
"status": "ok",
"provider": h.IdpProvider.Name(),
"sessionJwt": authInfo.SessionToken.JWT,
"status": "ok",
"provider": h.IdpProvider.Name(),
}
if authInfo.RefreshToken != nil {
resp["refreshToken"] = authInfo.RefreshToken.JWT
resp["refreshJwt"] = authInfo.RefreshToken.JWT
}
if authInfo.Subject != "" {
resp["subject"] = authInfo.Subject
@@ -2079,16 +2075,6 @@ type kratosCourierRequest struct {
Body string `json:"body"`
}
// sanitizePhoneForSms - 네이버 SMS 등 국내 발송기를 위해 +82 형식을 010 형식으로 변환합니다.
func sanitizePhoneForSms(phone string) string {
p := strings.ReplaceAll(phone, "-", "")
p = strings.ReplaceAll(p, " ", "")
if strings.HasPrefix(p, "+82") {
return "0" + p[3:]
}
return p
}
// HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다.
func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
var req kratosCourierRequest
@@ -2467,6 +2453,16 @@ func extractFirstString(data map[string]interface{}, keys ...string) string {
return ""
}
func sanitizePhoneForSms(phone string) string {
sanitized := strings.TrimSpace(phone)
if strings.HasPrefix(sanitized, "+82") {
sanitized = "0" + sanitized[3:]
}
sanitized = strings.ReplaceAll(sanitized, "-", "")
sanitized = strings.ReplaceAll(sanitized, " ", "")
return sanitized
}
// --- User Profile Handlers ---
func (h *AuthHandler) formatPhoneForDisplay(phone string) string {
@@ -2484,56 +2480,7 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string {
return phone
}
// 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
}
// GetMe - Returns current user's profile with enriched data from local DB
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
profile, err := h.resolveCurrentProfile(c)
if err != nil {
@@ -4006,13 +3953,6 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
}
}
// Fetch Manageable Tenants for Admins
if profile.Role == domain.RoleSuperAdmin || profile.Role == domain.RoleTenantAdmin || profile.Role == domain.RoleRPAdmin {
if tenants, err := h.TenantService.ListManageableTenants(c.Context(), profile.ID); err == nil {
profile.ManageableTenants = tenants
}
}
// 4. Save to Redis Cache (Short TTL)
if h.RedisService != nil && cacheKey != "" {
if data, err := json.Marshal(profile); err == nil {
@@ -4842,7 +4782,10 @@ func extractLoginIDFromClaims(claims map[string]any) string {
}
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) {
kratosURL := strings.TrimRight(utils.GetEnv("KRATOS_PUBLIC_URL", "http://kratos:4433"), "/")
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" {
kratosURL = "http://kratos:4433"
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
if err != nil {
return "", nil, err
@@ -4850,44 +4793,33 @@ 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 {
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
}
}
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))
}
// 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
}
}
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
}
return "", nil, fmt.Errorf("invalid session or token")
return result.Identity.ID, result.Identity.Traits, nil
}
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
kratosURL := strings.TrimRight(utils.GetEnv("KRATOS_PUBLIC_URL", "http://kratos:4433"), "/")
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" {
kratosURL = "http://kratos:4433"
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
if err != nil {
return "", err
@@ -4910,7 +4842,6 @@ func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.ID, nil
}
@@ -4919,7 +4850,10 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string)
return "", fmt.Errorf("kratos identity id is empty")
}
kratosAdminURL := strings.TrimRight(utils.GetEnv("KRATOS_ADMIN_URL", "http://kratos:4434"), "/")
kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
if kratosAdminURL == "" {
kratosAdminURL = "http://kratos:4434"
}
payload := map[string]interface{}{
"identity_id": identityID,