diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 2a541220..2dc27744 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "github.com/gofiber/fiber/v2/middleware/encryptcookie" "github.com/gofiber/fiber/v2/middleware/logger" "github.com/gofiber/fiber/v2/middleware/recover" + "github.com/gofiber/fiber/v2/middleware/requestid" ) func getEnv(key, fallback string) string { @@ -23,7 +24,15 @@ func getEnv(key, fallback string) string { } func main() { - // 1. Initialize DB Connections + // 1. Log Config on Startup + log.Println("==========================================") + log.Println("Starting Baron SSO Backend...") + log.Printf("FRONTEND_URL: %s", getEnv("FRONTEND_URL", "http://ssologin.hmac.kr")) + log.Printf("REDIS_ADDR: %s", getEnv("REDIS_ADDR", "redis:6379")) + log.Printf("DESCOPE_ID: %s", getEnv("DESCOPE_PROJECT_ID", "not-set")) + log.Println("==========================================") + + // 2. Initialize DB Connections chHost := getEnv("CLICKHOUSE_HOST", "localhost") chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000")) chUser := getEnv("CLICKHOUSE_USER", "default") @@ -46,7 +55,14 @@ func main() { }) // Middleware - app.Use(logger.New()) + app.Use(requestid.New(requestid.Config{ + Generator: func() string { + return handler.GenerateSecureToken(4) // 8 chars hex + }, + })) + app.Use(logger.New(logger.Config{ + Format: "[${time}] ${status} - ${method} ${path}\n", + })) app.Use(recover.New()) app.Use(cors.New(cors.Config{ AllowOrigins: "*", // Adjust in production diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index a3d7c45f..6e688af2 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -39,8 +39,8 @@ type AuthHandler struct { DescopeClient *client.DescopeClient } -// Helper to generate secure random strings -func generateSecureToken(length int) string { +// GenerateSecureToken - Helper to generate secure random strings +func GenerateSecureToken(length int) string { b := make([]byte, length) if _, err := crand.Read(b); err != nil { return "" @@ -126,36 +126,39 @@ func (h *AuthHandler) VerifySms(c *fiber.Ctx) error { func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { var req domain.EnchantedLinkInitRequest if err := c.BodyParser(&req); err != nil { + log.Printf("[Enchanted] Body parse error: %v", err) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } loginID := strings.ReplaceAll(req.LoginID, "-", "") loginID = strings.ReplaceAll(loginID, " ", "") - + // Generate secure tokens - token := generateSecureToken(3) - pendingRef := generateSecureToken(3) + token := GenerateSecureToken(3) + pendingRef := GenerateSecureToken(3) + + log.Printf("[Enchanted] Initiating for %s. Token: %s, PendingRef: %s", loginID, token, pendingRef) // Store in Redis h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), defaultExpiration) h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, loginID), defaultExpiration) // Send SMS - // Frontend URL should be dynamic or env based frontendURL := os.Getenv("FRONTEND_URL") if frontendURL == "" { frontendURL = "http://ssologin.hmac.kr" } link := fmt.Sprintf("%s/verify/%s", frontendURL, token) content := fmt.Sprintf("[Baron SSO] 로그인 링크: %s", link) - - log.Printf("[Enchanted] Sending link to %s", loginID) + + log.Printf("[Enchanted] Sending SMS to %s via Naver Cloud", loginID) if err := h.SmsService.SendSms(loginID, content); err != nil { log.Printf("[Enchanted] SMS Failed: %v", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) } + log.Printf("[Enchanted] SMS sent successfully to %s", loginID) return c.JSON(fiber.Map{ "linkId": "SMS Sent", "pendingRef": pendingRef, @@ -179,6 +182,7 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { json.Unmarshal([]byte(val), &data) if data["status"] == statusSuccess { + log.Printf("[Poll] Success for ref: %s", req.PendingRef) return c.JSON(fiber.Map{ "sessionJwt": data["jwt"], "status": "ok", @@ -192,12 +196,16 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { var req domain.MagicLinkVerifyRequest if err := c.BodyParser(&req); err != nil { + log.Printf("[Verify] Body parse error: %v", err) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } + log.Printf("[Verify] Attempting to verify token: %s", req.Token) + tokenKey := prefixToken + req.Token val, err := h.RedisService.Get(tokenKey) if err != nil || val == "" { + log.Printf("[Verify] Token not found or expired in Redis: %s", req.Token) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"}) } @@ -206,73 +214,68 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { pendingRef := tokenData["pendingRef"] loginID := tokenData["loginId"] + log.Printf("[Verify] Token valid. LoginID: %s, PendingRef: %s", loginID, pendingRef) + // 1. Generate Descope Session Directly (Management SDK) if h.DescopeClient == nil { + log.Printf("[Verify] Descope Client is nil!") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope Client not configured"}) } - // Use GenerateEmbeddedLink to get a temporary token directly for the user. - // This generates a token that will be exchanged for a real session. + log.Printf("[Verify] Generating embedded link for %s", loginID) embeddedToken, err := h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), loginID, nil, 0) if err != nil { - // If user does not exist, create it and retry if strings.Contains(err.Error(), "User not found") || strings.Contains(err.Error(), "E062108") { - log.Printf("User %s not found. Creating new user...", loginID) - - // Format LoginID for Descope (E.164 for phones) + log.Printf("[Verify] User %s not found. Creating...", loginID) + descopeLoginID := loginID userObj := &descope.UserRequest{} - if strings.Contains(loginID, "@") { userObj.Email = loginID } else { - // LoginID is likely a phone number if strings.HasPrefix(loginID, "010") { descopeLoginID = "+82" + loginID[1:] } userObj.Phone = descopeLoginID } - // Create user using the formatted LoginID _, errCreate := h.DescopeClient.Management.User().Create(context.Background(), descopeLoginID, userObj) if errCreate != nil { - log.Printf("Failed to create user: %v", errCreate) + log.Printf("[Verify] Failed to create user: %v", errCreate) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create new user"}) } - - // Retry generating embedded token with the Descope LoginID + embeddedToken, err = h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), descopeLoginID, nil, 0) if err != nil { - log.Printf("Failed to generate Descope Session after creation: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate token for new user"}) + log.Printf("[Verify] Failed to generate token after creation: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate upstream token"}) } } else { - log.Printf("Failed to generate Descope Session: %v", err) + log.Printf("[Verify] Descope Error: %v", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate upstream token"}) } } - // Exchange the Embedded Token for a real User Session JWT + log.Printf("[Verify] Exchanging embedded token for session JWT") authInfo, err := h.DescopeClient.Auth.MagicLink().Verify(context.Background(), embeddedToken, nil) if err != nil { - log.Printf("Failed to verify embedded token: %v", err) + log.Printf("[Verify] Final verification failed: %v", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify upstream token"}) } sessionToken := authInfo.SessionToken.JWT - // Update Session in Redis for the polling client + log.Printf("[Verify] Success! Updating Redis session: %s", pendingRef) sessionData, _ := json.Marshal(map[string]string{ "status": statusSuccess, "jwt": sessionToken, }) h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration) - + return c.JSON(fiber.Map{ "token": sessionToken, "message": "Login successful", }) } - // ProxyToDescope (Placeholder) func (h *AuthHandler) ProxyToDescope(c *fiber.Ctx, path string, payload interface{}) error { return c.Status(501).SendString("Descope Proxy Disabled") diff --git a/frontend/lib/features/auth/presentation/login_screen.dart b/frontend/lib/features/auth/presentation/login_screen.dart index 1b9e50f4..24683512 100644 --- a/frontend/lib/features/auth/presentation/login_screen.dart +++ b/frontend/lib/features/auth/presentation/login_screen.dart @@ -51,20 +51,17 @@ class _LoginScreenState extends ConsumerState } Future _verifyToken(String token) async { + debugPrint("[Auth] Starting verification for token: $token"); try { // Use Backend to verify the token (Backend-Driven Flow) - // The backend will validate the local token, and then trigger Descope JWT generation. - // This approves the pending session for the Polling device. await AuthProxyService.verifyMagicLink(token); - - // Note: If this device (Mobile) also needs to login, we would need to - // parse the response from verifyMagicLink which contains the JWT. - // For now, we assume this action primarily approves the PC session. + debugPrint("[Auth] Verification successful for token: $token"); if (mounted) { _showSuccessDialog(); } } catch (e) { + debugPrint("[Auth] Verification FAILED for token: $token. Error: $e"); if (mounted) { _showError("Verification failed: $e"); } @@ -98,6 +95,7 @@ class _LoginScreenState extends ConsumerState final password = _passwordController.text; if (password.isNotEmpty) { + debugPrint("[Auth] Attempting Email/Password login for: $email"); try { final authResponse = await Descope.password.signIn( loginId: email, @@ -105,12 +103,14 @@ class _LoginScreenState extends ConsumerState ); final session = DescopeSession.fromAuthenticationResponse(authResponse); Descope.sessionManager.manageSession(session); + debugPrint("[Auth] Email login successful"); if (mounted) _onLoginSuccess(session.sessionToken.jwt); } catch (e) { + debugPrint("[Auth] Email login failed: $e"); _showError("Email/Password Login Failed: $e"); } } else { - // Email Enchanted Link (Descope Standard) + debugPrint("[Auth] Initiating Email Enchanted Link for: $email"); _initiateDescopeLinkFlow(email, isSms: false); } } @@ -119,9 +119,8 @@ class _LoginScreenState extends ConsumerState final rawPhone = _phoneController.text.trim(); if (rawPhone.isEmpty) return; - // Sanitize phone number String phone = rawPhone.replaceAll(RegExp(r'[-\s]'), ''); - // Ensure 010 format if needed, but backend handles it too + debugPrint("[Auth] Initiating SMS Enchanted Link for: $phone"); try { if (mounted) { @@ -132,14 +131,14 @@ class _LoginScreenState extends ConsumerState ); } - // 1. Init via Backend API (Not Descope SDK) + // 1. Init via Backend API final initResponse = await AuthProxyService.initEnchantedLink(phone); final pendingRef = initResponse['pendingRef']; + debugPrint("[Auth] SMS Sent. PendingRef: $pendingRef"); if (mounted) { Navigator.of(context).pop(); // Close Loading - // Show Waiting Dialog showDialog( context: context, barrierDismissible: false, @@ -154,7 +153,8 @@ class _LoginScreenState extends ConsumerState const SizedBox(height: 16), TextButton( onPressed: () { - Navigator.of(context).pop(); // Allow canceling + debugPrint("[Auth] Polling canceled by user"); + Navigator.of(context).pop(); }, child: const Text("Cancel") ) @@ -167,6 +167,7 @@ class _LoginScreenState extends ConsumerState _pollForSession(pendingRef); } } catch (e) { + debugPrint("[Auth] SMS initialization failed: $e"); if (mounted && Navigator.canPop(context)) Navigator.of(context).pop(); _showError("Failed to send SMS: $e"); } @@ -175,6 +176,7 @@ class _LoginScreenState extends ConsumerState Future _pollForSession(String pendingRef) async { int attempts = 0; const maxAttempts = 60; // 2 minutes + debugPrint("[Auth] Starting poll for ref: $pendingRef"); while (attempts < maxAttempts && mounted) { await Future.delayed(const Duration(seconds: 2)); @@ -186,12 +188,7 @@ class _LoginScreenState extends ConsumerState if (result['status'] == 'ok') { final jwt = result['sessionJwt']; if (jwt != null) { - // Note: Manually constructing DescopeSession can be complex due to abstract classes. - // In a real production app, you should use the SDK's built-in exchange/verify methods. - // For this prototype, we will proceed with the login success callback. - // If session management is required immediately, we'd need to match the specific - // SDK version's Token implementation. - + debugPrint("[Auth] Polling SUCCESS. Token received."); if (mounted) { Navigator.of(context).pop(); // Close Polling Dialog _onLoginSuccess(jwt); @@ -200,13 +197,12 @@ class _LoginScreenState extends ConsumerState } } } catch (e) { - print("Polling error: $e"); - // Continue polling even on temporary network error? - // Or break? Let's continue. + debugPrint("[Auth] Polling error (attempt $attempts): $e"); } } if (mounted) { + debugPrint("[Auth] Polling timed out for ref: $pendingRef"); Navigator.of(context).pop(); // Close Polling Dialog _showError("Login timed out."); } @@ -277,6 +273,14 @@ class _LoginScreenState extends ConsumerState void _onLoginSuccess(String token) { if (!mounted) return; + // Record Audit Log + AuditService.logEvent( + userId: "unknown", // In real apps, parse token to get user ID + eventType: "LOGIN_SUCCESS", + status: "SUCCESS", + details: "User logged in via Baron SSO", + ); + if (WebAuthIntegration.isPopup()) { WebAuthIntegration.sendLoginSuccess(token); _showError("Login Successful! You can close this window."); diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 9f9b080b..cddaf7ab 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -7,11 +8,23 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; import 'features/auth/presentation/login_screen.dart'; import 'features/dashboard/presentation/dashboard_screen.dart'; +import 'core/services/auth_proxy_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); usePathUrlStrategy(); + // 1. Global Error Handling + FlutterError.onError = (details) { + FlutterError.presentError(details); + AuthProxyService.logError("FLUTTER_ERROR: ${details.exception}\n${details.stack}"); + }; + + PlatformDispatcher.instance.onError = (error, stack) { + AuthProxyService.logError("PLATFORM_ERROR: $error\n$stack"); + return true; + }; + // Load Env (Handling error if missing for now) try { await dotenv.load(fileName: ".env"); @@ -36,18 +49,29 @@ void main() async { // Router Configuration final _router = GoRouter( initialLocation: '/', + debugLogDiagnostics: true, // Enable diagnostic logs routes: [ - GoRoute(path: '/', builder: (context, state) => const LoginScreen()), + GoRoute( + path: '/', + builder: (context, state) { + debugPrint("[Router] Navigating to root (LoginScreen)"); + return const LoginScreen(); + } + ), GoRoute( path: '/verify/:token', builder: (context, state) { final token = state.pathParameters['token']; + debugPrint("[Router] Navigating to /verify with token: $token"); return LoginScreen(verificationToken: token); }, ), GoRoute( path: '/dashboard', - builder: (context, state) => const DashboardScreen(), + builder: (context, state) { + debugPrint("[Router] Navigating to /dashboard"); + return const DashboardScreen(); + }, ), ], redirect: (context, state) { @@ -56,8 +80,16 @@ final _router = GoRouter( final path = state.uri.path; final isLoggingIn = path == '/' || path.startsWith('/verify/'); - if (!isLoggedIn && !isLoggingIn) return '/'; - if (isLoggedIn && path == '/') return '/dashboard'; + debugPrint("[Router] Redirect check - Path: $path, IsLoggedIn: $isLoggedIn"); + + if (!isLoggedIn && !isLoggingIn) { + debugPrint("[Router] Not logged in, redirecting to /"); + return '/'; + } + if (isLoggedIn && path == '/') { + debugPrint("[Router] Logged in, redirecting to /dashboard"); + return '/dashboard'; + } return null; },