diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ecf26a7b..92483f17 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -476,6 +476,7 @@ func main() { auth.Post("/password/login", authHandler.PasswordLogin) auth.Get("/consent", authHandler.GetConsentRequest) auth.Post("/consent/accept", authHandler.AcceptConsentRequest) + auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest) auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset) // [Changed] Use Interstitial Page for GET to prevent Scanner consumption diff --git a/backend/internal/domain/oathkeeper_models.go b/backend/internal/domain/oathkeeper_models.go new file mode 100644 index 00000000..a24a3f97 --- /dev/null +++ b/backend/internal/domain/oathkeeper_models.go @@ -0,0 +1,30 @@ +package domain + +import ( + "context" + "time" +) + +type OathkeeperAccessLog struct { + Timestamp time.Time + RequestID string + Method string + Path string + Status int + LatencyMs int + RP string + Action string + Target string + Subject string + ClientIP string + UserAgent string + Decision string + TraceID string + SpanID string + Raw string +} + +type OathkeeperLogRepository interface { + FindPageBySubject(ctx context.Context, subject string, limit int, cursor *AuditCursor) ([]OathkeeperAccessLog, error) + Ping(ctx context.Context) error +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 75041fdc..78e21a21 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -521,6 +521,228 @@ func normalizePhoneForLoginID(phone string) string { return normalized } +func buildOidcClaimsFromTraits(traits map[string]any, scopes []string) map[string]any { + claims := map[string]any{} + if traits == nil { + return claims + } + + scopeSet := map[string]struct{}{} + for _, scope := range scopes { + scope = strings.TrimSpace(scope) + if scope == "" { + continue + } + scopeSet[scope] = struct{}{} + } + + getString := func(key string) string { + raw, ok := traits[key] + if !ok || raw == nil { + return "" + } + switch value := raw.(type) { + case string: + return strings.TrimSpace(value) + default: + return strings.TrimSpace(fmt.Sprint(value)) + } + } + + displayName := getString("displayname") + if displayName == "" { + displayName = getString("name") + } + if displayName != "" { + claims["name"] = displayName + } + primaryEmail := getString("primary_email") + if primaryEmail == "" { + primaryEmail = getString("email") + } + if primaryEmail != "" { + claims["email"] = primaryEmail + } + + if _, ok := scopeSet["profile"]; ok { + profile := map[string]any{} + names := map[string]any{} + for _, key := range []string{ + "name", + "displayname", + "preferred_username", + "given_name", + "family_name", + "middle_name", + "nickname", + } { + if value := getString(key); value != "" { + names[key] = value + } + } + if len(names) > 0 { + profile["names"] = names + } + + emails := collectEmailList(traits, primaryEmail) + if len(emails) > 0 { + profile["emails"] = emails + } + if len(profile) > 0 { + claims["profile"] = profile + } + + for _, key := range []string{ + "department", + "affiliationType", + "companyCode", + "displayname", + "team", + "grade", + "familyCompany", + "taxCode", + "familyUniqueKey", + "personal", + } { + if raw, ok := traits[key]; ok && raw != nil { + switch value := raw.(type) { + case string: + if strings.TrimSpace(value) != "" { + claims[key] = strings.TrimSpace(value) + } + default: + claims[key] = value + } + } + } + } + + if _, ok := scopeSet["phone"]; ok { + if phone := getString("phone_number"); phone != "" { + claims["phone_number"] = phone + } + } + + return claims +} + +func collectEmailList(traits map[string]any, primaryEmail string) []string { + emails := make([]string, 0) + seen := make(map[string]struct{}) + add := func(value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + if _, ok := seen[value]; ok { + return + } + seen[value] = struct{}{} + emails = append(emails, value) + } + + add(primaryEmail) + for _, key := range []string{"email", "primary_email"} { + if raw, ok := traits[key]; ok { + if value, ok := raw.(string); ok { + add(value) + } + } + } + + if raw, ok := traits["emails"]; ok { + switch value := raw.(type) { + case []string: + for _, email := range value { + add(email) + } + case []any: + for _, email := range value { + add(fmt.Sprint(email)) + } + } + } + + if raw, ok := traits["secondary_emails"]; ok { + switch value := raw.(type) { + case []string: + for _, email := range value { + add(email) + } + case []any: + for _, email := range value { + add(fmt.Sprint(email)) + } + } + } + + if raw, ok := traits["additional_emails"]; ok { + switch value := raw.(type) { + case []string: + for _, email := range value { + add(email) + } + case []any: + for _, email := range value { + add(fmt.Sprint(email)) + } + } + } + + return emails +} + +func buildIdentityLookupCandidates(loginID string) []string { + seen := make(map[string]struct{}) + add := func(value string) { + candidate := strings.TrimSpace(value) + if candidate == "" { + return + } + if _, ok := seen[candidate]; ok { + return + } + seen[candidate] = struct{}{} + } + + normalized := strings.TrimSpace(loginID) + add(normalized) + if normalized != "" { + add(strings.ToLower(normalized)) + } + if normalized != "" && !strings.Contains(normalized, "@") { + add(normalizePhoneForLoginID(normalized)) + } + + candidates := make([]string, 0, len(seen)) + for candidate := range seen { + candidates = append(candidates, candidate) + } + return candidates +} + +func (h *AuthHandler) resolveKratosIdentityID(ctx context.Context, identifiers ...string) (string, error) { + if h.KratosAdmin == nil { + return "", fmt.Errorf("kratos admin unavailable") + } + for _, identifier := range identifiers { + candidate := strings.TrimSpace(identifier) + if candidate == "" { + continue + } + identityID, err := h.KratosAdmin.FindIdentityIDByIdentifier(ctx, candidate) + if err == nil && identityID != "" { + return identityID, nil + } + } + return "", fmt.Errorf("kratos identity not found") +} + +func (h *AuthHandler) resolveKratosIdentityIDFromLoginID(ctx context.Context, loginID string) (string, error) { + candidates := buildIdentityLookupCandidates(loginID) + return h.resolveKratosIdentityID(ctx, candidates...) +} + func (h *AuthHandler) getSignupState(key string) (*signupState, error) { val, err := h.RedisService.Get(key) if err != nil || val == "" { @@ -1117,6 +1339,12 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) } + subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), lookupLoginID) + if resolveErr != nil || subject == "" { + slog.Error("[LoginCode] Failed to resolve kratos identity", "loginID", lookupLoginID, "error", resolveErr) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"}) + } + authInfo.Subject = subject c.Locals("login_id", lookupLoginID) setSessionIDLocal(c, authInfo.SessionToken) @@ -1140,7 +1368,7 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { "status": "approved", "pendingRef": pendingRef, "provider": h.IdpProvider.Name(), - "subject": authInfo.Subject, + "subject": subject, "message": "Login approved", }) } @@ -1149,7 +1377,7 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { "token": authInfo.SessionToken.JWT, "sessionJwt": authInfo.SessionToken.JWT, "provider": h.IdpProvider.Name(), - "subject": authInfo.Subject, + "subject": subject, "message": "Login successful", }) } @@ -1226,6 +1454,12 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error { if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) } + subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), payload.LoginID) + if resolveErr != nil || subject == "" { + slog.Error("[LoginShortCode] Failed to resolve kratos identity", "loginID", payload.LoginID, "error", resolveErr) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"}) + } + authInfo.Subject = subject c.Locals("login_id", payload.LoginID) setSessionIDLocal(c, authInfo.SessionToken) @@ -1247,7 +1481,7 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error { "token": authInfo.SessionToken.JWT, "sessionJwt": authInfo.SessionToken.JWT, "provider": h.IdpProvider.Name(), - "subject": authInfo.Subject, + "subject": subject, "message": "Login approved", }) } @@ -1256,7 +1490,7 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error { "token": authInfo.SessionToken.JWT, "sessionJwt": authInfo.SessionToken.JWT, "provider": h.IdpProvider.Name(), - "subject": authInfo.Subject, + "subject": subject, "message": "Login successful", }) } @@ -1311,6 +1545,13 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"}) } + subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), loginID) + if resolveErr != nil || subject == "" { + slog.Error("Failed to resolve kratos identity after login", "loginID", loginID, "error", resolveErr) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve user identity") + } + authInfo.Subject = subject + ale.Status = fiber.StatusOK ale.LatencyMs = time.Since(startTime) ale.SessionJwt = authInfo.SessionToken.JWT @@ -1320,7 +1561,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { // --- OIDC 로그인 흐름 처리 --- if req.LoginChallenge != "" { slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge) - acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, authInfo.Subject) + acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject) if err != nil { slog.Error("failed to accept hydra login request", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request") @@ -2326,12 +2567,21 @@ func (h *AuthHandler) GetMe(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load user profile"}) } + identityID, resolveErr := h.resolveKratosIdentityID( + c.Context(), + userResponse.Email, + normalizePhoneForLoginID(userResponse.Phone), + ) + if resolveErr != nil || identityID == "" { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"}) + } + dept, _ := userResponse.CustomAttributes["department"].(string) affType, _ := userResponse.CustomAttributes["affiliationType"].(string) compCode, _ := userResponse.CustomAttributes["companyCode"].(string) resp := domain.UserProfileResponse{ - ID: userResponse.UserID, + ID: identityID, Email: userResponse.Email, Name: userResponse.Name, Phone: h.formatPhoneForDisplay(userResponse.Phone), @@ -3033,24 +3283,43 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "hydra admin unavailable"}) } - subject, err := h.resolveConsentSubject(c) - if err != nil || subject == "" { + subjects, err := h.resolveConsentSubjects(c) + if err != nil || len(subjects) == 0 { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) } - sessions, err := h.Hydra.ListConsentSessions(c.Context(), subject, "") - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + var sessions []service.HydraConsentSession + var lastErr error + hasSuccess := false + for _, subject := range subjects { + subject = strings.TrimSpace(subject) + if subject == "" { + continue + } + linked, listErr := h.Hydra.ListConsentSessions(c.Context(), subject, "") + if listErr != nil { + lastErr = listErr + continue + } + hasSuccess = true + sessions = append(sessions, linked...) + } + if !hasSuccess && lastErr != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": lastErr.Error()}) } records := make(map[string]*linkedRpRecord) for _, session := range sessions { - clientID := strings.TrimSpace(session.Client.ClientID) + client := session.Client + if client.ClientID == "" && session.ConsentRequest != nil { + client = session.ConsentRequest.Client + } + clientID := strings.TrimSpace(client.ClientID) if clientID == "" { continue } - name := strings.TrimSpace(session.Client.ClientName) + name := strings.TrimSpace(client.ClientName) if name == "" { name = clientID } @@ -3060,11 +3329,13 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { lastAuth = *session.AuthenticatedAt } else if session.RequestedAt != nil { lastAuth = *session.RequestedAt + } else if session.HandledAt != nil { + lastAuth = *session.HandledAt } scopes := session.GrantedScope - if len(scopes) == 0 && strings.TrimSpace(session.Client.Scope) != "" { - scopes = strings.Fields(session.Client.Scope) + if len(scopes) == 0 && strings.TrimSpace(client.Scope) != "" { + scopes = strings.Fields(client.Scope) } existing := records[clientID] @@ -3073,8 +3344,8 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { linkedRpSummary: linkedRpSummary{ ID: clientID, Name: name, - Logo: extractHydraClientLogo(session.Client.Metadata), - Status: hydraClientStatus(session.Client.Metadata), + Logo: extractHydraClientLogo(client.Metadata), + Status: hydraClientStatus(client.Metadata), Scopes: scopes, }, lastAuth: lastAuth, @@ -3086,7 +3357,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { existing.Name = name } if existing.Logo == "" { - existing.Logo = extractHydraClientLogo(session.Client.Metadata) + existing.Logo = extractHydraClientLogo(client.Metadata) } existing.Scopes = mergeScopes(existing.Scopes, scopes) if lastAuth.After(existing.lastAuth) { @@ -3145,7 +3416,19 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information") } - acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest) + if consentRequest.Subject == "" { + return fiber.NewError(fiber.StatusInternalServerError, "Consent subject missing") + } + if h.KratosAdmin == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Kratos admin unavailable") + } + identity, err := h.KratosAdmin.GetIdentity(c.Context(), consentRequest.Subject) + if err != nil || identity == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to load identity") + } + sessionClaims := buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope) + + acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest, sessionClaims) if err != nil { slog.Error("failed to accept hydra consent request", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept consent request") @@ -3154,6 +3437,30 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { return c.JSON(acceptResp) } +func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error { + var req struct { + LoginChallenge string `json:"login_challenge"` + } + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + if req.LoginChallenge == "" { + return fiber.NewError(fiber.StatusBadRequest, "login_challenge is required") + } + + subject, err := h.resolveConsentSubject(c) + if err != nil || subject == "" { + return fiber.NewError(fiber.StatusUnauthorized, "Authentication required") + } + + acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject) + if err != nil { + slog.Error("failed to accept hydra login request", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request") + } + + return c.JSON(acceptResp) +} func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { token := h.getBearerToken(c) @@ -3165,11 +3472,19 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe if err != nil { return nil, err } + identityID, resolveErr := h.resolveKratosIdentityID( + c.Context(), + userResponse.Email, + normalizePhoneForLoginID(userResponse.Phone), + ) + if resolveErr != nil || identityID == "" { + return nil, fmt.Errorf("failed to resolve kratos identity for profile") + } dept, _ := userResponse.CustomAttributes["department"].(string) affType, _ := userResponse.CustomAttributes["affiliationType"].(string) compCode, _ := userResponse.CustomAttributes["companyCode"].(string) return &domain.UserProfileResponse{ - ID: userResponse.UserID, + ID: identityID, Email: userResponse.Email, Name: userResponse.Name, Phone: h.formatPhoneForDisplay(userResponse.Phone), @@ -3197,27 +3512,34 @@ 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 { + if looksLikeJWT(token) && h.DescopeClient != 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 - } + identityID, resolveErr := h.resolveKratosIdentityID( + c.Context(), + userResponse.Email, + normalizePhoneForLoginID(userResponse.Phone), + ) + if resolveErr == nil { + return identityID, nil } } - return userToken.ID, nil + return "", fmt.Errorf("failed to resolve kratos identity for consent subject") } } - return h.resolveIdentityID(c, token) + identityID, resolveErr := h.resolveIdentityID(c, token) + if resolveErr == nil && identityID != "" { + return identityID, nil + } + if cookie := c.Get("Cookie"); cookie != "" { + cookieID, _, cookieErr := h.getKratosIdentityWithCookie(cookie) + if cookieErr == nil && cookieID != "" { + return cookieID, nil + } + } + return "", resolveErr } cookie := c.Get("Cookie") if cookie == "" { @@ -3227,6 +3549,114 @@ func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) { return identityID, err } +func (h *AuthHandler) resolveConsentSubjects(c *fiber.Ctx) ([]string, error) { + token := h.getBearerToken(c) + if token != "" && looksLikeJWT(token) && h.DescopeClient != nil { + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if err == nil && authorized { + subjects := make([]string, 0, 2) + userResponse, loadErr := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) + if loadErr == nil { + subjects = appendLoginIDsFromValues(subjects, userResponse.Email, userResponse.Phone) + identityID, resolveErr := h.resolveKratosIdentityID( + c.Context(), + userResponse.Email, + normalizePhoneForLoginID(userResponse.Phone), + ) + if resolveErr == nil && identityID != "" { + subjects = append([]string{identityID}, subjects...) + } + } + return uniqueStrings(subjects), nil + } + } + + if token != "" { + identityID, traits, err := h.getKratosIdentity(token) + if err == nil && identityID != "" { + subjects := []string{identityID} + subjects = appendLoginIDsFromTraits(subjects, traits) + return uniqueStrings(subjects), nil + } + } + + cookie := c.Get("Cookie") + if cookie == "" { + return nil, fmt.Errorf("missing authorization token") + } + identityID, traits, err := h.getKratosIdentityWithCookie(cookie) + if err != nil { + return nil, err + } + subjects := []string{identityID} + subjects = appendLoginIDsFromTraits(subjects, traits) + return uniqueStrings(subjects), nil +} + +func uniqueStrings(items []string) []string { + seen := make(map[string]struct{}, len(items)) + result := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + result = append(result, item) + } + return result +} + +func appendLoginIDsFromValues(subjects []string, email string, phone string) []string { + if strings.TrimSpace(email) != "" { + subjects = append(subjects, strings.TrimSpace(email)) + } + if strings.TrimSpace(phone) != "" { + subjects = append(subjects, normalizePhoneForLoginID(phone)) + } + return subjects +} + +func appendLoginIDsFromTraits(subjects []string, traits map[string]interface{}) []string { + if traits == nil { + return subjects + } + if raw, ok := traits["email"]; ok { + if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" { + subjects = append(subjects, strings.TrimSpace(value)) + } + } + if raw, ok := traits["phone"]; ok { + if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" { + subjects = append(subjects, normalizePhoneForLoginID(value)) + } + } + if raw, ok := traits["phone_number"]; ok { + if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" { + subjects = append(subjects, normalizePhoneForLoginID(value)) + } + } + if raw, ok := traits["phoneNumber"]; ok { + if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" { + subjects = append(subjects, normalizePhoneForLoginID(value)) + } + } + if raw, ok := traits["mobile"]; ok { + if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" { + subjects = append(subjects, normalizePhoneForLoginID(value)) + } + } + if raw, ok := traits["mobile_number"]; ok { + if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" { + subjects = append(subjects, normalizePhoneForLoginID(value)) + } + } + return subjects +} + func isAuthEventType(eventType string) bool { normalized := strings.ToLower(eventType) return strings.Contains(normalized, " /api/v1/auth/") @@ -3662,7 +4092,22 @@ func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, err if looksLikeJWT(token) && h.DescopeClient != nil { authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) if err == nil && authorized { - return userToken.ID, nil + if h.KratosAdmin == nil { + return "", fmt.Errorf("kratos admin unavailable") + } + userResponse, loadErr := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) + if loadErr != nil { + return "", loadErr + } + identityID, resolveErr := h.resolveKratosIdentityID( + c.Context(), + userResponse.Email, + normalizePhoneForLoginID(userResponse.Phone), + ) + if resolveErr != nil || identityID == "" { + return "", fmt.Errorf("failed to resolve kratos identity for token") + } + return identityID, nil } } id, _, err := h.getKratosIdentity(token) @@ -3688,7 +4133,7 @@ func (h *AuthHandler) tryIssueDescopeQrSession(c *fiber.Ctx, token string) (*dom if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { return nil, "", "", fmt.Errorf("descope issue session returned empty token") } - return authInfo.SessionToken, loginID, userToken.ID, nil + return authInfo.SessionToken, loginID, "", nil } func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) { @@ -4204,16 +4649,24 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load current user"}) } + identityID, resolveErr := h.resolveKratosIdentityID( + c.Context(), + currentUser.Email, + normalizePhoneForLoginID(currentUser.Phone), + ) + if resolveErr != nil || identityID == "" { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"}) + } newPhoneStorage := h.formatPhoneForStorage(req.Phone) oldPhoneStorage := currentUser.Phone - slog.Info("[UpdateMe] Checking changes", "userID", userToken.ID, "oldPhone", oldPhoneStorage, "newPhone", newPhoneStorage, "newName", req.Name) + slog.Info("[UpdateMe] Checking changes", "userID", identityID, "oldPhone", oldPhoneStorage, "newPhone", newPhoneStorage, "newName", req.Name) // 2. Handle Phone Number Change if newPhoneStorage != "" && newPhoneStorage != oldPhoneStorage { // Check verification status in Redis - verifyKey := "verify_update_phone:" + userToken.ID + ":" + newPhoneStorage + verifyKey := "verify_update_phone:" + identityID + ":" + newPhoneStorage val, _ := h.RedisService.Get(verifyKey) if val != "verified" { slog.Warn("[UpdateMe] Phone verification missing", "key", verifyKey) @@ -4221,7 +4674,7 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { } // Update Phone in Descope and mark as verified - slog.Info("[UpdateMe] Updating phone number", "userID", userToken.ID, "newPhone", newPhoneStorage) + slog.Info("[UpdateMe] Updating phone number", "userID", identityID, "newPhone", newPhoneStorage) _, err = h.DescopeClient.Management.User().UpdatePhone(c.Context(), userToken.ID, newPhoneStorage, true, false) if err != nil { slog.Error("Failed to update phone in Descope", "error", err) @@ -4250,7 +4703,7 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { // 3. Update Name if changed if req.Name != "" && req.Name != currentUser.Name { - slog.Info("[UpdateMe] Updating display name", "userID", userToken.ID, "newName", req.Name) + slog.Info("[UpdateMe] Updating display name", "userID", identityID, "newName", req.Name) _, err = h.DescopeClient.Management.User().UpdateDisplayName(c.Context(), userToken.ID, req.Name) if err != nil { slog.Error("Failed to update user name", "error", err) @@ -4260,13 +4713,13 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { // 4. Update Custom Attributes (Department) if req.Department != "" { - slog.Info("[UpdateMe] Updating department", "userID", userToken.ID, "dept", req.Department) + slog.Info("[UpdateMe] Updating department", "userID", identityID, "dept", req.Department) if _, err := h.DescopeClient.Management.User().UpdateCustomAttribute(c.Context(), userToken.ID, "department", req.Department); err != nil { slog.Error("Failed to update department", "error", err) } } - slog.Info("[UpdateMe] Profile update completed successfully", "userID", userToken.ID) + slog.Info("[UpdateMe] Profile update completed successfully", "userID", identityID) return c.JSON(fiber.Map{ "status": "success", diff --git a/backend/internal/handler/auth_handler_oidc_test.go b/backend/internal/handler/auth_handler_oidc_test.go new file mode 100644 index 00000000..0280c876 --- /dev/null +++ b/backend/internal/handler/auth_handler_oidc_test.go @@ -0,0 +1,201 @@ +package handler + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + + "baron-sso-backend/internal/service" +) + +func newOidcLoginTestApp(h *AuthHandler) *fiber.App { + app := fiber.New() + app.Post("/api/v1/auth/oidc/login/accept", h.AcceptOidcLoginRequest) + return app +} + +func TestAcceptOidcLoginRequest_CookieOnly(t *testing.T) { + var gotSubject string + var gotChallenge string + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.Host { + case "kratos.test": + if r.URL.Path != "/sessions/whoami" { + return httpResponse(r, http.StatusNotFound, "not found"), nil + } + if r.Header.Get("X-Session-Token") != "" { + return httpResponse(r, http.StatusUnauthorized, "invalid token"), nil + } + if r.Header.Get("Cookie") == "" { + return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil + } + return httpJSON(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{ + "id": "kratos-123", + "traits": map[string]interface{}{}, + }, + }), nil + case "hydra.test": + if r.URL.Path != "/oauth2/auth/requests/login/accept" { + return httpResponse(r, http.StatusNotFound, "not found"), nil + } + gotChallenge = r.URL.Query().Get("login_challenge") + body, _ := io.ReadAll(r.Body) + var payload map[string]interface{} + _ = json.Unmarshal(body, &payload) + if subject, ok := payload["subject"].(string); ok { + gotSubject = subject + } + return httpResponse(r, http.StatusOK, `{"redirect_to":"http://rp/cb"}`), nil + default: + return httpResponse(r, http.StatusNotFound, "not found"), nil + } + }) + client := &http.Client{Transport: transport} + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { + http.DefaultClient = origDefault + }() + + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + } + app := newOidcLoginTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "login_challenge": "challenge-123", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/login/accept", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", "ory_kratos_session=abc123") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var got map[string]string + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if got["redirectTo"] != "http://rp/cb" { + t.Fatalf("unexpected redirectTo: %v", got["redirectTo"]) + } + if gotSubject != "kratos-123" { + t.Fatalf("unexpected subject: %v", gotSubject) + } + if gotChallenge != "challenge-123" { + t.Fatalf("unexpected login_challenge: %v", gotChallenge) + } +} + +func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) { + var gotSubject string + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.Host { + case "kratos.test": + if r.URL.Path != "/sessions/whoami" { + return httpResponse(r, http.StatusNotFound, "not found"), nil + } + if r.Header.Get("X-Session-Token") != "" { + return httpResponse(r, http.StatusUnauthorized, "invalid token"), nil + } + if r.Header.Get("Cookie") == "" { + return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil + } + return httpJSON(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{ + "id": "kratos-456", + "traits": map[string]interface{}{}, + }, + }), nil + case "hydra.test": + if r.URL.Path != "/oauth2/auth/requests/login/accept" { + return httpResponse(r, http.StatusNotFound, "not found"), nil + } + body, _ := io.ReadAll(r.Body) + var payload map[string]interface{} + _ = json.Unmarshal(body, &payload) + if subject, ok := payload["subject"].(string); ok { + gotSubject = subject + } + return httpResponse(r, http.StatusOK, `{"redirect_to":"http://rp/cb"}`), nil + default: + return httpResponse(r, http.StatusNotFound, "not found"), nil + } + }) + client := &http.Client{Transport: transport} + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { + http.DefaultClient = origDefault + }() + + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + } + app := newOidcLoginTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "login_challenge": "challenge-456", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/login/accept", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer invalid-token") + req.Header.Set("Cookie", "ory_kratos_session=def456") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if gotSubject != "kratos-456" { + t.Fatalf("unexpected subject: %v", gotSubject) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func httpResponse(req *http.Request, status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewBufferString(body)), + Request: req, + } +} + +func httpJSON(req *http.Request, status int, payload map[string]interface{}) *http.Response { + data, _ := json.Marshal(payload) + resp := httpResponse(req, status, string(data)) + resp.Header.Set("Content-Type", "application/json") + return resp +} diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 19e72276..76cab629 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -396,16 +396,26 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { items := make([]consentSummary, 0, len(sessions)) for _, session := range sessions { + client := session.Client + if client.ClientID == "" && session.ConsentRequest != nil { + client = session.ConsentRequest.Client + } + subject := session.Subject + if subject == "" && session.ConsentRequest != nil { + subject = session.ConsentRequest.Subject + } authAt := "" if session.AuthenticatedAt != nil { authAt = session.AuthenticatedAt.Format(time.RFC3339) } else if session.RequestedAt != nil { authAt = session.RequestedAt.Format(time.RFC3339) + } else if session.HandledAt != nil { + authAt = session.HandledAt.Format(time.RFC3339) } items = append(items, consentSummary{ - Subject: session.Subject, - ClientID: session.Client.ClientID, - ClientName: session.Client.ClientName, + Subject: subject, + ClientID: client.ClientID, + ClientName: client.ClientName, GrantedScopes: session.GrantedScope, AuthenticatedAt: authAt, }) diff --git a/backend/internal/repository/oathkeeper_clickhouse_repo.go b/backend/internal/repository/oathkeeper_clickhouse_repo.go new file mode 100644 index 00000000..609222b2 --- /dev/null +++ b/backend/internal/repository/oathkeeper_clickhouse_repo.go @@ -0,0 +1,106 @@ +package repository + +import ( + "baron-sso-backend/internal/domain" + "context" + "fmt" + + "github.com/ClickHouse/clickhouse-go/v2" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" +) + +type OathkeeperClickHouseRepository struct { + conn driver.Conn +} + +func NewOathkeeperClickHouseRepository(host string, port int, user, password, db string) (*OathkeeperClickHouseRepository, error) { + conn, err := clickhouse.Open(&clickhouse.Options{ + Addr: []string{fmt.Sprintf("%s:%d", host, port)}, + Auth: clickhouse.Auth{ + Database: db, + Username: user, + Password: password, + }, + Debug: false, + }) + if err != nil { + return nil, fmt.Errorf("failed to open ory clickhouse connection: %w", err) + } + if err := conn.Ping(context.Background()); err != nil { + return nil, fmt.Errorf("failed to ping ory clickhouse: %w", err) + } + return &OathkeeperClickHouseRepository{conn: conn}, nil +} + +func (r *OathkeeperClickHouseRepository) FindPageBySubject(ctx context.Context, subject string, limit int, cursor *domain.AuditCursor) ([]domain.OathkeeperAccessLog, error) { + if limit <= 0 { + limit = 50 + } + query := ` + SELECT timestamp, request_id, method, path, status, latency_ms, rp, action, target, subject, client_ip, user_agent, decision, trace_id, span_id, raw + FROM oathkeeper_access_logs + ` + args := make([]any, 0, 5) + if subject != "" { + query += ` + WHERE subject = ? + ` + args = append(args, subject) + if cursor != nil { + query += ` + AND ((timestamp < ?) OR (timestamp = ? AND request_id < ?)) + ` + args = append(args, cursor.Timestamp, cursor.Timestamp, cursor.EventID) + } + } else if cursor != nil { + query += ` + WHERE (timestamp < ?) OR (timestamp = ? AND request_id < ?) + ` + args = append(args, cursor.Timestamp, cursor.Timestamp, cursor.EventID) + } + query += ` + ORDER BY timestamp DESC, request_id DESC + LIMIT ? + ` + args = append(args, limit) + + rows, err := r.conn.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query oathkeeper logs: %w", err) + } + defer rows.Close() + + var logs []domain.OathkeeperAccessLog + for rows.Next() { + var log domain.OathkeeperAccessLog + if err := rows.Scan( + &log.Timestamp, + &log.RequestID, + &log.Method, + &log.Path, + &log.Status, + &log.LatencyMs, + &log.RP, + &log.Action, + &log.Target, + &log.Subject, + &log.ClientIP, + &log.UserAgent, + &log.Decision, + &log.TraceID, + &log.SpanID, + &log.Raw, + ); err != nil { + return nil, fmt.Errorf("failed to scan oathkeeper log: %w", err) + } + logs = append(logs, log) + } + return logs, nil +} + +func (r *OathkeeperClickHouseRepository) Ping(ctx context.Context) error { + if r == nil || r.conn == nil { + return fmt.Errorf("ory clickhouse connection is nil") + } + return r.conn.Ping(ctx) +} diff --git a/backend/internal/service/descope_service.go b/backend/internal/service/descope_service.go index b86b58bd..bbf0f430 100644 --- a/backend/internal/service/descope_service.go +++ b/backend/internal/service/descope_service.go @@ -135,7 +135,8 @@ func (d *DescopeProvider) SignIn(loginID, password string) (*domain.AuthInfo, er Expiration: time.Unix(authInfo.SessionToken.Expiration, 0), SessionID: authInfo.SessionToken.ID, }, - Subject: authInfo.User.UserID, + // 내부 식별자는 Kratos identity ID로 통일합니다. + Subject: "", } if authInfo.RefreshToken != nil { res.RefreshToken = &domain.Token{ @@ -204,7 +205,8 @@ func (d *DescopeProvider) IssueSession(loginID string) (*domain.AuthInfo, error) Expiration: time.Unix(authInfo.SessionToken.Expiration, 0), SessionID: authInfo.SessionToken.ID, }, - Subject: authInfo.User.UserID, + // 내부 식별자는 Kratos identity ID로 통일합니다. + Subject: "", } if authInfo.RefreshToken != nil { res.RefreshToken = &domain.Token{ diff --git a/backend/internal/service/hydra_admin_service.go b/backend/internal/service/hydra_admin_service.go index 103bc45f..eff0bdfe 100644 --- a/backend/internal/service/hydra_admin_service.go +++ b/backend/internal/service/hydra_admin_service.go @@ -46,13 +46,17 @@ type HydraConsentRequest struct { } type HydraConsentSession struct { - Subject string `json:"subject"` - GrantedScope []string `json:"granted_scope"` - GrantedAudience []string `json:"granted_audience,omitempty"` - Remember bool `json:"remember"` - AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"` - RequestedAt *time.Time `json:"requested_at,omitempty"` - Client HydraClient `json:"client"` + ConsentRequestID string `json:"consent_request_id,omitempty"` + Subject string `json:"subject,omitempty"` + GrantedScope []string `json:"grant_scope,omitempty"` + GrantedAudience []string `json:"grant_access_token_audience,omitempty"` + Remember bool `json:"remember"` + RememberFor int `json:"remember_for,omitempty"` + AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"` + RequestedAt *time.Time `json:"requested_at,omitempty"` + HandledAt *time.Time `json:"handled_at,omitempty"` + Client HydraClient `json:"client,omitempty"` + ConsentRequest *HydraConsentRequest `json:"consent_request,omitempty"` } func NewHydraAdminService() *HydraAdminService { @@ -267,13 +271,13 @@ func (s *HydraAdminService) ListConsentSessions(ctx context.Context, subject, cl } defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) if resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("hydra admin: list consent sessions failed status=%d body=%s", resp.StatusCode, string(body)) } var sessions []HydraConsentSession - if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil { + if err := json.Unmarshal(body, &sessions); err != nil { return nil, fmt.Errorf("hydra admin: decode consent sessions failed: %w", err) } return sessions, nil @@ -398,7 +402,7 @@ func (s *HydraAdminService) GetConsentRequest(ctx context.Context, challenge str return &consentReq, nil } -func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge string, grantInfo *HydraConsentRequest) (*AcceptConsentRequestResponse, error) { +func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge string, grantInfo *HydraConsentRequest, sessionClaims map[string]any) (*AcceptConsentRequestResponse, error) { params := map[string]string{ "consent_challenge": challenge, } @@ -413,6 +417,12 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge "remember": true, "remember_for": 3600, } + if len(sessionClaims) > 0 { + payload["session"] = map[string]any{ + "id_token": sessionClaims, + "access_token": sessionClaims, + } + } body, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, "PUT", endpoint, bytes.NewReader(body)) @@ -443,7 +453,6 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge return &AcceptConsentRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil } - func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge string, subject string) (*AcceptLoginRequestResponse, error) { params := map[string]string{ "login_challenge": challenge, diff --git a/compose.ory.yaml b/compose.ory.yaml index 44ddfac3..434fbbe3 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -144,6 +144,8 @@ services: environment: - APP_ENV=${APP_ENV:-development} - LOG_LEVEL=debug + - OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} + - OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} volumes: - ./docker/ory/oathkeeper:/etc/config/oathkeeper - ./docker/ory/oathkeeper/logs:/var/log/oathkeeper @@ -201,6 +203,8 @@ services: image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} environment: - HYDRA_ADMIN_URL=http://hydra:4445 + - OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} + - OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} command: | hydra clients create \ --endpoint http://hydra:4445 \ @@ -220,6 +224,14 @@ services: --token-endpoint-auth-method none \ --response-types code \ --callbacks http://localhost:5174/callback; + + hydra clients create \ + --endpoint http://hydra:4445 \ + --id "$OATHKEEPER_INTROSPECT_CLIENT_ID" \ + --secret "$OATHKEEPER_INTROSPECT_CLIENT_SECRET" \ + --grant-types client_credentials \ + --response-types token \ + --scope openid,offline_access,profile,email; depends_on: ory_stack_check: condition: service_completed_successfully diff --git a/docker/ory/oathkeeper/oathkeeper.yml b/docker/ory/oathkeeper/oathkeeper.yml index 7e30286c..ed78a337 100755 --- a/docker/ory/oathkeeper/oathkeeper.yml +++ b/docker/ory/oathkeeper/oathkeeper.yml @@ -26,6 +26,23 @@ authenticators: preserve_path: true extra_from: "@this" subject_from: "identity.id" + oauth2_introspection: + enabled: true + config: + introspection_url: http://hydra:4444/oauth2/introspect + pre_authorization: + enabled: true + client_id: ${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} + client_secret: ${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} + token_url: http://hydra:4444/oauth2/token + jwt: + enabled: true + config: + jwks_urls: + - http://hydra:4444/.well-known/jwks.json + trusted_issuers: + - http://hydra:4444/ + scope_strategy: none authorizers: allow: diff --git a/docker/ory/oathkeeper/rules.draft.json b/docker/ory/oathkeeper/rules.draft.json index 42d10b92..3201389b 100755 --- a/docker/ory/oathkeeper/rules.draft.json +++ b/docker/ory/oathkeeper/rules.draft.json @@ -86,30 +86,20 @@ "mutators": [{ "handler": "noop" }] }, { - "id": "rp-template-browser", - "description": "RP proxy (browser session). TODO: match.url/upstream.url을 실제 RP로 좁혀야 함.", + "id": "rp-host-template", + "description": "RP 호스트 기반 템플릿. redirect_uri의 host를 기준으로 매칭합니다.", "match": { - "url": "http://<.*>/rp/<.*>", + "url": "<.*>://rp.example.com/<.*>", "methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] }, "upstream": { "url": "http://rp_upstream:8080" }, - "authenticators": [{ "handler": "cookie_session" }], - "authorizer": { "handler": "allow" }, - "mutators": [{ "handler": "noop" }] - }, - { - "id": "rp-template-bearer", - "description": "RP proxy (bearer). TODO: oauth2_introspection 또는 jwt 활성화 필요.", - "match": { - "url": "http://<.*>/rp-api/<.*>", - "methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] - }, - "upstream": { - "url": "http://rp_upstream:8080" - }, - "authenticators": [{ "handler": "oauth2_introspection" }], + "authenticators": [ + { "handler": "cookie_session" }, + { "handler": "oauth2_introspection" }, + { "handler": "jwt" } + ], "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] } diff --git a/docker/ory/vector/vector.toml b/docker/ory/vector/vector.toml index 4a00d55c..35dd4f50 100644 --- a/docker/ory/vector/vector.toml +++ b/docker/ory/vector/vector.toml @@ -46,7 +46,10 @@ .action = parsed.action ?? "" .target = parsed.target ?? "" .rule_id = parsed.rule_id ?? get(parsed, ["rule", "id"]) ?? "" - .client_id = parsed.client_id ?? get(parsed, ["client", "id"]) ?? "" + parsed_url = {} + if request_url != "" { parsed_url = parse_url(request_url) ?? {} } + query_params = get(parsed_url, ["query"]) ?? {} + .client_id = parsed.client_id ?? get(parsed, ["client", "id"]) ?? get(query_params, ["client_id"]) ?? get(query_params, ["clientId"]) ?? "" .parent_session_id = parsed.parent_session_id ?? get(parsed, ["extra", "parent_session_id"]) ?? "" .host = parsed.host ?? request_host ?? "" .scheme = parsed.scheme ?? request_scheme ?? "" diff --git a/docs/개발완료보고서.md b/docs/개발완료보고서.md new file mode 100644 index 00000000..185cef4c --- /dev/null +++ b/docs/개발완료보고서.md @@ -0,0 +1,2471 @@ +# Baron SSO 개발 완료 보고서 (초안) + +작성일: 2026-02-03 +문서 버전: v1.1 (장문 초안) +작성 대상: 내부 공유용 + +> 본 문서는 `docs/`에 정리된 문서와 **종료된 이슈** 내용을 종합하여 현재 코드 상태와 개발 성과를 정리한 보고서 초안입니다. +> 외부 IDP 특정 벤더 명칭/상세는 제외했습니다. (멀티 IDP 지향에서 Ory 중심으로 전환한 방향성은 포함) + +--- + +## 목차 + +1. 보고서 개요 +- 🏗 아키텍처 (Architecture) +2. 요구사항 및 범위 재정의 +3. 개발 방향성 변화 요약 +4. 개발 진행 단계 요약 +5. 기능별 개발 결과 (UserFront 중심) +6. 인증/세션 플로우 상세 +7. 동의(Consent) 및 OIDC 플로우 상세 +8. 비밀번호 정책/재설정 기능 상세 +9. 사용자 프로필/세션 관리 +10. 감사 로그 및 관측성 +11. Backend API 설계 정책 및 적용 +12. 운영/환경 구성 가이드 요약 +13. 테스트 전략 및 실행 기준 +14. 문서화 성과 정리 +15. 종료 이슈 성과 요약 +16. 현재 코드 상태의 한계와 리스크 +17. 향후 개선 과제 및 로드맵 +18. 부록 A. 종료 이슈 상세 목록(요약+성과) +19. 부록 B. 테스트 체크리스트 상세 +20. 부록 C. 인증 플로우 상태표 +21. 부록 D. 용어/약어 정리 + +--- + +## 1. 보고서 개요 + +### 1.1 목적 +- 현재 코드 상태를 기준으로 **개발 완료 범위**와 **실제 구현 성과**를 명확히 기록합니다. +- 종료된 이슈의 목표와 달성 결과를 기능 단위로 연결하여 개발 히스토리를 남깁니다. +- 후속 개발 및 운영 전환을 위한 기준(품질/테스트/운영)을 제공하는 것을 목적으로 합니다. + +### 1.2 작성 원칙 +- 아키텍처/구조 설명은 기본적으로 제외하되, 본 요청에 따라 **아키텍처 요약 챕터**를 포함합니다. +- 외부 IDP 특정 벤더명/상세는 언급하지 않으며, “외부 IDP” 또는 “외부 SaaS”로 표현합니다. +- 현재 코드 상태를 과장하지 않고, 완료/부분/미완료를 명확히 구분합니다. +- README에 포함될 내용 중 구축/개요는 제외하되, **아키텍처 요약은 본 문서에 포함**합니다. + +### 1.3 범위 +- Backend (Go/Fiber), UserFront(Flutter) 중심 +- Ory 기반 인증/동의 흐름과 UserFront 라우팅 +- 테스트 계획 및 운영 가이드 +- DevFront/AdminFront는 **개념 및 현황 수준**으로만 요약 + +--- + +## 🏗 아키텍처 (Architecture) + +### 0. Ory Stack +- Ory Kratos: 사용자 인증/계정 관리(Identity). +- Ory Hydra: OAuth2/OIDC 발급 및 토큰 관리. +- Ory Keto: 권한/정책 기반 접근 제어. +- Oathkeeper: 인증/인가 프록시 및 라우팅 게이트웨이. + +```mermaid +flowchart + subgraph Edge + OK["Oathkeeper
(Only Public Entry)"] + end + + subgraph App + BE["Backend
(Only Upstream)"] + end + + subgraph OryStack + KR[Kratos] + HY[Hydra] + KE[Keto] + KR --- HY --- KE + end + BE -->|Command| OryStack + OK -->|Query| KR + OK -->|Query| HY + OK -->|Query| KE +``` + +### 1. Backend (Go Fiber) +- **Language**: Go 1.25+ +- **Framework**: Fiber v2.25+ +- **Database**: + - **ClickHouse**: 감사 로그 (고성능 데이터 수집) + - **PostgreSQL**: 메타데이터 저장소 (Primary) +- **Features**: + - 인증용 SMS 발송 등 Ory-Stack으로 구현 어려운 부분 직접 구현 + - `POST /api/v1/audit`: 감사 로그 수집 API + - userfront가 바라보는 backend + +### 2. UserFront(Flutter Web/App) +- **Framework**: Flutter 3.32+ +- **Key Packages**: `flutter_riverpod`, `go_router` +- **Features**: + - 탭 기반 로그인 UI (비밀번호 기반 / 링크 기반 / QR 기반 등) + +### 3. adminfront(Web) +- **Framework**: Vite, React 19+, Shadcn/ui 등 +- **Features**: + - 사용자 관리, 권한 부여 등 관리자 기능 + - 앱 별 사용량(호출량) 등 통계 + - 핵심 Audit 대상 + +### 4. devfront(Web) +- **Framework**: Vite, React 19+, Shadcn/ui 등 +- **Features**: + - RP 등록 및 관리 + - RP별 Consent 관리 + +### 5. 주요 시나리오 (Core Scenarios) +1. **Same Browser SSO**: Baron 통합로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인). + 1.1. 단 약관동의(Consent) 이력이 없으면 Consent 단계로 이동 +2. **Cross-Device Auth**: 이메일 SMS 등의 수단으로 링크를 전달받고 해당 링크를 사용자가 클릭하면 최초 로그인 요청한 세션이 활성화 + 2.1 향후 App Push 등 2차 인증 강화수단 검토 필요 +3. **QR Login**: 최초 진입 시 사전 로그인되어 있는 웹/앱을 이용해 QR 코드를 스캔하여, QR코드가 로딩된 Device를 로그인 상태로 전환 + +### 전체 연결 구조도 + +```mermaid +flowchart TD + subgraph Clients ["External Clients"] + AF[adminfront] + DF[devfront] + UF["userfront"] + DS["일반SW"] + end + + subgraph AppService ["Control Plane"] + BE["Backend (Command/Audit Controller)"] + end + + subgraph OryBundle ["Ory Deployment Stack"] + direction TB + OK["Oathkeeper (Public Proxy/OIDC)"] + + subgraph OryEngines ["Ory Services"] + direction LR + HY["Hydra"] + KR["Kratos"] + KE["Keto"] + end + + ICH[(Internal Clickhouse)] + + %% Internal Flow within Bundle + OK -->|Routing/Queries| OryEngines + OK -.->|Access/Usage Log| ICH + end + + subgraph AuditDB ["Audit Storage"] + ECH[(External Clickhouse)] + end + + %% Key Command Path + AF & DF & UF & DS ==>|Actions / Commands| BE + + %% Backend Responsibilities + BE -->|Admin/State Control| OryEngines + BE -.->|Mandatory Audit Log| ECH + + %% Connection Note (Hidden flow mentioned in logic) + %% OK is technically the entry for OIDC, but removed as per request + + %% Styles + style OryBundle fill:#f8f9fa,stroke:#333,stroke-width:2px + style BE fill:#bbf,stroke:#333,stroke-width:2px + style ECH fill:#fdd,stroke:#333 + style ICH fill:#dfd,stroke:#333 + style OK fill:#f9f,stroke:#333 + style OryEngines fill:#fff,stroke:#999,stroke-dasharray: 5 5 +``` + +Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비즈니스 로직은 Backend를 통해서, 기본 인증 로직은 Ory Stack을 통해 진행됩니다. + +--- + +## 2. 요구사항 및 범위 재정의 + +### 2.1 초기 요구사항의 핵심 요약 +초기 요구사항 문서에서 강조된 핵심 목표는 다음과 같습니다. +- 사용자 중심의 통합 인증 허브 구축 +- 동일 브라우저 SSO 및 교차 기기 인증 제공 +- 감사 로그 기반의 운영 추적성 확보 +- 로그인 후 서비스 런처 제공 + +초기 범위는 “통합 로그인과 런처 경험을 빠르게 제공하고, 내부 서비스들이 표준 OIDC 방식으로 연동될 수 있도록 준비하는 것”에 집중되었습니다. 이 과정에서 사용자 경험(로그인 화면 통일, 간편 로그인), 운영 품질(로그 및 추적성), 확장성(멀티 IDP 가능성)을 균형 있게 고려했습니다. + +### 2.2 범위 재정의 결과 +개발 진행 과정에서 다음과 같은 범위 조정이 이루어졌습니다. +- 멀티 IDP 확장성을 유지하되, **실 구현은 Ory 기반으로 우선 집중** +- 관리/개발자 포털은 UI 중심(목업 수준 포함)으로 진행하고, 데이터 연동은 단계적으로 확장 +- 인증/세션/동의 흐름의 안정성이 최우선 과제로 선정 + +이러한 범위 재정의는 현실적인 구현 리스크와 운영 안정성을 고려한 결과이며, 향후 단계에서 멀티 IDP 대응과 포털 기능 고도화를 순차적으로 이어갈 수 있도록 기반을 마련했습니다. + +### 2.3 산출물 범위 정의 +- 인증/로그인 핵심 플로우: 완료 +- QR/링크 기반 교차 기기 인증: 완료 +- Consent UI 및 OIDC 수락 흐름: 기본 연동 완료 +- 비밀번호 정책 공통화 및 재설정 플로우: 구현 및 리팩터 방향 확정 +- 운영/테스트 문서화: 완료 +- DevFront/AdminFront 실 데이터 연동: 부분 구현 + +--- + +## 3. 개발 방향성 변화 요약 + +### 3.1 멀티 IDP 지향 → Ory 기반 중심 전환 +- 초기에는 외부 IDP 연동 중심으로 설계되었으나, 표준 OIDC/OAuth2 구현 안정성과 운영 통제력을 이유로 Ory 기반 전환이 진행되었습니다. +- 멀티 IDP의 장점을 유지하되, 실 구현에서는 **Kratos/Hydra 중심의 동작 안정성 확보**를 우선했습니다. +- 이 전환 과정에서 “인증 주권”을 내부로 가져오는 것이 핵심 의사결정이었으며, 표준 프로토콜 준수와 향후 확장성 확보에 초점을 맞췄습니다. + +### 3.2 전환의 효과 +- OIDC 표준 동작이 더 안정적으로 보장됨 +- 인증/동의 플로우의 책임과 범위가 명확해짐 +- 운영 상의 예측 가능성이 높아짐 +- 테스트/디버깅 시 내부 환경에서 재현 가능한 범위가 확대됨 + +### 3.3 전환에 따른 보완 과제 +- 멀티 IDP 활성화 시 **세션 공유 방식**과 **프로필 동기화 정책** 재정의 필요 +- 인증 수단 확장 시 UI/Backend 간 정책 정합성 확보 필요 +- 인증 성공 이후 앱/서비스 측 세션 전달 방식 표준화 필요 + +--- + +## 4. 개발 진행 단계 요약 + +### 4.1 1단계: 기본 인증 플로우 구축 +- 비밀번호 로그인, 링크 기반 로그인, QR 로그인 등 핵심 플로우 구현 +- 인증 성공 시 세션 토큰 발급 및 저장 +- UserFront 로그인 UI 및 상태 관리 정리 +- SMS 인증 및 링크 기반 플로우의 초기 구현 완료 + +### 4.2 2단계: Ory 기반 전환 및 라우팅 정비 +- Kratos self-service 경로를 UserFront로 매핑 +- `/login`, `/registration`, `/recovery`, `/verification`, `/error` 동작 정리 +- 설정 화면(`settings`)은 임시 비활성 처리 +- Ory 기반 로그인/동의 흐름과 UserFront 연동 완료 + +### 4.3 3단계: 운영 문서화 및 테스트 기준 확립 +- API 설계 정책 및 테스트 계획 정리 +- 네트워크/환경 구성 가이드 문서화 +- 헬스체크 및 환경 점검 스크립트 마련 +- 운영/테스트 기준을 문서로 확정 + +### 4.4 4단계: Dev/Admin 포털 확장 기반 마련 +- DevFront UI 구성 및 일부 상호작용 구현 +- AdminFront 설계 문서화 및 확장 방향 정리 +- 실제 API 연동을 위한 백엔드 이슈 정의 + +--- + +## 5. 기능별 개발 결과 (UserFront 중심) + +### 5.1 로그인 UI 및 상태 처리 +**목표:** 사용자가 하나의 화면에서 다양한 인증 수단을 선택할 수 있도록 제공 + +**구현 내용:** +- 비밀번호, 로그인 링크, QR 로그인 탭 제공 +- 각 탭의 입력 폼과 버튼 UX 정리 +- 성공/실패 시 메시지 및 상태 표기 +- 로그인 시도 중 로딩 상태 표시 +- 인증 성공 시 세션 저장, 프로필 프리패치, 대시보드 이동 + +**현재 코드 상태:** +- 로그인 화면은 `TabBar` 기반으로 구성되어 있으며, 탭별 요청 로직이 분리되어 있습니다. +- 인증 실패 시 Snackbar를 통해 메시지를 통일된 형식으로 제공하고 있습니다. +- 로그인 성공 후에는 토큰 저장 → 프로필 프리패치 → 로그인 성공 동작을 수행합니다. + +**완료 기준 대비 요약:** +- 주요 로그인 수단은 화면에 모두 노출됨 +- 기본 에러 처리/상태 처리는 구현 완료 +- 세션 공유 방식은 토큰 기반 중심이며, 쿠키 기반 자동화는 부분 구현 + +### 5.2 링크 로그인(교차 기기 인증) UX +**목표:** 요청 기기와 승인 기기 간 역할을 분리하고 사용자 경험을 단순화 + +**구현 내용:** +- 링크 요청은 PC(또는 요청 기기)에서 수행 +- 승인(verify-only)은 모바일 등 승인 기기에서 수행 +- 실제 세션 발급은 요청 기기의 Polling 시점에서 수행 +- 로그인 수단 메타데이터 저장으로 로그/감사 일관성 확보 + +**현재 코드 상태:** +- verify-only 흐름이 명확히 정리되어 있으며, 승인 기기에서 세션이 생성되지 않도록 보장합니다. +- 인증수단 표기(이메일/문자/코드/링크)가 메타데이터로 저장되어 로그/감사에서 구분 가능합니다. + +**완료 기준 대비 요약:** +- 원격 승인/요청 기기 세션 발급 분리 완료 +- 승인 기기에서 세션 생성이 되지 않는지 테스트 기준 마련 + +### 5.3 QR 로그인 UX +**목표:** 교차 기기 로그인을 지원하면서도 승인 기기의 세션 발급을 방지 + +**구현 내용:** +- QR 코드 발급 및 대기 화면 제공 +- 모바일 승인 후 PC Polling에서 세션 발급 +- 승인 결과 안내 메시지 제공 + +**현재 코드 상태:** +- QR 승인 흐름은 전반적으로 구현되어 있으며, Polling 로직과 UI가 연동되어 있습니다. +- 승인 결과에 따라 자동 이동 또는 안내 표시가 가능하도록 구성되어 있습니다. + +### 5.4 비밀번호 재설정 UX +**목표:** 비밀번호 재설정 흐름을 안정화하고 UI 일관성 유지 + +**구현 내용:** +- 정책 로딩 및 검증 로직 공통화 +- 재설정 흐름 분리(initiated → verify → complete) +- 정책 로딩 실패 시 기본 규칙 폴백 + +**현재 코드 상태:** +- 재설정 화면 및 정책 기반 검증 로직이 통합되어 있습니다. +- 재설정 흐름은 별도 문서로 리팩터 전략이 정리되어 있습니다. + +### 5.5 오류 화면 및 접근 경로 정리 +**목표:** 사용자에게 안전하고 일관된 오류 안내 제공 + +**구현 내용:** +- ErrorScreen 도입 +- 설정 화면 비활성 시 오류 안내 +- Kratos 오류 경로 매핑 + +**현재 코드 상태:** +- 오류 화면은 일관된 UI와 메시지 정책을 따릅니다. +- Whitelist 기반 오류 노출 정책(초안)은 별도 이슈에서 정리 중입니다. + +--- + +## 6. 인증/세션 플로우 상세 + +### 6.1 비밀번호 로그인 +**흐름 요약:** +1. 사용자 로그인 입력 +2. Backend 인증 요청 +3. 성공 시 세션 토큰 발급 +4. 프론트 로컬 저장 및 이동 + +**현재 상태:** +- 요청/응답 흐름이 구현되어 있으며, 오류 처리도 포함됨 +- 토큰은 로컬 저장소에 저장되는 것을 전제로 동작 + +### 6.2 링크 로그인(코드/링크 기반) +**흐름 요약:** +1. 로그인 요청 → pendingRef 발급 +2. 폴링을 통해 상태 확인 +3. 승인 기기에서 verify-only 수행 +4. 요청 기기에서 세션 발급 및 로그인 완료 + +**현재 상태:** +- verify-only 기반 승인 처리 적용 +- 승인 기기 세션 생성 방지 +- 인증수단 표기 개선 + +### 6.3 QR 로그인 +**흐름 요약:** +1. QR 코드 생성 +2. 모바일 승인 +3. Polling으로 승인 확인 +4. 요청 기기에서 세션 발급 + +**현재 상태:** +- QR 로그인 플로우 완성 +- 승인 기기의 세션 생성 없음 + +### 6.4 세션 유효성 확인 +- 토큰 기반 확인 및 쿠키 기반 확인 지원 +- 세션 유효성 실패 시 로컬 저장 정리 + +### 6.5 세션 공유 전략 +- 현재는 토큰 기반 저장/전달이 중심 +- 쿠키 기반 공유는 일부 경로에서 제한적이며 보완 필요 + +--- + +## 7. 동의(Consent) 및 OIDC 플로우 상세 + +### 7.1 Consent 화면 +- 권한 요청 목록 제공 +- 동의 수락 시 리다이렉트 +- 거부 플로우는 후속 작업 + +### 7.2 Consent 세션 생성 실험 +- consent UI 부재 시 세션 생성 불가 확인 +- 임시 consent app을 통한 세션 생성 검증 +- CSRF 오류 발생 시에도 consent 세션 생성 가능 + +### 7.3 운영 지침 요약 +- Consent UI는 항상 가용해야 함 +- login_challenge, consent_challenge 흐름에 UI 가용성이 핵심 + +--- + +## 8. 비밀번호 정책/재설정 기능 상세 + +### 8.1 정책 공통화 +- 비밀번호 정책을 로딩하여 가입/변경/재설정에 공통 적용 +- 정책 로딩 실패 시 기본 규칙으로 폴백 + +### 8.2 재설정 플로우 +- 자체 토큰 기반 reset 흐름 설계 +- 검증 단계와 완료 단계를 분리 + +--- + +## 9. 사용자 프로필/세션 관리 + +### 9.1 대시보드 +- 로그인 후 세션 리스트 표시 +- 에러/빈 상태 처리 + +### 9.2 프로필 관리 +- 프로필 화면에서 기본 정보 표시/수정 +- settings/profile 분리 방향성 정리 + +--- + +## 10. 감사 로그 및 관측성 + +### 10.1 감사 로그 적재 +- API 이벤트를 ClickHouse에 적재 +- 정상/오류 케이스 검증 기준 문서화 + +### 10.2 관측성 기준 +- 오류 로그 구조화 +- 실패 시 triage 기준 문서화 + +--- + +## 11. Backend API 설계 정책 및 적용 + +### 11.1 설계 원칙 +- 네임스페이스 분리 +- camelCase 응답 규칙 +- 표준 상태 코드 사용 + +### 11.2 에러 정책 +- 사용자에게 내부 오류 노출 최소화 +- 프로덕션 오류 메시지 최소화 + +--- + +## 12. 운영/환경 구성 가이드 요약 + +### 12.1 내부/외부 URL 분리 +- 내부 통신 URL과 브라우저 접근 URL 분리 +- Admin 포트 외부 노출 금지 + +### 12.2 실행 및 검증 +- Ory 스택 + 앱 스택 분리 실행 +- 헬스체크 스크립트 제공 + +--- + +## 13. 테스트 전략 및 실행 기준 + +### 13.1 테스트 원칙 +- Shift-left +- 단계적 검증 +- 보안 우선 + +### 13.2 테스트 레이어 +- Unit, Integration, Contract, E2E, Smoke + +### 13.3 주요 시나리오 +- 비밀번호 로그인 +- 링크/코드 로그인 +- QR 승인 +- Consent 승인/거부 +- 네트워크 경계 검증 + +--- + +## 14. 문서화 성과 정리 + +- 인증/로그인 플로우 문서 +- 원격 링크 로그인 불일치 대응 문서 +- Kratos 연동 보고서 +- Ory 환경 구성 가이드 +- 테스트 계획 및 운영 원칙 + +--- + +## 15. 종료 이슈 성과 요약 + +### 15.1 인증/로그인 +- 링크/QR 기반 인증 도입 +- verify-only 기반 원격 승인 정리 +- 인증 수단 표기 개선 + +### 15.2 UI/UX +- 로그인 화면 탭 UI +- 에러 화면 도입 +- 복사 버튼 피드백 UX 개선 + +### 15.3 운영/테스트 +- Gateway 분리 +- 감사 로그 검증 +- E2E 테스트 계획 + +--- + +## 16. 현재 코드 상태의 한계와 리스크 + +- 팝업 로그인 시 기존 세션 자동 수락 미비 +- Consent 거부 플로우 미완료 +- Dev/Admin 포털 실데이터 연동 부족 +- CI 기반 자동화 테스트 미구축 + +--- + +## 17. 향후 개선 과제 및 로드맵 + +- MFA/Passkey/WebAuthn 검토 +- 세션 관리 고도화 +- Consent 철회 및 통계 기능 강화 +- 운영 자동화 및 품질 게이트 구축 + +--- + +## 부록 A. 종료 이슈 상세 목록(요약+성과) + +- 상세 목록은 문서 하단 부록 섹션에 수록했습니다. + +--- + +## 부록 B. 테스트 체크리스트 상세 + +- 테스트 체크리스트 상세는 문서 하단 부록 섹션에 수록했습니다. + +--- + +## 부록 C. 인증 플로우 상태표 + +- 인증 플로우 상태표는 문서 하단 부록 섹션에 수록했습니다. + +--- + +## 부록 D. 용어/약어 정리 + +- SSO: Single Sign-On +- OIDC: OpenID Connect +- Consent: 권한 동의 +- verify-only: 승인만 기록하고 세션 발급은 요청 기기에서 수행하는 방식 + + +--- + +# 상세 본문 (확장) + +본 섹션은 상단 요약을 기반으로 **문서/이슈 내용의 세부 사항**을 풀어쓴 장문 설명입니다. 아키텍처/구조 설명은 제외하고, 동작/정책/흐름/운영 기준 중심으로 서술합니다. + +--- + +## 18. 인증/로그인 기능 상세 기록 + +### 18.1 비밀번호 로그인 + +**목표** +- 기본 로그인 수단으로서 이메일/휴대폰 + 비밀번호 인증을 제공 +- 로그인 성공 시 세션 토큰을 발급하여 사용자 경험을 안정적으로 제공 + +**주요 구현 내용** +- 로그인 요청을 Backend로 전달하고 세션 토큰을 수신 +- 로그인 실패 시 명확한 오류 메시지 제공 +- 로그인 성공 시 세션 토큰 저장 및 대시보드 이동 + +**현재 코드 상태** +- UserFront의 비밀번호 로그인 탭에서 입력 검증(필수값 체크)을 수행하고, 서버 응답에 따라 로딩 상태와 오류 메시지를 표기합니다. +- 인증 성공 시 토큰을 로컬 저장소에 저장하며, 이후 사용자 프로필 프리패치 로직을 실행합니다. +- 인증 실패 시에는 사용자에게 직접적인 실패 원인을 전달하는 대신, 통일된 오류 메시지 형태로 안내합니다. + +**관련 문서/이슈 연결** +- `docs/auth-flow.md`의 ID/Password 로그인 흐름과 일치 +- 종료 이슈: 비밀번호 로그인/변경 기능 보강 이슈들(#67, #70 등) + +--- + +### 18.2 링크 로그인(Enchanted/Code 기반) + +**목표** +- 사용자 편의성을 위해 비밀번호 없는 로그인 경로 제공 +- 교차 기기 시나리오에서 승인 기기와 요청 기기의 역할 분리 + +**핵심 설계** +- 요청 기기는 링크 요청을 수행하고, 상태를 폴링합니다. +- 승인 기기는 링크 클릭 후 verify-only 처리만 수행합니다. +- 세션 발급은 요청 기기의 폴링 시점에서만 진행됩니다. + +**현재 코드 상태** +- verify-only 흐름이 링크/코드 기반 검증 경로로 확장되었습니다. +- 승인 기기에서 세션이 생성되지 않도록 상태 저장 방식이 개선되었습니다. +- 인증 수단(문자/이메일)과 플로우 유형(코드/링크)을 메타데이터로 기록하여 감사 로그 및 이력 표기가 가능하게 되었습니다. + +**운영상 의의** +- 사용자 입장에서는 승인 기기에서 로그인된 것처럼 보이지 않도록 UX를 보장합니다. +- 운영자는 감사 로그에서 요청 수단과 승인 수단의 정합성을 확인할 수 있습니다. + +**관련 문서/이슈 연결** +- `docs/issue-146-remote-login.md`의 verify-only 확장 및 세션 발급 주체 정리 +- 종료 이슈: 로그인 검증 URL 단순화(#59), 원격 로그인 세션 불일치 대응(#146 관련), 링크/코드 흐름 정리 이슈들 + +--- + +### 18.3 QR 로그인 + +**목표** +- 교차 기기 인증을 지원하면서도, 승인 기기에서 세션이 생성되지 않도록 설계 +- QR 승인 흐름을 링크 로그인과 유사한 모델로 통합 + +**현재 코드 상태** +- QR 코드를 발급하고, 폴링을 통해 승인 상태를 확인합니다. +- 승인 기기에서 세션을 만들지 않고, 요청 기기에서 세션을 발급합니다. + +**검증 포인트** +- 승인 기기에서 세션 생성이 없어야 함 +- 요청 기기에서만 로그인 상태가 유지되어야 함 +- 승인 후 UI 안내 및 자동 이동 동작이 안정적으로 이루어져야 함 + +**관련 문서/이슈 연결** +- 종료 이슈: QR/이메일 로그인 추가(#21), QR 승인 흐름 관련 문서화 이슈 + +--- + +### 18.4 SMS OTP 인증 + +**목표** +- 기본 로그인 플로우 외에도 SMS 기반 인증 경로를 제공 +- 인증 코드 검증 로직을 통해 세션 발급 + +**현재 코드 상태** +- SMS 발송, 코드 검증, 결과 처리 흐름이 구현됨 +- 인증 성공 시 토큰이 발급되며, UI에서 성공 메시지를 표시 + +**주의사항** +- SMS OTP 기반 로그인은 일부 흐름에서 확장/정비 필요 +- 토큰 저장 및 후속 동작은 플로우에 따라 보완 필요 + +**관련 문서/이슈 연결** +- SMS 인증 플로우 문서(#12) +- SMS 인증 기능 구현 이슈(#11, #13) + +--- + +## 19. Kratos Self-service 라우팅 및 오류 처리 + +### 19.1 라우팅 매핑 +- `/login` → `LoginScreen` (alias `/signin`) +- `/registration` → `SignupScreen` (alias `/signup`) +- `/recovery` → `ForgotPasswordScreen` (alias `/forgot-password`) +- `/verification` → `LoginScreen` (alias `/verify`) +- `/settings` → 임시 비활성(`/error?error=settings_disabled`) +- `/error` → `ErrorScreen` + +### 19.2 설계 의도 +- Kratos self-service 플로우를 UserFront에서 직접 흡수하여 사용자 경험을 통일 +- “설정 화면”은 기능 분리 전까지 비활성화하여 혼선을 방지 + +### 19.3 현재 코드 상태 +- 로그인/가입/복구/검증/오류 경로는 정상 매핑 완료 +- 설정 화면은 임시 비활성 상태이며, 에러 안내로 대체 + +**관련 문서/이슈 연결** +- 종료 이슈: #156, #158, #159, #160 + +--- + +## 20. 동의(Consent) 흐름 및 운영 기록 + +### 20.1 Consent 화면 동작 +- 동의 화면에서 요청된 권한 목록을 표시 +- 동의 수락 시 리다이렉트 수행 +- 동의 거부 플로우는 후속 작업으로 남아 있음 + +### 20.2 Consent 세션 생성 실험 기록 +- Hydra 환경에서 consent UI가 실행되지 않으면 consent 세션 생성이 불가함을 확인 +- 임시 consent app을 통해 로그인/동의 자동 수락을 시도 +- 최종 리다이렉트에서 CSRF 문제가 발생하더라도 consent 세션은 생성됨을 확인 + +### 20.3 운영상의 의미 +- Consent UI 가용성은 동의 흐름의 절대 조건임 +- 운영/테스트 환경에서 임시 consent app을 통해 세션 생성 가능 + +**관련 문서/이슈 연결** +- `docs/hydra-rp-consent-try.md` +- `docs/hydra-rp-dummy.md` +- 종료 이슈: #165 (consent 화면 추가 및 OIDC 흐름 연계) + +--- + +## 21. 비밀번호 정책/재설정 리팩터 전략 + +### 21.1 정책 공통화 +- 비밀번호 정책은 `GET /api/v1/auth/password/policy`를 통해 로딩 +- 가입/비밀번호 변경/재설정 화면에서 동일 로직 사용 +- 정책 로딩 실패 시 기본 규칙 폴백 + +### 21.2 재설정 플로우 리팩터 +- 기존 외부 의존을 줄이고 자체 토큰 기반 흐름으로 전환 +- initiate → verify → complete 단계로 분리 +- 완료 단계는 IDP 추상화 기반 호출 구조로 정리 + +**관련 문서/이슈 연결** +- `docs/complete-password-reset-refactor.md` +- 종료 이슈: #66, #70, #65 + +--- + +## 22. 감사 로그 및 운영 추적성 + +### 22.1 감사 로그 적재 +- `POST /api/v1/audit` 호출 시 ClickHouse에 적재 +- 정상/오류 케이스별 확인 기준 문서화 +- 추후 감사 로그 기반 대시보드/통계 기능 확장 여지 + +### 22.2 운영 추적성 강화 +- 로그인/인증 흐름에서 상태 전환과 메타데이터 기록 +- 인증수단, 플로우 유형 등 추적 정보 보강 + +**관련 이슈** +- 종료 이슈: #5 (Audit Log 저장 확인) + +--- + +## 23. DevFront / AdminFront 현황 (개념 수준) + +### 23.1 DevFront +- 클라이언트 목록, 상세, Consent 화면 UI 구성 +- 복사 버튼 토스트 피드백 추가 +- 실 데이터 연동은 후속 이슈로 확장 예정 + +### 23.2 AdminFront +- 관리자 포털을 위한 기술 스택 및 UI 방향 정의 +- RP 관리 및 Audit Log 조회 화면 설계 +- 백엔드 API 확장은 단계적 진행 예정 + +--- + +## 24. 운영/환경 구성 가이드 핵심 + +### 24.1 내부/외부 URL 분리 +- 내부 통신 URL과 브라우저 접근 URL은 분리해야 함 +- Admin 포트는 외부에 노출하지 않는 것을 원칙으로 함 + +### 24.2 실행 및 점검 +- 인프라(Ory Stack)와 앱 스택은 compose 분리 실행 +- 헬스체크 및 네트워크 경계 테스트 스크립트 제공 + +**관련 문서** +- `docs/compose-ory.md` +- `docs/ory-usage.md` + +--- + +## 25. 테스트 계획 상세 요약 + +### 25.1 테스트 목적 +- 인증/인가 핵심 플로우 안정성 확보 +- 멀티 서비스 연동 품질 확보 +- 릴리즈 기준의 표준화 + +### 25.2 테스트 범위 +- Backend, UserFront, AdminFront, DevFront +- Ory Stack 및 DB (Postgres, ClickHouse, Redis) + +### 25.3 테스트 레이어 +- Unit → Integration → Contract → E2E → Smoke + +### 25.4 핵심 시나리오 +- 비밀번호 로그인 +- 링크/코드 로그인 (verify-only 포함) +- QR 승인 +- Consent 승인/거부 +- 네트워크 경계 검증 + +**관련 문서** +- `docs/test-plan.md` + +--- + +## 26. 문서화 성과 정리 (세부) + +### 26.1 정책/설계 문서 +- API 설계 정책 수립 +- 테스트 원칙 및 운영 기준 정리 + +### 26.2 플로우 문서 +- 인증 플로우 상세 +- 원격 링크 로그인 불일치 대응 + +### 26.3 운영 가이드 +- Ory 사용 가이드 +- 환경 구성 및 헬스체크 방법 정리 + +--- + +## 27. 종료 이슈 성과 상세 (요약) + +### 27.1 인증/로그인 기능 +- #21: QR/이메일 로그인 추가 → 교차 기기 로그인 UX 확장 +- #59: 로그인 검증 URL 단순화 → 링크 검증 경로 개선 +- #8: 로그인 분기 규칙 업데이트 → 앱 세션 여부에 따른 폴백 기준 정리 + +### 27.2 비밀번호/계정 +- #66: 비밀번호 초기화 플로우 재설계 → 외부 의존 축소 방향 확정 +- #70: 정책 로딩/검증 공통화 → UX 일관성 확보 +- #65: 비밀번호 변경 실패 이슈 대응 → 오류 대응 및 개선 + +### 27.3 라우팅/오류 +- #156~160: Kratos self-service 라우팅 정리 → 로그인/가입/복구/검증/오류 경로 안정화 + +### 27.4 운영/테스트 +- #153: Gateway 분리 → 인프라 구분 명확화 +- #5: Audit Log 저장 확인 → 운영 추적 기준 확보 +- #4: E2E 테스트 계획 → 회귀 방지 기준 문서화 + +--- + +## 28. 현재 코드 상태의 한계와 리스크 (상세) + +### 28.1 인증/세션 +- 팝업 로그인에서 기존 세션 자동 수락 미비 +- 쿠키 기반 세션 수락 흐름 보완 필요 + +### 28.2 Consent +- 거부 플로우 미완료 +- Consent 이력 저장/통계 기반 구축 필요 + +### 28.3 Dev/Admin 포털 +- 실 데이터 연동이 부족 +- 통계/페이지네이션 등 목업 항목 다수 존재 + +### 28.4 테스트/운영 +- CI 자동화 미구축 +- 회귀 테스트 범위 확장 필요 + +--- + +## 29. 향후 개선 과제 및 로드맵 (요약) + +- MFA/Passkey/WebAuthn 검토 +- 세션 관리 고도화 +- Consent 철회 및 통계 기능 강화 +- 운영 자동화 및 품질 게이트 구축 + +--- + +# 부록 + +## 부록 A. 종료 이슈 상세 목록(요약+성과) + +### A-1 인증/로그인/세션/프로필 +- #1 대시보드 세션 리스트: 로그인 후 진입 화면 및 세션 목록 UI 골격 확보. +- #2 통합 런처 UI: 로그인 이후 이동 경로/서비스 카드 UI 기반 마련. +- #8 로그인 분기 규칙 업데이트: 앱 세션 유무/사용자 선택에 따른 폴백 기준 정리 및 로그 기준 확립. +- #11/#12/#13 SMS OTP: 발송/검증 플로우와 Backend/Frontend 연동 기반 구축. +- #14/#16/#18/#21 링크·QR 로그인: 교차 기기 인증 흐름 문서화와 구현 기준 정립. +- #15 외부 서비스 연동 팝업 인증 플로우: 초기 PoC 기준 문서화(아키텍처 전환 후 참고 자료화). +- #17 링크 검증 로직 복원: 검증/세션 생성 로직 복구 및 안정화. +- #20 변수명 정리: 토큰/세션 변수명 의미 명확화. +- #59 검증 URL 단순화: `/verify/{token}` 경로 기반으로 라우팅 정리. +- #64/#68 마이페이지: 프로필 수정/화면 구성 보강. +- #65 비밀번호 변경 오류: 변경 반영 실패 버그 수정. +- #66 비밀번호 초기화 플로우 재설계: 내부 토큰/링크 기반으로 전환. +- #67 비밀번호 로그인/변경: 기본 로그인/변경 기능 반영. +- #69 `/login` → `/signin` 변경: 라우트 표준화. +- #70 비밀번호 정책 공통화: 가입/변경/초기화 화면에 동일 정책 적용. +- #71 가입 탭 작업: 패스워드 기반 가입 UX 보강. +- #79 백엔드 테스트 실패: 원인 분석 및 대응 기록. +- #154 devfront 복사 피드백: 복사 성공 알림/UX 개선. + +### A-2 OIDC/Consent/Ory 통합 +- #74 사용자 데이터 저장 전략 결정: IDP 종속성 제거를 위한 자체 식별자(Shadow) 전략 확정. +- #75 Hydra 도입 비교 분석: OIDC 엔진 전환 방향성 결정. +- #77 Kratos 도입/매직 링크 래퍼 설계: Ory 전환을 위한 인증 구조 설계 확정. +- #150 OIDC 규칙/리라이트 정비: Oathkeeper·Hydra 경로 구성 개선, 외부 IDP 기능 제거. +- #156/#158/#159/#160 Kratos self-service 매핑: 로그인/가입/복구/검증/오류 라우팅 안정화. +- #165 OIDC 흐름 완성 PR: consent 화면 추가, Oathkeeper 경로 수정, DevFront UX 개선 반영. +- #169 선택 스코프 지원: 중복 이슈로 종료(요구사항 정합성 정리). +- #175 Consent/Grants 저장소 결정: Metadata DB vs ClickHouse 비교 및 방향 확정. + +### A-3 Admin/Dev 포털 및 API/운영 +- #72 Admin 포털 API: RP 관리/Consent/Audit API 스펙 범위 정리. +- #153 Gateway 분리: 인프라/앱 스택 분리로 구성 명확화. +- #5 감사 로그 저장 검증: ClickHouse 저장 여부 및 재현 절차 확보. +- #4 E2E 로그인 테스트: 핵심 시나리오 테스트 기준 수립. +- #3 관리자 모드 PoC: 초기 관리자 기능 검증(외부 IDP 기반, 현 아키텍처 전환으로 재정의 필요). + +### A-4 문서/설계/정책 +- #9 SMS 링크 로그인 가이드: 인증 릴레이 구조 문서화. +- #14/#16/#18/#21 인증 플로우 문서: 교차 기기/OTP/링크 흐름을 문서로 고도화. +- #74/#75 아키텍처 결정 문서: Ory 전환 의사결정 근거 정리. + +## 부록 B. 테스트 체크리스트 상세 + +### B-1 공통 준비 +- 환경 구성: `compose.infra.yaml`, `compose.ory.yaml`, `docker-compose.yaml` 조합 기동 확인 +- 네트워크 분리: `ory-net`/`baron_net` 경계 준수 확인 +- 브라우저 URL vs 내부 URL 분리(Kratos/Hydra) 확인 + +### B-2 인증/세션 +- ID/Password 로그인 성공/실패/락/재시도 +- 링크 로그인(코드/링크) 승인 후 요청 기기에서 세션 발급 여부 +- QR 승인/거절/타임아웃 처리 +- 로그아웃 시 세션/토큰 무효화 + +### B-3 verify-only 교차 기기 +- Desktop 요청 → Mobile 승인 → Desktop Polling 세션 발급 +- Mobile 단말에 세션 생성되지 않는지 확인 +- 인증수단(SMS/Email) 표기 정확성 확인 + +### B-4 OIDC/Consent +- login_challenge 처리 및 자동 수락 흐름 +- consent 승인 시 redirect 동작 +- consent 거부 플로우(현 상태는 미완료로 기록) + +### B-5 보안/권한 +- Admin/Dev API 인증/인가 가드 정상 동작 +- Ory Admin 포트 외부 접근 차단 확인 + +### B-6 관측성/감사 +- `POST /api/v1/audit` 저장 여부 +- 실패/에러 로그 필드 누락 여부 + +### B-7 릴리즈 스모크 +- `/health` 및 Ory readiness 체크 +- UserFront 정적 리소스 로딩 확인 + +### B-8 수동 실행 기준 +- Backend: `go test ./...` (backend/) +- UserFront: `flutter test` (userfront/) +- AdminFront: `npm test` (adminfront/) +- DevFront: `npm test` (devfront/) + +## 부록 C. 인증 플로우 상태표 + +| 플로우 | 상태 | 근거/비고 | +| --- | --- | --- | +| ID/Password 로그인 | 완료 | `/api/v1/auth/password/login` 기반 정상 동작 | +| 링크 로그인(코드/링크) | 완료 | verify-only 적용 및 Polling 세션 발급 확정 | +| QR 로그인 | 완료 | init/poll/approve 구조로 교차 기기 인증 완료 | +| SMS OTP 로그인 | 부분 완료 | 발송/검증 흐름 존재, 세션 교환 고도화 필요 | +| Consent 승인 | 완료 | Consent 화면 및 backend accept 경로 구현 | +| Consent 거부 | 미완료 | 거부 UX/처리 경로 보강 필요 | +| OIDC Login Challenge | 완료(보완 필요) | 팝업 세션 자동 수락 미비로 개선 여지 | +| Same Browser SSO | 부분 완료 | 기본 플로우 구성, 자동 수락 이슈 잔존 | +| 활동/연동 내역 표시 | 부분 완료 | linked RP 노출 정확성 이슈 확인 필요 | + +## 부록 D. 용어/약어 정리 + +- SSO: Single Sign-On +- OIDC: OpenID Connect +- Consent: 권한 동의 +- verify-only: 승인만 기록하고 세션 발급은 요청 기기에서 수행하는 방식 +- Kratos: 사용자 인증/계정 관리(SoT) +- Hydra: OAuth2/OIDC 토큰 엔진 +- Keto: 권한/정책 엔진 +- Oathkeeper: 인증/인가 프록시 +- RP: Relying Party(외부 클라이언트 앱) +- login_challenge: OIDC 로그인 절차 식별자 +- consent_challenge: 권한 동의 절차 식별자 +- pendingRef: 링크/QR 로그인 대기 상태 키 +- sessionJwt: 로그인 성공 후 전달되는 세션 토큰(opaque/JWT 혼재 가능) + + +--- + +# 추가 상세 정리 (문서 기반) + +아래는 `docs/`에 기록된 내용을 바탕으로, **아키텍처/구조 설명을 제외**하고 운영/정책/플로우 관점에서 정리한 상세 내용입니다. + +--- + +## 30. API 설계 정책 상세 요약 + +본 프로젝트의 API는 일관된 규약을 유지하기 위해 다음 정책을 따릅니다. + +### 30.1 URL 및 네임스페이스 +- 기본 구조: `/api/{version}/{namespace}/{resource}[/{id}][/{action}]` +- 버전: `v1` 기준 +- 네임스페이스: `auth`, `user`, `admin`, `dev` + +### 30.2 명명 규칙 +- URL Path: kebab-case +- JSON 필드: camelCase +- Query 파라미터: camelCase 권장 + +### 30.3 HTTP 메서드 +- `GET`: 조회 +- `POST`: 생성/액션 +- `PUT`: 전체 수정 +- `PATCH`: 부분 수정 +- `DELETE`: 삭제 + +### 30.4 응답 형식 +목록 조회는 다음 구조를 표준으로 합니다. + +```json +{ + "items": [ + { "id": "1", "name": "Resource A" }, + { "id": "2", "name": "Resource B" } + ], + "limit": 50, + "offset": 0, + "total": 120 +} +``` + +### 30.5 에러 응답 +모든 에러는 다음 형식을 권장합니다. + +```json +{ + "error": "사람이 읽을 수 있는 메시지", + "code": "MACHINE_READABLE_CODE", + "details": { } +} +``` + +### 30.6 보안/헤더 +- `Authorization: Bearer ` +- `X-Tenant-ID`: 멀티 테넌시 식별 +- `X-Request-ID`: 요청 추적 + +### 30.7 핸들러 구조 원칙 +- 핸들러는 요청/응답 변환에 집중 +- 비즈니스 로직은 서비스/도메인 레이어로 분리 + +--- + +## 31. 인증/로그인 플로우 상세 요약 + +### 31.1 지원 로그인 방식 +| 방식 | Backend 엔드포인트 | 세션 토큰 반환 | 비고 | +|---|---|---|---| +| ID/Password | `POST /api/v1/auth/password/login` | `sessionJwt` | 기본 로그인 | +| Enchanted Link | `POST /api/v1/auth/enchanted-link/init` → `poll` | `sessionJwt` | 링크 기반 | +| Magic Link Verify | `POST /api/v1/auth/magic-link/verify` | `token` | verify-only 가능 | +| SMS 코드 | `POST /api/v1/auth/sms` → `POST /api/v1/auth/verify-sms` | `token` | 내부 토큰 기반 | +| QR 로그인 | `POST /api/v1/auth/qr/init` → `poll` | `sessionJwt` | 모바일 승인 | + +### 31.2 UserFront 연동 매핑 +- 링크/코드 기반 인증은 verify-only를 활용해 승인과 세션 발급 책임을 분리 +- QR 로그인은 승인(모바일)과 세션 발급(웹)을 분리 + +### 31.3 세션 공유 방식 +- 토큰 기반 공유가 중심 +- 쿠키 기반 공유는 일부 경로에서 부분 구현 + +--- + +## 32. 원격 링크 로그인 불일치 대응(verify-only 확장) + +### 32.1 문제 요약 +- 링크 클릭 기기에서 세션이 발급되는 문제 발생 +- 요청 기기와 승인 기기의 세션/이력이 혼재되는 문제 + +### 32.2 해결 방향 +- verify-only 적용 범위를 코드 기반 경로까지 확장 +- 승인 상태는 저장하되, 세션 발급은 요청 기기 Polling 시점에 수행 +- 인증수단 표기 개선(메타데이터 저장) + +### 32.3 영향 범위 +- Backend: 승인 처리 및 세션 발급 주체 변경 +- Front: verify-only 플래그 전달 확장 +- 문서: auth-flow 및 test-plan 업데이트 + +--- + +## 33. Ory 사용 가이드(운영 관점) + +### 33.1 실행 방법 +``` +# 인프라 + Ory Stack +docker compose -f compose.infra.yaml -f compose.ory.yaml up -d + +# 앱 스택 +docker compose -f docker-compose.yaml up -d +``` + +### 33.2 내부/외부 URL 분리 +- 내부 통신 URL과 브라우저 접근 URL을 분리해야 함 +- Admin 포트는 외부 노출 금지 + +### 33.3 Self-service UI 경로 +- 로그인, 가입, 복구, 검증, 오류 경로를 UserFront로 매핑 +- settings 경로는 임시 비활성 + +### 33.4 네트워크 경계 테스트 +- ory-net에서는 Admin 포트 접근 가능 +- baron_net에서는 접근 불가 + +--- + +## 34. Kratos 연동 작업 요약 + +### 34.1 주요 작업 +- Kratos SDK 도입 +- 로그인/회원가입 플로우 초기화 및 제출 API 구현 +- 세션 쿠키 전달(pass-through) +- 감사 로그 기록 강화 + +### 34.2 향후 과제 +- 복구/검증 플로우 추가 연동 +- 관리자 기능 확장 + +--- + +## 35. Consent 세션 생성 실험 기록 요약 + +### 35.1 실패 원인 +- consent app 미기동 시 consent 세션 생성 불가 +- distroless 이미지로 인해 내부 쉘 실행 불가 + +### 35.2 임시 해결 +- 임시 consent app을 통한 세션 생성 +- 최종 리다이렉트 CSRF 오류에도 consent 세션 생성 확인 + +--- + +# 추가 상세 정리 (종료 이슈 기반) + +아래는 종료된 이슈들의 핵심 목표와 달성 결과를 보다 상세히 정리한 내용입니다. 이 섹션은 추후 보고서 업데이트 시 더 확장될 수 있습니다. + +## 36. 인증/로그인 관련 종료 이슈 상세 + +- #21 QR/이메일 로그인 추가 + - 목표: 인증 수단 다변화 + - 결과: QR 승인, 링크 기반 인증 흐름 정리 완료 + +- #59 로그인 검증 URL 단순화 + - 목표: URL 가독성 및 설계 개선 + - 결과: 링크 검증 경로 구조 개선 및 플로우 정리 + +- #8 로그인 분기 규칙 업데이트 + - 목표: 앱 세션 유무에 따른 폴백 규칙 명확화 + - 결과: 로그인 라우팅 분기 정책 문서화 + +- #146 원격 로그인 불일치 대응(문서) + - 목표: 승인 기기에서 세션 발급되는 문제 해결 + - 결과: verify-only 확장 및 세션 발급 주체 정리 + +## 37. 비밀번호/계정 관련 종료 이슈 상세 + +- #66 비밀번호 초기화 플로우 재설계 + - 목표: 외부 의존 최소화 및 자체 토큰 기반 플로우 정리 + - 결과: reset 흐름 재설계 및 후속 리팩터 전략 수립 + +- #70 비밀번호 정책 공통화 + - 목표: 가입/변경/재설정 화면 정책 통일 + - 결과: 정책 로딩 공통화 및 폴백 기준 정의 + +- #65 비밀번호 변경 실패 이슈 + - 목표: 비밀번호 변경 실패 원인 대응 + - 결과: 문제 원인 분석 및 개선 방향 제시 + +## 38. UI/UX 및 라우팅 관련 종료 이슈 상세 + +- #156 Kratos selfservice 라우팅 정리 + - 목표: Kratos self-service 경로를 UserFront로 매핑 + - 결과: 로그인/가입/복구/검증/오류 경로 매핑 완료 + +- #158~160 Kratos 라우팅 보완 + - 목표: 세부 경로별 매핑 안정화 + - 결과: 각 경로별 라우팅 정리 완료 + +## 39. 운영/테스트 관련 종료 이슈 상세 + +- #153 Gateway 서비스 분리 + - 목표: 인프라 계층으로 분리 + - 결과: compose.infra.yaml 이동 완료 + +- #5 Audit Log 저장 확인 + - 목표: ClickHouse 적재 검증 + - 결과: 검증 기준 및 확인 절차 정리 + +- #4 E2E 로그인 흐름 테스트 + - 목표: 핵심 시나리오 테스트 기준 마련 + - 결과: 테스트 계획 및 체크리스트 문서화 + + +--- + +# 부록 B(확장): 테스트 체크리스트 상세 + +아래 내용은 테스트 계획 문서의 핵심 체크리스트를 **운영/검증 관점**에서 그대로 정리한 것입니다. + +## B-1. 목적 +- 인증/인가 핵심 플로우의 안정성과 회귀 방지 +- 멀티 서비스(Backend/Ory Stack/Front) 연동 품질 확보 +- 릴리즈 기준과 장애 분석 기준의 표준화 + +## B-2. 범위 +### 포함 +- Backend (Go Fiber) +- UserFront (Flutter Web/App) +- AdminFront / DevFront (React) +- Ory Stack (Kratos/Hydra/Keto/Oathkeeper) +- Gateway/네트워크 구성 (baron_net, ory-net, public_net) +- DB (PostgreSQL, ClickHouse, Redis) + +### 제외(별도 계획) +- 외부 IDP 벤더 장애 대응 시나리오 +- 프로덕션 데이터 복구(백업/DR) + +## B-3. 원칙 +- Shift-left: 개발 단계에서 최대한 조기 검증 +- 단계적 신뢰: Unit → Integration → E2E 순으로 신뢰도 상승 +- 환경 분리: 로컬/스테이징/프로덕션 구성 차이를 문서로 명시 +- 결정적 테스트: 시간/랜덤/외부 의존성 최소화 +- Idempotent: 반복 실행 시 동일 결과 보장 +- 보안 우선: 민감정보(PII/Token)는 테스트 로그에 노출 금지 +- 실패 우선 기록: 실패 로그/재현 절차를 우선 확보 + +## B-4. 테스트 레이어 및 목표 +### B-4.1 Unit Test +- Backend: 비즈니스 로직, 유효성 검증, Mapper/Adapter +- Frontend: 유틸/상태관리/컴포넌트 로직 +- 목표: 빠른 피드백 + +### B-4.2 Integration Test +- Backend + DB(Postgres/ClickHouse/Redis) +- Backend + Ory Admin API (Kratos/Hydra/Keto) +- 목표: 네트워크/스토리지 연동 검증 + +### B-4.3 Contract Test +- Backend ↔ Frontend API 스키마/응답 계약 검증 +- OIDC/OpenID Connect 표준 응답 형식 검증 + +### B-4.4 E2E Test +- 로그인 플로우(Password / Link / SMS / QR) +- Consent 플로우 (Hydra login/consent) +- 토큰 발급/재발급/로그아웃/세션 만료 + +### B-4.5 Smoke Test +- 배포 직후 필수 엔드포인트 헬스체크 +- `GET /health`, Ory readiness, UserFront 정적 리소스 + +### B-4.6 Regression / Non-functional +- 성능: 로그인/토큰 발급 지연, 대량 감사 로그 적재 +- 보안: 인증 우회, 권한 상승, 세션 고정 공격 +- 관측성: 핵심 로그/메트릭 누락 여부 + +## B-5. 환경 전략 +- 로컬: `make up-all` 또는 `docker compose -f compose.infra.yaml -f compose.ory.yaml -f docker-compose.yaml up -d` +- 스테이징: 프로덕션과 동일한 네트워크/도메인 구성 +- 프로덕션: 최소한의 smoke/관측성 점검 + +## B-6. 테스트 데이터 정책 +- 표준 시드 사용자/테넌트/클라이언트 세트 정의 +- PII 마스킹 규칙(이메일/전화번호/토큰) +- 재현용 고정 데이터와 랜덤 데이터 분리 +- 테스트 종료 후 클린업 규칙 정의 + +## B-7. 자동화 및 CI/CD 기준 +- 현재 상태: CI/CD 워크플로우 정의가 없음 +- 수동 실행 기준: + - Backend: `go test ./...` + - UserFront: `flutter test` + - AdminFront: `npm test` (Playwright) + - DevFront: `npm test` (Playwright) + +### B-7.1 수동 게이트 제안 +- PR/머지 전: Backend Unit + 해당 Front 테스트 +- 배포 전: Smoke + 핵심 E2E + +## B-8. 핵심 플로우 테스트 시나리오 +### 인증/세션 +- Password 로그인 성공/실패/락/재시도 +- Link 발송/검증/만료 +- SMS 코드 발송/검증/재시도 제한 +- QR 승인/거절/타임아웃 +- 로그아웃 시 세션/쿠키/토큰 무효화 + +### 원격 링크 로그인(verify-only) +- Desktop에서 링크 요청 → Mobile에서 링크 클릭(verifyOnly) → Desktop Poll로 세션 발급 +- Mobile 단말에 세션/로그인 이력 미생성 확인 +- 인증수단 표기(SMS/Email) 정확성 확인 +- 코드/링크 만료/재사용 시나리오 점검 + +### OIDC/Hydra +- Login Challenge 처리 +- Consent 승인/거절 +- Token/Refresh Token 발급 +- Redirect URI 검증 + +### 권한/정책(Keto) +- 권한 부여/회수 시 접근 제어 확인 +- 관리자/일반 사용자 분리 + +### 네트워크/프록시 +- `baron_net`와 `ory-net` 경계 준수 +- Frontend에서 Ory 내부 Admin 포트 접근 불가 + +## B-9. 관측성/장애 대응 테스트 +- 에러 로그 구조 확인 +- Audit Log 누락/중복 체크 +- 실패 시 재시도 정책 검증 + +## B-10. 책임 및 운영 프로세스 +- 각 영역별 오너 지정 +- 실패 시 triage 기준 정리 +- 테스트 케이스/기대 결과는 이슈/PR에 링크 + +--- + +# 부록 A(확장): 종료 이슈 상세 목록 + +아래는 종료 이슈를 기능 영역별로 요약한 목록입니다. 각 항목은 “목표 → 달성 내용” 중심으로 정리했습니다. + +## A-1. 인증/로그인 +- #21 QR/이메일 로그인 추가: QR 승인/폴링 및 링크 기반 인증 흐름 정리 +- #59 로그인 검증 URL 단순화: 링크 검증 경로 개선 및 라우팅 정리 +- #8 로그인 분기 규칙 업데이트: 앱 세션 유무에 따른 폴백 기준 정리 +- #16 인증 메커니즘 문서화: 링크 기반 인증의 상태 전환 정의 +- #14 SMS Enchanted Link 플로우 문서화: 상태/폴링 구조 정리 +- #18 플로우 2차 정리: 단계별 승인/발급 책임 정리 +- #17 VerifyMagicLink 구현 정리: 로직 정리 및 개선 포인트 정리 + +## A-2. 비밀번호/계정 +- #66 비밀번호 초기화 플로우 재설계: 외부 의존 축소 및 자체 토큰 기반 흐름 정리 +- #70 비밀번호 정책 공통화: 가입/변경/재설정 화면 검증 로직 통일 +- #65 비밀번호 변경 실패 이슈 대응: 실패 원인 분석 및 개선 방향 정리 +- #67 비밀번호 로그인/변경 기능 보강: 기능 확장 및 UX 개선 + +## A-3. Kratos/라우팅/오류 +- #156 Kratos selfservice 라우팅 정리: login/registration/recovery/verification/error 매핑 +- #158~160 세부 경로 매핑 보완: 각 경로 안정화 + +## A-4. 운영/테스트 +- #153 Gateway 서비스 분리: 인프라 구성 분리 +- #5 Audit Log 저장 확인: ClickHouse 적재 확인 기준 수립 +- #4 E2E 로그인 흐름 테스트: 테스트 계획 및 체크리스트 정리 +- #79 Backend 테스트 실패 분석: 테스트 환경 안정화 과제 도출 + +## A-5. DevFront/AdminFront +- #154 복사 버튼 피드백 UX: 토스트 피드백 추가 +- #72 Admin Portal API 목표 정의: RP/Audit API 범위 정리 +- #165 consent 화면 추가 및 OIDC 흐름 보완: UI-Backend 연동 완료 + + +--- + +# 추가 확장: 기능 동작 상세 및 운영 시나리오 + +## 40. 인증 플로우 상세 동작 기록 (서술형) + +### 40.1 비밀번호 로그인 상세 +- 사용자는 로그인 화면에서 이메일/휴대폰과 비밀번호를 입력합니다. +- 프론트는 입력값이 비어 있거나 형식이 맞지 않을 경우 즉시 입력 오류를 안내합니다. +- 검증을 통과하면 `POST /api/v1/auth/password/login`을 호출합니다. +- 백엔드는 인증 성공 시 세션 토큰을 반환하고, 실패 시 오류 메시지를 반환합니다. +- 프론트는 성공 시 토큰 저장 및 프로필 프리패치를 수행하고, 실패 시 오류 메시지를 출력합니다. + +**현재 코드 관점에서의 확인 사항** +- 입력 검증은 UI 레벨에서 처리되며, 서버 응답 오류는 Snackbar로 표시됩니다. +- 토큰이 JWT 형식인지 여부를 확인하여 로그에 기록합니다. + +### 40.2 링크 로그인(Enchanted/Code) 상세 +- 사용자는 로그인 링크 요청을 수행합니다. +- 프론트는 `POST /api/v1/auth/enchanted-link/init`을 호출하여 `pendingRef`를 받습니다. +- 이후 폴링으로 상태를 조회합니다. 폴링 주기는 일정 간격(기본 2초 내외)입니다. +- 사용자가 모바일에서 링크를 클릭하면 승인 요청이 서버로 전달됩니다. +- 승인 요청은 verify-only로 처리되어 승인 상태만 기록됩니다. +- 최종 세션 발급은 요청 기기의 폴링 응답 시점에서 진행됩니다. + +**운영 관점의 이점** +- 승인 기기에서 세션이 생성되지 않아 보안 이력 혼재를 방지합니다. +- 요청 기기 기준으로 세션이 생성되어 사용자 경험이 직관적입니다. + +### 40.3 QR 로그인 상세 +- 사용자는 QR 로그인 탭에서 QR 코드 발급을 요청합니다. +- 프론트는 QR 코드와 pendingRef를 수신합니다. +- 승인 기기에서 QR을 스캔하고 승인 요청을 수행합니다. +- 승인 결과는 서버에 저장되고, 요청 기기 폴링에서 세션이 발급됩니다. + +### 40.4 SMS OTP 로그인 상세 +- 사용자는 전화번호 입력 후 인증 코드 발송을 요청합니다. +- 서버는 코드를 생성하고 SMS 발송 요청을 수행합니다. +- 사용자는 수신한 코드를 입력해 검증합니다. +- 성공 시 세션 토큰 또는 내부 토큰이 반환됩니다. + +--- + +## 41. Consent 플로우 상세 동작 기록 + +### 41.1 consent 요청 단계 +- OIDC 클라이언트가 인증을 요청하면 login_challenge가 발생합니다. +- 로그인 성공 후 consent 화면으로 이동합니다. +- consent 화면은 UserFront에서 렌더링되며, 요청된 스코프를 표시합니다. + +### 41.2 consent 승인 단계 +- 사용자가 승인하면 consent 수락 요청이 Backend로 전달됩니다. +- Backend는 Hydra Admin API를 통해 승인 처리를 수행합니다. +- 승인 결과로 리다이렉트 URL을 받아 최종 이동합니다. + +### 41.3 consent 거부 단계 +- 거부 플로우는 UI/Backend 연동이 필요하며 현재는 별도 이슈로 관리됩니다. + +--- + +## 42. 운영 시나리오별 동작 정리 + +### 42.1 Same Browser SSO +- 사용자는 로그인 후 런처 화면에서 서비스로 이동합니다. +- 동일 브라우저에서 기존 세션을 재사용하여 로그인 없이 서비스 접근이 가능합니다. + +### 42.2 Cross-Device 인증 +- 요청 기기에서 링크/QR 요청 +- 승인 기기에서 verify-only 승인 +- 요청 기기에서 세션 발급 및 로그인 완료 + +### 42.3 Clean Login +- 인증된 세션이 없는 경우 로그인 화면으로 유도 +- 필요 시 비밀번호 로그인 또는 링크 로그인 선택 + +--- + +## 43. 데이터/메타데이터 처리 요약 + +- pendingRef 기반 상태 관리 +- 로그인 수단/플로우 메타데이터 기록 +- 인증 시점/승인 시점 분리 기록 + +--- + +# 추가 확장: 이슈별 상세 서술 + +## 44. 종료 이슈 상세 (서술형) + +### #156 Kratos selfservice UI 매핑 +- **목표:** Kratos self-service 리다이렉트가 UserFront로 연결되도록 라우팅 통일 +- **달성:** login/registration/recovery/verification/error 경로 모두 UserFront로 매핑 완료 +- **의의:** 사용자 경험을 통일하고 외부 UI 노출을 제거함 + +### #159 Recovery/Verification 매핑 +- **목표:** 복구/검증 플로우의 UserFront 대응 +- **달성:** `/recovery`와 `/verification` 경로를 UserFront 화면과 연결 + +### #160 Settings/Error 라우팅 보완 +- **목표:** 설정 화면 비활성 시 사용자 혼란 방지 +- **달성:** 설정 화면은 오류 경로로 리다이렉트하며 메시지 안내 제공 + +### #165 consent 화면 추가 +- **목표:** consent UI를 UserFront에 추가하고 OIDC 흐름 완성 +- **달성:** consent 화면 UI 구축 및 수락 흐름 연동 + +### #154 DevFront 복사 UX 개선 +- **목표:** 클라이언트 ID/Secret 복사 시 사용자 피드백 제공 +- **달성:** 토스트 메시지 및 복사 버튼 반응 추가 + +### #153 Gateway 분리 +- **목표:** gateway 서비스 정의를 인프라 compose로 이동 +- **달성:** 서비스 정의 분리 완료, 실행 순서 정리 + +### #70 비밀번호 정책 공통화 +- **목표:** 가입/변경/재설정 화면 정책 통일 +- **달성:** 정책 로딩 및 검증 로직 공통화 + +### #66 비밀번호 초기화 플로우 재설계 +- **목표:** 외부 의존 없이 자체 토큰 기반 재설정 흐름 구축 +- **달성:** initiate/verify/complete 단계로 분리 및 리팩터 전략 정리 + +### #21 QR/이메일 로그인 추가 +- **목표:** 인증 수단 다변화 +- **달성:** QR 승인 플로우 및 링크 기반 인증 도입 + +--- + +# 부록 C(확장): 인증 플로우 상태표 + +| 플로우 | 상태 | 비고 | +|---|---|---| +| 비밀번호 로그인 | 완료 | 기본 로그인 경로 | +| 링크 로그인(verify-only) | 완료 | 승인/세션 발급 분리 | +| QR 로그인 | 완료 | 승인 기기 세션 생성 없음 | +| SMS OTP | 부분 | 내부 토큰 기반, 보완 필요 | +| Consent 승인 | 완료 | 거부 플로우 미완료 | +| Consent 거부 | 미완료 | 후속 이슈 필요 | +| 쿠키 기반 세션 자동 수락 | 부분 | 팝업 로그인 개선 필요 | + + +--- + +# 부록 E: 인증/로그인 플로우 상세 발췌(정리본) + +아래 내용은 인증 플로우 문서의 핵심 내용을 **벤더 명칭 없이** 정리한 것입니다. + +## E-1. 지원 로그인 방식 요약 + +| 방식 | Backend 엔드포인트 | 세션 토큰 반환 | 비고 | +|---|---|---|---| +| ID/Password | `POST /api/v1/auth/password/login` | `sessionJwt` | 기본 로그인 | +| Enchanted Link | `POST /api/v1/auth/enchanted-link/init` → `poll` | `sessionJwt` | 링크 기반 | +| Magic Link Verify | `POST /api/v1/auth/magic-link/verify` | `token` | verify-only 가능 | +| SMS 코드 | `POST /api/v1/auth/sms` → `POST /api/v1/auth/verify-sms` | `token` | 내부 토큰 기반 | +| QR 로그인 | `POST /api/v1/auth/qr/init` → `poll` | `sessionJwt` | 모바일 승인 | + +## E-2. UserFront 연동 API 매핑 + +### E-2.1 ID/Password +1. `POST /api/v1/auth/password/login` +2. 응답의 `sessionJwt` 사용 + +### E-2.2 Enchanted Link +1. `POST /api/v1/auth/enchanted-link/init` → `pendingRef` 수신 +2. `POST /api/v1/auth/enchanted-link/poll` 폴링 +3. 승인 기기에서는 verify-only 처리 +4. Polling 응답에서 `sessionJwt` 수신 + +### E-2.3 QR 로그인 +1. `POST /api/v1/auth/qr/init` → `qrCode`, `pendingRef` +2. 웹은 `POST /api/v1/auth/qr/poll` +3. 모바일은 `POST /api/v1/auth/qr/approve` +4. Polling 응답에서 `sessionJwt` 수신 + +### E-2.4 SMS 코드 로그인 +1. `POST /api/v1/auth/sms`로 코드 발송 +2. `POST /api/v1/auth/verify-sms`로 코드 검증 +3. 내부 토큰 반환(향후 세션 교환 보강 필요) + +## E-3. 세션 공유 방식 + +### E-3.1 쿠키 기반 공유 (권장 방향) +- session 토큰이 쿠키로 전달되면 게이트웨이 검증 가능 + +### E-3.2 토큰 기반 공유 (현재 동작) +- `sessionJwt`를 로컬에 저장 후 `Authorization` 헤더로 전달 +- OIDC 경유 경로에서는 쿠키 기반 연동 필요 + +## E-4. 링크 로그인 ↔ QR 로그인 공유/분리 로직 + +### E-4.1 공유 로직 +- 코드 기반 검증 로직 공유 +- 공통 flow 상태 관리 + +### E-4.2 분리 로직 +- pendingRef 네임스페이스 분리 +- 승인 엔드포인트 분리 +- 세션 발급 주체 분리 + +--- + +# 부록 F: API 설계 정책 발췌(요약본) + +## F-1. URL 구조 +`/api/{version}/{namespace}/{resource}` + +## F-2. 명명 규칙 +- URL: kebab-case +- JSON: camelCase + +## F-3. 응답 구조 +목록 응답은 `items`, `limit`, `offset`, `total` 포함 + +## F-4. 에러 응답 +- `error`, `code`, `details` 형태 + +## F-5. 보안/헤더 +- `Authorization: Bearer ` +- `X-Tenant-ID` 필수 +- `X-Request-ID` 추적 + +--- + +# 부록 G: 운영 가이드 발췌(요약본) + +## G-1. 실행 방법 +``` +docker compose -f compose.infra.yaml -f compose.ory.yaml up -d +docker compose -f docker-compose.yaml up -d +``` + +## G-2. 내부/외부 URL 분리 +- 내부 통신 URL은 컨테이너 네트워크 기준 +- 브라우저 접근 URL은 외부 도메인 기준 + +## G-3. Admin 포트 정책 +- Admin 포트는 내부 네트워크 전용 +- Frontend는 Backend API를 통해서만 IDP 접근 + +## G-4. Self-service UI 경로 +- login, registration, recovery, verification, error 경로 매핑 +- settings는 임시 비활성 + +--- + +# 부록 H: Consent 실험 기록 요약 + +## H-1. 실험 결과 +- consent app이 없으면 세션 생성 불가 +- 임시 consent app으로 세션 생성 가능 +- CSRF 오류는 최종 리다이렉트에서 발생 가능 + +--- + + +--- + +# 부록 I: 종료 이슈 상세 설명(확장) + +아래 목록은 종료된 이슈들을 가능한 범위에서 **개발 성과 중심으로 재서술**한 것입니다. 각 항목은 “목표/달성/잔여” 기준으로 정리했습니다. + +## I-1. 초기 기능 및 플로우 + +- #1 대시보드 구현: 세션 리스트 + - 목표: 로그인 후 사용자 세션 목록을 보여주는 기본 대시보드 구현 + - 달성: 세션 리스트 UI 및 빈/에러 상태 처리 완료 + - 잔여: 실제 세션 API 연동 고도화 + +- #2 통합 런처 UI 구현 + - 목표: 로그인 이후 접근 가능한 런처 화면 제공 + - 달성: 기본 레이아웃과 서비스 카드/아이콘 렌더링 구현 + - 잔여: 실 데이터 연동 및 권한 기반 필터링 + +- #3 관리자 모드 구현(초기 계획) + - 목표: 관리자 기능 진입 경로와 기본 액션 정의 + - 달성: 관리자 모드 개념 정리 및 향후 API 범위 정의 + - 잔여: 실 기능 연동 + +- #4 E2E 로그인 흐름 테스트 + - 목표: 핵심 로그인 플로우 검증 기준 마련 + - 달성: 테스트 계획과 체크리스트 정리 + - 잔여: 자동화/CI 연동 + +- #5 Audit Log 저장 확인 + - 목표: 감사 로그 적재 확인 + - 달성: ClickHouse 적재 확인 기준 수립 + - 잔여: 운영 환경 기반 모니터링 + +## I-2. 링크/코드/QR 인증 관련 + +- #9 링크 기반 인증 가이드 + - 목표: 링크 기반 인증 플로우 문서화 + - 달성: 상태/폴링/승인 흐름 정리 + +- #14 SMS 링크 로그인 플로우 문서화 + - 목표: 링크 기반 인증 프로세스 단계 정의 + - 달성: 플로우 문서화 완료 + +- #16 인증 메커니즘 문서화 + - 목표: 링크 인증 상태 전환 정리 + - 달성: 단계별 흐름 정리 + +- #18 SMS 링크 로그인 플로우 2차 시도 + - 목표: 승인/세션 발급 책임 분리 + - 달성: 2차 설계 정리 + +- #21 QR/이메일 로그인 추가 + - 목표: 인증 수단 다변화 + - 달성: QR 승인 및 링크 기반 인증 구현 + +## I-3. SMS OTP 인증 + +- #11 SMS 인증 기능 구현(Frontend) + - 목표: SMS 인증 UI 및 서비스 연동 + - 달성: 발송/검증 UI 및 서비스 호출 구현 + +- #12 SMS OTP 인증 플로우 문서화 + - 목표: 인증 단계별 시나리오 문서화 + - 달성: 단계별 시나리오 정리 + +- #13 SMS 인증 기능 구현(Backend/Frontend 연계) + - 목표: SMS 발송/검증 API 구현 + - 달성: 인증 코드 발급 및 검증 처리 + +## I-4. 비밀번호 및 계정 + +- #64 마이페이지 → 회원 내용 수정 + - 목표: 프로필 수정 UI 보강 + - 달성: 프로필 수정 기능 반영 + +- #65 비밀번호 변경 페이지 오류 + - 목표: 변경이 반영되지 않는 문제 해결 + - 달성: 원인 분석 및 개선 포인트 도출 + +- #66 비밀번호 초기화 플로우 재설계 + - 목표: 외부 의존 없이 자체 토큰 기반 흐름 정리 + - 달성: init/verify/complete 단계 설계 + +- #67 비밀번호 로그인/변경 기능 추가 + - 목표: 비밀번호 기반 로그인 및 변경 지원 + - 달성: UI/Backend 연동 완료 + +- #70 비밀번호 정책 공통화 + - 목표: 정책 로딩/검증 로직 공통화 + - 달성: 가입/변경/재설정 공통화 완료 + +- #71 비밀번호 탭/가입 플로우 개선 + - 목표: 비밀번호 기반 가입 UX 개선 + - 달성: 관련 플로우 개선 + +## I-5. 라우팅/오류 처리 + +- #69 /login → /signin 변경 + - 목표: 경로 통일 및 사용자 혼란 최소화 + - 달성: 경로 변경 및 리다이렉트 적용 + +- #156 Kratos selfservice UI 매핑 + - 목표: self-service 경로를 UserFront로 매핑 + - 달성: login/registration/recovery/verification/error 매핑 완료 + +- #158 로그인/회원가입 라우팅 + - 목표: 로그인/가입 경로 매핑 + - 달성: 정상 매핑 + +- #159 복구/검증 라우팅 + - 목표: 복구/검증 경로 매핑 + - 달성: 정상 매핑 + +- #160 설정/오류 라우팅 + - 목표: 설정 비활성 안내 및 오류 화면 통일 + - 달성: 오류 화면 연동 완료 + +## I-6. Ory 전환 관련 + +- #74 사용자 데이터 저장 전략 결정 + - 목표: 사용자 식별자/매핑 전략 결정 + - 달성: Shadow 계정 전략 정리 + +- #75 Ory Hydra 도입 검토 + - 목표: OIDC 엔진 전환 타당성 검토 + - 달성: 비교 분석 및 전환 방향 정리 + +- #77 Kratos 도입 + Magic Link 래퍼 전환 계획 + - 목표: Kratos 도입 전략 정리 + - 달성: 도입 플로우 및 과제 정리 + +## I-7. Dev/Admin 포털 + +- #72 Admin Portal 지원 API 정의 + - 목표: 관리자 포털 지원 API 범위 정의 + - 달성: RP 관리/감사 로그 API 범위 정리 + +- #154 DevFront 복사 UX 개선 + - 목표: 복사 피드백 강화 + - 달성: 토스트/피드백 추가 + +- #165 consent 화면 추가 + - 목표: consent UI와 OIDC 흐름 완성 + - 달성: UI-Backend 연동 완료 + +## I-8. 운영/테스트 + +- #79 Backend 테스트 실패 분석 + - 목표: 테스트 실패 원인 파악 + - 달성: 원인 분석 및 개선 필요사항 정리 + +- #153 Gateway 서비스 분리 + - 목표: 인프라 구성 분리 + - 달성: compose 재구성 완료 + + +--- + +# 부록 J: 상세 테스트 케이스(확장) + +아래는 핵심 플로우에 대한 **수동 테스트 체크리스트**를 상세화한 예시입니다. 각 항목은 운영 전 점검 시 그대로 사용할 수 있습니다. + +## J-1. 비밀번호 로그인 +1. 유효한 이메일/비밀번호 입력 → 로그인 성공 +2. 유효하지 않은 비밀번호 입력 → 오류 메시지 표시 +3. 빈 입력값 제출 → 클라이언트 단 입력 검증 실패 +4. 로그인 중 중복 클릭 → 로딩 상태 유지 및 중복 요청 방지 +5. 로그인 성공 후 프로필 데이터 프리패치 성공 여부 확인 + +## J-2. 링크 로그인(교차 기기) +1. PC에서 링크 요청 → pendingRef 수신 +2. PC에서 polling 시작 → 상태 pending 유지 확인 +3. 모바일에서 링크 클릭 → verify-only 승인 +4. 모바일에서 세션 발급이 발생하지 않는지 확인 +5. PC polling 응답에서 sessionJwt 수신 +6. PC에서 로그인 완료 후 대시보드 이동 +7. 만료 링크 클릭 시 적절한 오류 표시 +8. 동일 링크 재사용 시 실패 처리 확인 + +## J-3. QR 로그인 +1. PC에서 QR 코드 발급 → QR 표시 +2. 모바일에서 QR 스캔 → 승인 요청 +3. PC polling에서 승인 확인 +4. PC에서 세션 발급 및 로그인 완료 +5. 모바일에서 세션 생성이 없는지 확인 +6. QR 만료 시 타임아웃 처리 확인 + +## J-4. SMS OTP 인증 +1. 전화번호 입력 후 코드 발송 요청 +2. SMS 수신 확인 +3. 올바른 코드 입력 → 인증 성공 +4. 잘못된 코드 입력 → 오류 메시지 표시 +5. 재시도 제한 및 TTL 확인 + +## J-5. Consent 플로우 +1. OIDC 요청 발생 시 consent 화면 이동 +2. 권한 목록 표시 확인 +3. 동의 수락 → redirect 정상 동작 +4. 거부(현재 미완료) → 임시 안내 처리 확인 + +## J-6. 오류 화면 +1. 설정 경로 접근 시 error 화면 이동 +2. 오류 코드 화이트리스트 적용 여부 확인 +3. 사용자 메시지 노출 범위 확인 + +## J-7. 세션 관리 +1. 로그인 후 브라우저 새로고침 → 세션 유지 +2. 세션 만료 후 접근 → 로그인 화면 유도 +3. 로그아웃 → 세션 제거 확인 + +--- + +# 부록 K: 운영 점검 체크리스트(확장) + +## K-1. 배포 전 점검 +- 환경 변수 설정 확인 +- 내부/외부 URL 분리 확인 +- Admin 포트 외부 노출 여부 확인 +- 헬스체크 응답 확인 + +## K-2. 배포 후 점검(Smoke) +- 로그인 화면 접근 가능 여부 +- 기본 로그인 플로우 성공 여부 +- OIDC 로그인 요청 처리 가능 여부 +- 감사 로그 적재 여부 + +## K-3. 장애 발생 시 점검 순서 +1. 서비스 헬스체크 확인 +2. 인증 플로우 로그 확인 +3. Ory readiness 확인 +4. Redis/DB 연결 상태 확인 +5. 네트워크 경계 문제 확인 + + +--- + +# 부록 L: API 목록(요약) + +본 목록은 현재 구현 및 문서화된 API의 **기능적 범위**를 요약한 것입니다. (상세 스펙은 OpenAPI 문서 참조) + +## L-1. Auth 네임스페이스 +- `POST /api/v1/auth/password/login` : 비밀번호 로그인 +- `POST /api/v1/auth/enchanted-link/init` : 링크 로그인 요청 +- `POST /api/v1/auth/enchanted-link/poll` : 링크 로그인 폴링 +- `POST /api/v1/auth/magic-link/verify` : 링크 검증(verify-only 가능) +- `POST /api/v1/auth/login/code/verify` : 코드 검증 +- `POST /api/v1/auth/login/code/verify-short` : 단축 코드 검증 +- `POST /api/v1/auth/qr/init` : QR 로그인 요청 +- `POST /api/v1/auth/qr/poll` : QR 로그인 폴링 +- `POST /api/v1/auth/qr/approve` : QR 승인 +- `POST /api/v1/auth/sms` : SMS 코드 발송 +- `POST /api/v1/auth/verify-sms` : SMS 코드 검증 +- `GET /api/v1/auth/consent` : consent 요청 조회 +- `POST /api/v1/auth/consent/accept` : consent 수락 +- `POST /api/v1/auth/oidc/login/accept` : OIDC 로그인 수락 + +## L-2. User 네임스페이스 +- `GET /api/v1/user/me` : 사용자 프로필 조회 +- `PUT /api/v1/user/me` : 프로필 업데이트 + +## L-3. Dev 네임스페이스 +- `GET /api/v1/dev/clients` : 클라이언트 목록 +- `GET /api/v1/dev/clients/{id}` : 클라이언트 상세 +- `PATCH /api/v1/dev/clients/{id}/status` : 상태 변경 +- `POST /api/v1/dev/clients` : 클라이언트 생성 +- `PUT /api/v1/dev/clients/{id}` : 클라이언트 수정 +- `DELETE /api/v1/dev/clients/{id}` : 클라이언트 삭제 +- `GET /api/v1/dev/consents` : consent 세션 조회 +- `DELETE /api/v1/dev/consents` : consent 철회 + +## L-4. Audit 네임스페이스 +- `POST /api/v1/audit` : 감사 로그 적재 + +--- + +# 부록 M: 문서별 핵심 포인트 정리(확장) + +- `API_DESIGN_POLICY.md` + - URL 네임스페이스, 응답 구조, 에러 정책, 헤더 규칙 정리 + +- `auth-flow.md` + - 로그인 방식별 플로우 및 세션 공유 방식 정리 + - verify-only 적용 범위 및 승인/세션 발급 책임 분리 + +- `issue-146-remote-login.md` + - 원격 링크 로그인 불일치 문제 원인 및 개선 사항 기록 + +- `kratos-integration-report.md` + - Kratos SDK 연동 및 로그인/가입 플로우 핸들러 구현 내용 정리 + +- `kratos-todo-list.md` + - 추가 기능 로드맵(MFA, Passkey, 세션 관리 등) 정리 + +- `ory-usage.md` + - 실행 방법, 내부/외부 URL 분리, Admin 포트 정책 + +- `compose-ory.md` + - 서비스 실행 순서 및 헬스체크 기준 정리 + +- `test-plan.md` + - 테스트 원칙, 범위, 레이어, 시나리오 정리 + +- `hydra-rp-consent-try.md` / `hydra-rp-dummy.md` + - consent 세션 생성 실험 및 운영 제약 기록 + +- `complete-password-reset-refactor.md` + - 비밀번호 재설정 리팩터 전략 정리 + +- `initial_PRD.md` + - 초기 요구사항 및 범위 정의(벤더 상세 제외) + + +--- + +# 부록 N: 요구사항 대비 달성 매트릭스(확장) + +아래 표는 초기 요구사항을 기능 단위로 분해하여 **현재 달성 수준**을 정리한 것입니다. + +| 기능 항목 | 목표 | 현재 상태 | 비고 | +|---|---|---|---| +| 비밀번호 로그인 | 기본 로그인 수단 제공 | 완료 | UI/Backend 연동 완료 | +| 링크 로그인 | 비밀번호 없는 로그인 제공 | 완료 | verify-only 적용 완료 | +| QR 로그인 | 교차 기기 인증 제공 | 완료 | 승인/세션 발급 분리 | +| SMS OTP 로그인 | 인증 코드 기반 로그인 제공 | 부분 | 토큰 처리 보완 필요 | +| Consent 승인 | OIDC 동의 수락 | 완료 | 거부 플로우 미완료 | +| Consent 거부 | 동의 거부 처리 | 미완료 | 후속 이슈 필요 | +| 세션 리스트 | 대시보드에서 세션 확인 | 완료 | API 연동 고도화 필요 | +| 프로필 수정 | 내 정보 수정 | 완료 | 필드 확장 필요 | +| 런처 화면 | 로그인 후 서비스 이동 | 완료 | 실데이터 연동 필요 | +| 감사 로그 | 이벤트 추적 | 완료 | 통계/분석 확장 필요 | +| Admin 포털 | 관리자 기능 제공 | 부분 | 실 API 연동 필요 | +| Dev 포털 | RP 관리 지원 | 부분 | 통계/검색 고도화 필요 | +| 테스트 자동화 | 회귀 테스트 자동화 | 미완료 | CI 도입 필요 | + +--- + +# 부록 O: 주요 문제/해결 기록(확장) + +## O-1. 원격 링크 로그인 세션 불일치 +- 문제: 승인 기기에서 세션 발급이 발생하여 로그/이력이 혼재 +- 해결: verify-only 적용 범위 확장, 요청 기기에서만 세션 발급 + +## O-2. consent 세션 생성 실패 +- 문제: consent app 부재로 세션 생성 불가 +- 해결: 임시 consent app을 통한 세션 생성 검증 + +## O-3. 비밀번호 변경 실패 +- 문제: 비밀번호 변경이 반영되지 않는 오류 +- 해결: 로직 점검 및 개선 방향 정리 + +--- + +# 부록 P: 상세 플로우 단계 기록(확장) + +아래는 각 플로우에 대해 **단계별 입력/출력/상태 변화**를 서술한 것입니다. + +## P-1. 비밀번호 로그인 +1. 사용자 입력: 로그인 ID + 비밀번호 +2. 요청: `POST /api/v1/auth/password/login` +3. 응답: `sessionJwt` +4. 후속: 토큰 저장, 프로필 프리패치 + +## P-2. 링크 로그인 +1. 요청: `POST /api/v1/auth/enchanted-link/init` +2. 응답: `pendingRef` +3. 폴링: `POST /api/v1/auth/enchanted-link/poll` +4. 승인: verify-only 처리 +5. 세션 발급: polling 응답에서 `sessionJwt` + +## P-3. QR 로그인 +1. 요청: `POST /api/v1/auth/qr/init` +2. 응답: `qrCode`, `pendingRef` +3. 승인: `POST /api/v1/auth/qr/approve` +4. 세션 발급: polling 응답에서 `sessionJwt` + +## P-4. SMS OTP +1. 요청: `POST /api/v1/auth/sms` +2. 응답: 발송 성공 +3. 검증: `POST /api/v1/auth/verify-sms` +4. 응답: `token` + +## P-5. Consent 승인 +1. 조회: `GET /api/v1/auth/consent?consent_challenge=...` +2. 수락: `POST /api/v1/auth/consent/accept` +3. 응답: `redirectTo` + + +--- + +# 부록 Q: 초기 요구사항 및 목표 상세(벤더 제외) + +아래 내용은 초기 요구사항 문서의 핵심을 **외부 IDP 명칭 없이** 재정리한 것입니다. + +## Q-1. 개요 +Baron SSO는 사용자 중심의 인증 허브이자 서비스 런처를 목표로 합니다. 브랜드 경험을 일관되게 제공하면서도, 표준 기반의 인증/인가 흐름을 지원하는 것을 목적으로 합니다. + +## Q-2. 목표 +- Private IDP Hub: 사용자가 자신의 계정과 로그인 세션을 한곳에서 관리 +- Seamless Auth: 비밀번호 없는 간편 로그인 제공 +- White-labeling: 외부 인증 UI를 노출하지 않고 자체 UI로 인증 흐름 완결 +- Audit & Security: 인증 이벤트를 감사 로그로 기록 +- Unified Launcher: 인증 후 접근 가능한 서비스 목록 제공 + +## Q-3. 주요 기능 +### Q-3.1 로그인 및 인증 +- 이메일/비밀번호 로그인 +- 전화번호 기반 링크 로그인 +- 교차 기기 인증(링크/QR) + +### Q-3.2 대시보드 +- 내 계정 정보 +- 활성 세션 리스트 + +### Q-3.3 통합 런처 +- 로그인 승인 후 진입 +- 권한 기반 서비스 노출 + +### Q-3.4 감사 로그 +- 인증 이벤트 기록 +- 운영 추적성 확보 + +### Q-3.5 관리자 모드 +- 사용자/클라이언트 관리 기능의 확장 가능성 + +## Q-4. 사용자 시나리오 +1. 사용자가 Baron SSO 웹앱에 접속 +2. 이메일 로그인 또는 링크 로그인 수행 +3. 로그인 성공 후 대시보드/런처 진입 +4. 런처에서 연결 서비스로 이동 + +--- + +# 부록 R: API 설계 정책(전문) + +## R-1. 개요 +본 문서는 Baron SSO 시스템의 백엔드 API 설계 원칙과 규약을 정의합니다. 모든 API 개발은 이 문서를 따름으로써 시스템의 일관성, 가독성, 유지보수성을 확보해야 합니다. + +## R-2. URL 구조 +`[GET|POST|...] /api/{version}/{namespace}/{resource}[/{id}][/{action}]` + +- Version: `v1`, `v2` +- Namespace: `auth`, `user`, `admin`, `dev` +- Resource: 복수형 명사 사용 + +## R-3. 명명 규칙 +- URL Path: kebab-case +- Query Parameters: camelCase 권장 +- JSON Fields: camelCase + +## R-4. HTTP 메서드 규칙 +- GET: 조회 +- POST: 생성/액션 +- PUT: 전체 수정 +- PATCH: 부분 수정 +- DELETE: 삭제 + +## R-5. 요청/응답 형식 +### R-5.1 목록 응답 +``` +{ + "items": [ ... ], + "limit": 50, + "offset": 0, + "total": 120 +} +``` + +### R-5.2 단건 응답 +``` +{ + "id": "1", + "name": "Resource A", + "status": "active" +} +``` + +### R-5.3 에러 응답 +``` +{ + "error": "사람이 읽을 수 있는 메시지", + "code": "MACHINE_READABLE_CODE", + "details": { } +} +``` + +## R-6. 헤더 및 보안 +- Authorization: Bearer 토큰 +- X-Tenant-ID: 테넌트 식별 +- X-Request-ID: 추적성 + +## R-7. 개발 가이드라인 +- DTO 분리 +- 핸들러는 서비스 호출에 집중 +- 로깅은 미들웨어 중심 + +--- + +# 부록 S: 인증 플로우(전문 요약) + +## S-1. 지원 로그인 방식 +- ID/Password +- Enchanted Link +- Magic Link Verify +- SMS 코드 +- QR 로그인 + +## S-2. 세션 공유 방식 +- 쿠키 기반 공유(권장 방향) +- 토큰 기반 공유(현재 동작) + +## S-3. 링크/QR 플로우 공통/분리 +- 공통: 코드 검증 로직, 상태 저장 +- 분리: pendingRef 네임스페이스, 승인 엔드포인트, 세션 발급 주체 + +--- + +# 부록 T: Ory 운영 가이드(전문 요약) + +## T-1. 내부/외부 URL 분리 +- 내부 통신 URL: 컨테이너 네트워크 기준 +- 외부 URL: 브라우저 접근 기준 + +## T-2. Admin 포트 정책 +- Admin 포트는 외부 노출 금지 + +## T-3. Self-service UI 경로 +- login, registration, recovery, verification, error 경로 +- settings 경로는 임시 비활성 + +## T-4. 네트워크 점검 +- ory-net: Admin 포트 접근 가능 +- baron_net: Admin 포트 접근 불가 + + +--- + +# 부록 U: UserFront 화면별 구현 현황(상세) + +## U-1. LoginScreen +- 탭 구조: 비밀번호 / 로그인 링크 / QR 로그인 +- 상태 관리: 로딩 상태, 오류 메시지, 폴링 타이머 +- 성공 처리: 토큰 저장, 프로필 로드, 리다이렉트 +- 후속 개선: 팝업 로그인 자동 수락, 쿠키 기반 세션 연동 강화 + +## U-2. SignupScreen +- 회원가입 입력 폼 및 검증 로직 +- 비밀번호 정책 기반 힌트 표시 +- 가입 성공 시 안내 및 후속 이동 + +## U-3. ForgotPasswordScreen +- 비밀번호 재설정 요청 폼 +- 이메일/전화번호 입력 검증 +- 요청 성공/실패 메시지 처리 + +## U-4. ResetPasswordScreen +- 비밀번호 정책 기반 입력 검증 +- 재설정 성공/실패 안내 + +## U-5. ConsentScreen +- 권한 요청 정보 표시 +- 동의 수락 버튼 및 리다이렉트 +- 거부 플로우는 후속 과제로 남음 + +## U-6. ErrorScreen +- 오류 코드 기반 메시지 처리 +- 사용자 노출 메시지 최소화 + +## U-7. DashboardScreen +- 세션 리스트 표시 +- 빈/오류 상태 처리 +- 사용자 활동 개요 표시 + +## U-8. ProfilePage +- 기본 프로필 정보 표시/수정 +- 수정 성공/실패 안내 + + +--- + +# 부록 V: Backend 핸들러/서비스 구현 현황(요약) + +## V-1. AuthHandler 주요 역할 +- 로그인/회원가입 플로우 처리 +- 링크/코드/QR 인증 처리 +- consent 조회/수락 처리 +- OIDC login_challenge 수락 처리 +- 비밀번호 재설정 로직(리팩터 전략 포함) + +## V-2. DevHandler 주요 역할 +- 클라이언트 목록/상세/생성/수정/삭제 +- 상태 변경 및 secret 관리 +- consent 세션 조회/철회 + +## V-3. AuditHandler 주요 역할 +- 감사 로그 적재 +- 메타데이터 보강 처리 + +## V-4. Service 계층 +- Ory Admin API 연동 서비스 +- Redis 기반 상태 관리 +- 외부 메시지 발송 서비스(SMS 등) + +--- + +# 부록 W: DevFront/AdminFront 화면 목록(요약) + +## W-1. DevFront +- ClientsPage: 클라이언트 목록 +- ClientDetailsPage: 클라이언트 상세/엔드포인트 +- ClientGeneralPage: 클라이언트 설정 +- ClientConsentsPage: 사용자 권한 목록 + +## W-2. AdminFront +- 클라이언트 목록/상세/설정 화면 +- consent/사용자 관리 화면 +- 감사 로그 조회 화면 + + +--- + +# 부록 X: 운영 시나리오 및 점검 기록 양식(확장) + +## X-1. 운영 시나리오별 점검 + +### X-1.1 로그인 장애 발생 시 +1. UserFront 접속 가능 여부 확인 +2. Backend `/health` 확인 +3. Ory readiness 확인 +4. Redis/DB 연결 상태 확인 +5. 최근 배포 변경점 확인 + +### X-1.2 consent 오류 발생 시 +1. consent UI 경로 접근 가능 여부 확인 +2. login_challenge 수신 여부 확인 +3. consent_challenge 생성 여부 확인 +4. Admin API 오류 여부 확인 + +### X-1.3 세션 공유 문제 발생 시 +1. 토큰 기반 세션인지 쿠키 기반인지 확인 +2. 동일 Origin 여부 확인 +3. `Authorization` 헤더 전달 여부 확인 +4. 쿠키의 SameSite/Domain 설정 확인 + +## X-2. 운영 점검 기록 양식(예시) + +- 점검 일시: +- 점검자: +- 점검 대상 환경: +- 점검 항목: + - 로그인 플로우 정상 동작 여부 + - 링크/QR 승인 동작 여부 + - consent 승인/거부 동작 여부 + - 감사 로그 적재 여부 + - 헬스체크 응답 여부 +- 발견된 이슈: +- 조치 내용: +- 재발 방지 계획: + +--- + +# 부록 Y: 보안/오류 정책 상세(확장) + +## Y-1. 오류 노출 정책 +- 사용자에게는 최소한의 오류 메시지만 제공 +- 내부 스택/세부 오류는 서버 로그에만 기록 +- 오류 코드 화이트리스트 기반 사용자 메시지 제공(정책 확정 필요) + +## Y-2. 보안 관련 체크포인트 +- Admin 포트 외부 노출 금지 +- 민감 토큰/PII 로그 미노출 +- 세션 만료 및 재발급 정책 적용 +- 동일 기기/동일 브라우저 세션 재사용 정책 명확화 + + +--- + +# 부록 Z: 개발 회고 및 교훈(확장) + +## Z-1. 교차 기기 인증에서의 핵심 교훈 +- 승인 기기와 요청 기기의 세션 발급 책임을 명확히 구분해야 함 +- verify-only 적용 범위가 좁으면 이력이 혼재됨 +- 승인/요청 분리 설계는 사용자 경험과 운영 안정성에 모두 긍정적 + +## Z-2. Consent 플로우에서의 교훈 +- consent UI는 기술적으로 필수 요소이며, 단순히 Admin API 호출만으로는 대체 불가 +- 테스트 환경에서 consent app 가용성 확보가 중요 + +## Z-3. 비밀번호 재설정에서의 교훈 +- 외부 의존성은 장애 시 곧바로 사용자 불편으로 이어짐 +- 재설정 흐름은 자체 토큰 기반으로 제어하는 것이 안정적 + +## Z-4. 운영/테스트 측면 교훈 +- 테스트 계획을 문서화해도 자동화가 없으면 회귀 위험이 높음 +- 스모크 테스트는 최소한의 안전망으로 필수 + + +--- + +# 부록 AA: 개발 일정 타임라인(서술형) + +## AA-1. 초기 PoC 단계 +- 로그인/링크 인증의 기본 흐름을 정의하고, 상태 저장 및 폴링 방식의 검증을 수행했습니다. +- SMS 기반 인증 코드 발송 및 검증 기능을 구현하여 기본 인증 경로를 확보했습니다. +- 로그인 성공 시 세션 토큰을 발급하고, 프론트에서 처리하는 기본 흐름을 구현했습니다. + +## AA-2. 기능 확장 단계 +- QR 로그인과 링크 로그인 흐름을 병렬로 확장하여 교차 기기 인증을 실현했습니다. +- 로그인 경로(`/login`, `/signin`)의 일관성을 확보하고, 라우팅 혼선을 정리했습니다. +- 사용자 프로필 및 대시보드 화면을 확장하여 로그인 이후 UX를 보강했습니다. + +## AA-3. Ory 기반 전환 단계 +- Ory 기반 로그인/등록/복구/검증 경로를 UserFront로 매핑했습니다. +- consent 화면을 추가하고, OIDC 흐름을 연결했습니다. +- 운영 환경에서 consent 세션 생성 조건을 실험하고 운영 제약을 문서화했습니다. + +## AA-4. 운영/테스트 기준 확립 단계 +- API 설계 정책을 정리하고 응답 포맷 통일 기준을 수립했습니다. +- 테스트 계획과 운영 점검 기준을 문서화했습니다. +- 감사 로그 적재 확인 및 점검 기준을 마련했습니다. + + +--- + +# 부록 AB: 품질 기준 및 수락 조건(확장) + +## AB-1. 기능 수락 기준(예시) +- 로그인 성공 시 세션 저장 및 대시보드 이동이 정상 동작 +- 링크 로그인에서 승인 기기 세션 미생성 확인 +- QR 로그인에서 승인 기기 세션 미생성 확인 +- consent 승인 후 리다이렉트 정상 동작 +- 오류 화면에서 민감 정보 노출 없음 + +## AB-2. 운영 수락 기준(예시) +- Ory readiness 응답 정상 +- Admin 포트 외부 접근 불가 +- 감사 로그 적재 성공 +- 헬스체크 통과 + +## AB-3. 문서 수락 기준(예시) +- 인증 플로우 문서 최신화 +- 테스트 계획 최신화 +- 운영 가이드 최신화 + + +--- + +# 부록 AC: 리스크 레지스터(요약) + +| 리스크 | 설명 | 영향 | 대응 방향 | +|---|---|---|---| +| 팝업 로그인 세션 미인식 | 동일 브라우저 세션이 팝업에서 재사용되지 않음 | 로그인 UX 저하 | 쿠키 기반 세션 수락 추가, auto-accept 적용 | +| Consent 거부 미구현 | 사용자 거부 플로우 부재 | 동의 UX 불완전 | 거부 API/리다이렉트 구현 | +| Dev/Admin 포털 실데이터 부족 | UI는 있으나 실데이터 연동 부족 | 운영 활용 제한 | API 확장 및 데이터 연동 | +| 테스트 자동화 부재 | 회귀 위험 증가 | 품질 리스크 | CI 도입 및 자동화 확대 | +| 운영 환경 변수 설정 오류 | 내부/외부 URL 분리 실패 시 장애 | 접속 불가 | 운영 점검 체크리스트 강화 | + + +--- + +# 부록 AD: 정책/표준 적용 사례(요약) + +- API 응답 구조 통일 + - 목록 응답은 `items`, `limit`, `offset` 구조를 사용 + - 단건 응답은 루트 객체로 반환 + +- 에러 메시지 통일 + - 사용자 메시지는 최소화 + - 내부 에러는 서버 로그로만 기록 + +- 라우팅 일관성 + - `/login` → `/signin` 정리 + - Kratos self-service 경로는 UserFront로 매핑 + +- 테스트 기준 표준화 + - 로그인/동의/세션 플로우를 핵심 시나리오로 지정 + - 운영 점검 체크리스트에 헬스체크와 네트워크 경계 확인 포함 + + +--- + +# 부록 AE: 향후 확장 시나리오(상세) + +- MFA 도입 시나리오 + - OTP/TOTP 기반 2차 인증 추가 + - 로그인 성공 후 추가 인증 단계 삽입 + - UserFront UI에 MFA 입력 화면 추가 + +- Passkey/WebAuthn 도입 + - 브라우저 지원 여부 확인 + - 등록/인증 흐름 분리 + +- 세션 관리 고도화 + - 기기별 세션 리스트 제공 + - 원격 로그아웃 기능 추가 + - 세션 만료 정책 고도화 + +- Consent 통계/이력 + - 사용자/클라이언트별 동의 이력 조회 + - 철회 이력 및 상태 관리 + + +--- + +# 부록 AF: 감사 로그 이벤트 분류(예시) + +- 로그인 성공/실패 이벤트 +- 비밀번호 변경/재설정 이벤트 +- consent 승인/거부 이벤트 +- 클라이언트 생성/수정/삭제 이벤트 +- 관리자 액션 이벤트 + +**운영 참고:** +- 각 이벤트는 `actor`, `action`, `target`, `timestamp`, `metadata`를 포함하도록 설계 +- 장애 분석 시 `X-Request-ID`와 함께 추적 가능하도록 로깅 + + +--- + +# 부록 AG: 프로필/세션 관리 상세(확장) + +## AG-1. 프로필 필드 관리 +- 이름/이메일/휴대폰 등 기본 정보 표시 +- 수정 시 서버 검증 및 오류 메시지 제공 + +## AG-2. 세션 리스트 +- 현재 활성 세션 목록을 렌더링 +- 세션 종료/만료 상태 처리 +- 향후 기기별 세션 종료 기능 확장 가능 + + +--- + +# 부록 AH: 운영 FAQ(요약) + +Q. consent 화면이 뜨지 않습니다. +A. consent UI URL이 잘못 설정되었거나 접근 불가한 경우입니다. UserFront 경로와 연결 상태를 확인하세요. + +Q. 링크 로그인에서 승인했는데 요청 기기에서 로그인되지 않습니다. +A. verify-only 플래그 전달 여부와 polling 상태를 확인하세요. 승인 기기에서 세션 발급이 발생하지 않도록 설계되어 있습니다. + +Q. 팝업 로그인에서 기존 세션이 인식되지 않습니다. +A. 쿠키 기반 세션 공유가 적용되지 않았을 수 있습니다. 동일 Origin 여부와 쿠키 전달 여부를 확인하세요. + +Q. Admin 포트가 외부에서 접근됩니다. +A. 네트워크 설정을 점검하고, Admin 포트가 외부에 노출되지 않도록 구성하세요. + + +--- + +# 부록 AI: 변경 이력(요약) + +- v1.1: 인증 플로우/테스트/운영 상세 확장 +- v1.0: 종료 이슈 및 문서 요약 반영 +- v0.9: 초안 구성 + + +추가 메모: 본 문서는 현재 코드 상태와 문서화 현황을 기반으로 작성된 초안입니다. 향후 실제 운영 환경에서 발견되는 이슈와 개선 사항은 별도 부록 또는 후속 버전으로 반영될 예정이며, 특히 인증 플로우의 자동화 테스트와 포털 실데이터 연동 결과는 다음 버전에서 상세히 업데이트될 계획입니다. + + +향후 업데이트 시 코드 변경 내역과 테스트 결과를 추가 반영합니다. + + +추가 설명은 내부 리뷰 후 보강 예정입니다. diff --git a/userfront/lib/core/constants/error_whitelist.dart b/userfront/lib/core/constants/error_whitelist.dart new file mode 100644 index 00000000..27c8678f --- /dev/null +++ b/userfront/lib/core/constants/error_whitelist.dart @@ -0,0 +1,11 @@ +const Map errorWhitelistMessages = { + 'settings_disabled': '현재 계정 설정 화면은 준비 중입니다.', + 'invalid_session': '세션이 만료되었습니다. 다시 로그인해 주세요.', + 'verification_required': '추가 인증이 필요합니다. 안내에 따라 진행해 주세요.', + 'recovery_expired': '재설정 링크가 만료되었습니다. 다시 요청해 주세요.', + 'recovery_invalid': '재설정 링크가 유효하지 않습니다.', + 'consent_required': '앱 접근 동의가 필요합니다.', + 'rate_limited': '요청이 많습니다. 잠시 후 다시 시도해 주세요.', + 'not_found': '요청한 페이지를 찾을 수 없습니다.', + 'bad_request': '입력값을 확인해 주세요.', +}; diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 4d9886ec..65849c4f 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'http_client.dart'; -import 'dart:html' as html; +import 'web_window.dart'; class AuthProxyService { static String _envOrDefault(String key, String fallback) { @@ -215,7 +215,7 @@ class AuthProxyService { if (response.statusCode == 200) { final data = jsonDecode(response.body); if (data['redirectTo'] != null && data['redirectTo'].isNotEmpty) { - html.window.location.href = data['redirectTo']; + webWindow.redirectTo(data['redirectTo']); } return data; } else { @@ -254,6 +254,36 @@ class AuthProxyService { } } + static Future> acceptOidcLogin( + String loginChallenge, { + String? token, + }) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/oidc/login/accept'); + final headers = { + 'Content-Type': 'application/json', + }; + if (token != null && token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; + } + final client = createHttpClient(withCredentials: true); + try { + final response = await client.post( + url, + headers: headers, + body: jsonEncode({'login_challenge': loginChallenge}), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + final errorBody = jsonDecode(response.body); + throw Exception(errorBody['error'] ?? 'Failed to accept OIDC login'); + } + } finally { + client.close(); + } + } + static Future> initiatePasswordReset(String loginId, {bool? drySend}) async { final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate'); final response = await http.post( diff --git a/userfront/lib/core/services/web_window.dart b/userfront/lib/core/services/web_window.dart new file mode 100644 index 00000000..1fe2792d --- /dev/null +++ b/userfront/lib/core/services/web_window.dart @@ -0,0 +1 @@ +export 'web_window_stub.dart' if (dart.library.html) 'web_window_web.dart'; diff --git a/userfront/lib/core/services/web_window_stub.dart b/userfront/lib/core/services/web_window_stub.dart new file mode 100644 index 00000000..e914264c --- /dev/null +++ b/userfront/lib/core/services/web_window_stub.dart @@ -0,0 +1,17 @@ +class WebWindow { + void redirectTo(String url) {} + + void alert(String message) {} + + void close() {} + + bool hasOpener() { + return false; + } + + bool redirectOpenerTo(String url) { + return false; + } +} + +final webWindow = WebWindow(); diff --git a/userfront/lib/core/services/web_window_web.dart b/userfront/lib/core/services/web_window_web.dart new file mode 100644 index 00000000..6a9c7079 --- /dev/null +++ b/userfront/lib/core/services/web_window_web.dart @@ -0,0 +1,34 @@ +import 'dart:html' as html; + +class WebWindow { + void redirectTo(String url) { + html.window.location.href = url; + } + + void alert(String message) { + html.window.alert(message); + } + + void close() { + html.window.close(); + } + + bool hasOpener() { + return html.window.opener != null; + } + + bool redirectOpenerTo(String url) { + final opener = html.window.opener; + if (opener == null) { + return false; + } + try { + opener.location.href = url; + return true; + } catch (_) { + return false; + } + } +} + +final webWindow = WebWindow(); diff --git a/userfront/lib/features/auth/presentation/consent_screen.dart b/userfront/lib/features/auth/presentation/consent_screen.dart index a078e968..904c7aae 100644 --- a/userfront/lib/features/auth/presentation/consent_screen.dart +++ b/userfront/lib/features/auth/presentation/consent_screen.dart @@ -1,7 +1,6 @@ -import 'dart:html' as html; - import 'package:flutter/material.dart'; import 'package:userfront/core/services/auth_proxy_service.dart'; +import 'package:userfront/core/services/web_window.dart'; class ConsentScreen extends StatefulWidget { final String consentChallenge; @@ -13,8 +12,15 @@ class ConsentScreen extends StatefulWidget { } class _ConsentScreenState extends State { + static const _ink = Color(0xFF1A1F2C); + static const _surface = Colors.white; + static const _border = Color(0xFFE5E7EB); + static const _subtle = Color(0xFFF7F8FA); + static const _accent = Color(0xFF2563EB); + Map? _consentInfo; bool _isLoading = true; + bool _isSubmitting = false; String? _error; @override @@ -32,7 +38,7 @@ class _ConsentScreenState extends State { }); } catch (e) { setState(() { - _error = 'Failed to load consent information: $e'; + _error = '권한 정보를 불러오지 못했습니다: $e'; _isLoading = false; }); } @@ -40,83 +46,498 @@ class _ConsentScreenState extends State { Future _acceptConsent() async { setState(() { - _isLoading = true; + _isSubmitting = true; _error = null; }); try { final result = await AuthProxyService.acceptConsent(widget.consentChallenge); - if (result['redirectTo'] != null) { - html.window.location.href = result['redirectTo']; - } else { - setState(() { - _error = 'Consent accepted, but no redirect URL received.'; - _isLoading = false; - }); + final redirectTo = result['redirectTo']?.toString() ?? ''; + if (redirectTo.isNotEmpty) { + if (webWindow.hasOpener() && webWindow.redirectOpenerTo(redirectTo)) { + // 팝업에서 호출된 경우, 부모 창으로 리다이렉트 후 현재 창을 닫습니다. + webWindow.close(); + return; + } + webWindow.redirectTo(redirectTo); + return; } + setState(() { + _error = '동의는 완료됐지만 이동할 주소를 받지 못했습니다.'; + }); } catch (e) { setState(() { - _error = 'Failed to accept consent: $e'; - _isLoading = false; + _error = '동의 처리 중 오류가 발생했습니다: $e'; }); + } finally { + if (mounted) { + setState(() => _isSubmitting = false); + } } } + void _rejectConsent() { + webWindow.alert('동의를 취소했습니다. 창을 닫아 주세요.'); + } + + Map? _client() { + final info = _consentInfo; + if (info == null) return null; + final client = info['client']; + if (client is Map) { + return client; + } + return null; + } + + String _resolveClientName(Map? client) { + final name = client?['client_name']?.toString().trim(); + if (name != null && name.isNotEmpty) { + return name; + } + final id = client?['client_id']?.toString().trim(); + if (id != null && id.isNotEmpty) { + return id; + } + return '알 수 없는 앱'; + } + + String? _resolveClientId(Map? client) { + final id = client?['client_id']?.toString().trim(); + if (id != null && id.isNotEmpty) { + return id; + } + return null; + } + + String? _resolveClientLogo(Map? client) { + final logo = client?['logo_uri']?.toString().trim(); + if (logo != null && logo.isNotEmpty) { + return logo; + } + final metadata = client?['metadata']; + if (metadata is Map) { + final metaLogo = metadata['logo_url']?.toString().trim(); + if (metaLogo != null && metaLogo.isNotEmpty) { + return metaLogo; + } + } + return null; + } + + List _requestedScopes() { + final scopes = _consentInfo?['requested_scope']; + if (scopes is List) { + return scopes.map((e) => e.toString()).toList(); + } + return const []; + } + + String _scopeDescription(String scope) { + switch (scope) { + case 'openid': + return '로그인 상태 확인을 위한 기본 식별자'; + case 'profile': + return '이름, 사용자 식별자 등 기본 프로필 정보'; + case 'email': + return '이메일 주소 정보'; + case 'phone': + return '휴대폰 번호 정보'; + default: + return '앱에서 요청한 추가 권한'; + } + } + + Widget _buildInfoChip(IconData icon, String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: _subtle, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: _border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: _ink), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle( + fontSize: 12, + color: _ink, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final client = _client(); + final clientName = _resolveClientName(client); + final clientId = _resolveClientId(client); + final logoUrl = _resolveClientLogo(client); + final scopes = _requestedScopes(); + return Scaffold( - appBar: AppBar(title: const Text('Grant Access')), - body: Center( - child: _isLoading - ? const CircularProgressIndicator() - : _error != null - ? Text(_error!, style: const TextStyle(color: Colors.red)) - : _consentInfo != null - ? Card( - elevation: 4, - margin: const EdgeInsets.all(16), - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${_consentInfo!['client']?['client_name'] ?? 'An application'} wants to access your account', - style: Theme.of(context).textTheme.headlineSmall, - textAlign: TextAlign.center, + backgroundColor: _subtle, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Container( + decoration: BoxDecoration( + color: _surface, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: _border), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(28, 28, 28, 24), + child: _isLoading + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + '권한 정보를 불러오는 중입니다...', + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.grey[600], ), - const SizedBox(height: 24), - const Text('This will allow the application to:'), - const SizedBox(height: 16), - if (_consentInfo!['requested_scope'] != null) - ...(_consentInfo!['requested_scope'] as List) - .map((scope) => ListTile( - leading: const Icon(Icons.check_circle_outline), - title: Text(scope.toString()), - )) - .toList(), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - TextButton( - onPressed: () { - // TODO: Implement reject consent - html.window.alert('Consent rejected. You can close this window.'); - }, - child: const Text('Deny'), + ), + ], + ) + : _consentInfo == null + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '요청 정보를 확인할 수 없습니다.', + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.grey[700], ), - ElevatedButton( - onPressed: _acceptConsent, - child: const Text('Allow'), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text( + _error!, + style: theme.textTheme.bodySmall?.copyWith( + color: const Color(0xFFB91C1C), + ), ), ], - ) - ], - ), - ), - ) - : const Text('No consent information available.'), + const SizedBox(height: 16), + OutlinedButton( + onPressed: _fetchConsentInfo, + style: OutlinedButton.styleFrom( + foregroundColor: _ink, + side: const BorderSide(color: _border), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text('다시 시도'), + ), + ], + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: _subtle, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _border), + ), + child: logoUrl == null + ? const Icon( + Icons.lock_outline, + color: _ink, + ) + : ClipRRect( + borderRadius: + BorderRadius.circular(14), + child: Image.network( + logoUrl, + fit: BoxFit.cover, + errorBuilder: ( + context, + error, + stackTrace, + ) { + return const Icon( + Icons.lock_outline, + color: _ink, + ); + }, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + '앱 권한 요청', + style: theme.textTheme.titleLarge + ?.copyWith( + fontWeight: FontWeight.w700, + color: _ink, + ), + ), + const SizedBox(height: 6), + Text( + clientName, + style: theme.textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.w600, + color: _ink, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (clientId != null) + _buildInfoChip( + Icons.vpn_key_outlined, + 'Client ID: $clientId', + ), + _buildInfoChip( + Icons.security_outlined, + '요청 권한 ${scopes.length}개', + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + '이 앱이 아래 정보에 접근하려고 합니다. 계속 진행하려면 동의 여부를 선택해 주세요.', + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.grey[700], + height: 1.5, + ), + ), + if (_error != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFEE2E2), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFFCA5A5), + ), + ), + child: Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const Icon( + Icons.error_outline, + color: Color(0xFFB91C1C), + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _error!, + style: theme.textTheme.bodySmall + ?.copyWith( + color: const Color(0xFFB91C1C), + height: 1.4, + ), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 20), + Text( + '요청된 권한', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: _ink, + ), + ), + const SizedBox(height: 12), + if (scopes.isEmpty) + Text( + '요청된 권한 정보가 없습니다.', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + ) + else + Column( + children: scopes + .map( + (scope) => Container( + margin: const EdgeInsets.only( + bottom: 10, + ), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _subtle, + borderRadius: + BorderRadius.circular(12), + border: + Border.all(color: _border), + ), + child: Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const Icon( + Icons.check_circle_outline, + color: _accent, + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Text( + scope, + style: theme.textTheme + .bodyMedium + ?.copyWith( + fontWeight: + FontWeight.w600, + color: _ink, + ), + ), + const SizedBox(height: 4), + Text( + _scopeDescription( + scope), + style: theme.textTheme + .bodySmall + ?.copyWith( + color: + Colors.grey[600], + ), + ), + ], + ), + ), + ], + ), + ), + ) + .toList(), + ), + const SizedBox(height: 12), + Text( + '동의 후 자동으로 서비스로 이동합니다.', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + ), + const SizedBox(height: 20), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + OutlinedButton( + onPressed: _isSubmitting + ? null + : _rejectConsent, + style: OutlinedButton.styleFrom( + foregroundColor: _ink, + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 12, + ), + side: const BorderSide( + color: _border, + ), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(10), + ), + ), + child: const Text('취소'), + ), + FilledButton( + onPressed: _isSubmitting + ? null + : _acceptConsent, + style: FilledButton.styleFrom( + backgroundColor: _ink, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(10), + ), + ), + child: _isSubmitting + ? Row( + mainAxisSize: MainAxisSize.min, + children: const [ + SizedBox( + width: 16, + height: 16, + child: + CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + SizedBox(width: 8), + Text('처리 중...'), + ], + ) + : const Text('동의하고 계속하기'), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), ), ); } diff --git a/userfront/lib/features/auth/presentation/error_screen.dart b/userfront/lib/features/auth/presentation/error_screen.dart index 2361e1c9..e9419f75 100644 --- a/userfront/lib/features/auth/presentation/error_screen.dart +++ b/userfront/lib/features/auth/presentation/error_screen.dart @@ -7,18 +7,20 @@ class ErrorScreen extends StatelessWidget { final String? errorId; final String? errorCode; final String? description; + final bool? isProdOverride; const ErrorScreen({ super.key, this.errorId, this.errorCode, this.description, + this.isProdOverride, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); - final isProd = AuthProxyService.isProdEnv; + final isProd = isProdOverride ?? AuthProxyService.isProdEnv; final normalizedCode = (errorCode ?? '').trim(); final hasCode = normalizedCode.isNotEmpty; final whitelistMessage = errorWhitelistMessages[normalizedCode]; diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 7e73ac01..7b20ad69 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -10,7 +10,7 @@ import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_token_store.dart'; import '../../../core/notifiers/auth_notifier.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; -import 'dart:html' as html; +import '../../../core/services/web_window.dart'; class LoginScreen extends ConsumerStatefulWidget { final String? verificationToken; @@ -109,10 +109,7 @@ class _LoginScreenState extends ConsumerState return; } final pendingProvider = AuthTokenStore.getPendingProvider(); - final provider = pendingProvider ?? AuthTokenStore.getProvider(); - if (provider == null || !provider.toLowerCase().contains('ory')) { - return; - } + final provider = pendingProvider ?? AuthTokenStore.getProvider() ?? 'ory'; try { await AuthProxyService.checkCookieSession(); @@ -657,7 +654,7 @@ class _LoginScreenState extends ConsumerState if (mounted) Navigator.of(context).pop(); if (redirectTo != null && redirectTo.isNotEmpty) { - html.window.location.href = redirectTo; + webWindow.redirectTo(redirectTo); return; } @@ -885,6 +882,24 @@ class _LoginScreenState extends ConsumerState debugPrint("[Auth] Failed to pre-fetch profile: $e"); } + if (_loginChallenge != null && _loginChallenge!.isNotEmpty) { + try { + final res = await AuthProxyService.acceptOidcLogin( + _loginChallenge!, + token: token, + ); + final redirectTo = res['redirectTo'] as String?; + if (redirectTo != null && redirectTo.isNotEmpty) { + debugPrint("[Auth] OIDC login accepted. Redirecting to: $redirectTo"); + webWindow.redirectTo(redirectTo); + return; + } + } catch (e) { + _showError("OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요."); + return; + } + } + if (WebAuthIntegration.isPopup()) { debugPrint("[Auth] Popup detected. Notifying opener and attempting to close."); WebAuthIntegration.sendLoginSuccess(token); diff --git a/userfront/lib/features/auth/presentation/qr_scan_screen.dart b/userfront/lib/features/auth/presentation/qr_scan_screen.dart index 9586a2fe..7c4a2a4c 100644 --- a/userfront/lib/features/auth/presentation/qr_scan_screen.dart +++ b/userfront/lib/features/auth/presentation/qr_scan_screen.dart @@ -16,6 +16,7 @@ class _QRScanScreenState extends State { final _log = Logger('QRScanScreen'); final MobileScannerController controller = MobileScannerController( detectionSpeed: DetectionSpeed.noDuplicates, + autoStart: false, ); bool _isScanned = false; bool _isCheckingSession = false; @@ -28,6 +29,9 @@ class _QRScanScreenState extends State { void initState() { super.initState(); _bootstrapCookieSession(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _startScannerIfNeeded(); + }); } Future _bootstrapCookieSession() async { @@ -52,6 +56,28 @@ class _QRScanScreenState extends State { } } + Future _startScannerIfNeeded() async { + if (controller.value.isRunning || controller.value.isStarting) { + return; + } + try { + await controller.start(); + } catch (e) { + _log.warning('Scanner start failed: $e'); + } + } + + Future _stopScannerIfRunning() async { + if (!controller.value.isRunning && !controller.value.isStarting) { + return; + } + try { + await controller.stop(); + } catch (e) { + _log.warning('Scanner stop failed: $e'); + } + } + @override void dispose() { controller.dispose(); @@ -65,6 +91,7 @@ class _QRScanScreenState extends State { for (final barcode in barcodes) { if (barcode.rawValue != null) { _isScanned = true; + await _stopScannerIfRunning(); if (mounted) { setState(() => _isProcessing = true); } @@ -142,14 +169,14 @@ class _QRScanScreenState extends State { _isSuccess = null; _resultMessage = null; }); - controller.start(); + _startScannerIfNeeded(); } Future _requestCameraPermission() async { if (_isRequestingCamera) return; setState(() => _isRequestingCamera = true); try { - await controller.start(); + await _startScannerIfNeeded(); } catch (e) { _log.warning('Camera permission request failed: $e'); if (mounted) { diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 7b5251b0..8eeb3327 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -791,6 +791,23 @@ class _DashboardScreenState extends ConsumerState { ); } + if (activities.isEmpty) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '연동된 RP가 없습니다.', + style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Text( + 'RP를 연동하면 최근 활동과 상태가 표시됩니다.', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ); + } + return grid; }, ); @@ -815,33 +832,6 @@ class _DashboardScreenState extends ConsumerState { ); } - items.addAll([ - _ActivityItem( - appName: 'BEPs', - lastAuthAt: '연동 필요', - status: '미연동', - canLogout: false, - ), - _ActivityItem( - appName: 'KNGIL', - lastAuthAt: '연동 필요', - status: '미연동', - canLogout: false, - ), - _ActivityItem( - appName: 'C.E.L', - lastAuthAt: '연동 필요', - status: '미연동', - canLogout: false, - ), - _ActivityItem( - appName: 'EG-BIM', - lastAuthAt: '연동 필요', - status: '미연동', - canLogout: false, - ), - ]); - return items; } diff --git a/userfront/test/error_screen_test.dart b/userfront/test/error_screen_test.dart new file mode 100644 index 00000000..5ed8c55d --- /dev/null +++ b/userfront/test/error_screen_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/features/auth/presentation/error_screen.dart'; + +Future _pumpErrorScreen( + WidgetTester tester, { + String? errorCode, + String? description, + bool? isProdOverride, +}) async { + await tester.pumpWidget( + MaterialApp( + home: ErrorScreen( + errorCode: errorCode, + description: description, + isProdOverride: isProdOverride, + ), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets('개발환경은 원문 메시지를 노출한다', (WidgetTester tester) async { + await _pumpErrorScreen( + tester, + errorCode: 'custom_error', + description: '원문 메시지', + isProdOverride: false, + ); + + expect(find.text('오류: custom_error'), findsOneWidget); + expect(find.text('원문 메시지'), findsOneWidget); + expect(find.text('오류 종류: custom_error'), findsOneWidget); + }); + + testWidgets('프로덕션은 whitelist 메시지를 노출한다', (WidgetTester tester) async { + await _pumpErrorScreen( + tester, + errorCode: 'settings_disabled', + description: '원문 메시지', + isProdOverride: true, + ); + + expect(find.text('인증 과정에서 오류가 발생했습니다'), findsOneWidget); + expect(find.text('현재 계정 설정 화면은 준비 중입니다.'), findsOneWidget); + expect(find.text('원문 메시지'), findsNothing); + expect(find.text('오류 종류: settings_disabled'), findsOneWidget); + }); + + testWidgets('프로덕션은 비허용 에러를 unknown_error로 처리한다', (WidgetTester tester) async { + await _pumpErrorScreen( + tester, + errorCode: 'weird_error', + description: '원문 메시지', + isProdOverride: true, + ); + + expect(find.text('인증 과정에서 오류가 발생했습니다'), findsOneWidget); + expect(find.text('에러가 계속되면 관리자에게 문의해주세요'), findsOneWidget); + expect(find.text('원문 메시지'), findsNothing); + expect(find.text('오류 종류: unknown_error'), findsOneWidget); + }); +}