package main import ( "context" "database/sql" "errors" "fmt" "log" "os" "regexp" "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/logger" "github.com/golang-jwt/jwt/v5" "github.com/gooseek/backend/internal/auth" "github.com/gooseek/backend/pkg/config" "github.com/gooseek/backend/pkg/email" _ "github.com/lib/pq" ) var ( jwtSecret string accessTokenTTL = 15 * time.Minute refreshTokenTTL = 7 * 24 * time.Hour emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) ) type JWTClaims struct { UserID string `json:"userId"` Email string `json:"email"` Role string `json:"role"` Tier string `json:"tier"` jwt.RegisteredClaims } func main() { cfg, err := config.Load() if err != nil { log.Fatal("Failed to load config:", err) } jwtSecret = cfg.JWTSecret if jwtSecret == "" { jwtSecret = os.Getenv("JWT_SECRET") if jwtSecret == "" { log.Fatal("JWT_SECRET is required") } } if cfg.DatabaseURL == "" { log.Fatal("DATABASE_URL is required") } db, err := sql.Open("postgres", cfg.DatabaseURL) if err != nil { log.Fatal("Failed to connect to database:", err) } defer db.Close() db.SetMaxOpenConns(25) db.SetMaxIdleConns(5) db.SetConnMaxLifetime(5 * time.Minute) maxRetries := 30 for i := 0; i < maxRetries; i++ { if err := db.Ping(); err == nil { break } log.Printf("Waiting for database (attempt %d/%d)...", i+1, maxRetries) time.Sleep(2 * time.Second) } authRepo := auth.NewRepository(db) emailSender := email.NewSender(email.SMTPConfig{ Host: cfg.SMTPHost, Port: cfg.SMTPPort, User: cfg.SMTPUser, Password: cfg.SMTPPassword, From: cfg.SMTPFrom, TLS: cfg.SMTPTLS, SiteURL: cfg.SiteURL, SiteName: cfg.SiteName, }) if emailSender.IsConfigured() { log.Println("Email notifications enabled") } else { log.Println("Email notifications disabled (SMTP not configured)") } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) if err := authRepo.RunMigrations(ctx); err != nil { log.Printf("Migration warning: %v", err) } cancel() log.Println("Auth database ready") go func() { ticker := time.NewTicker(1 * time.Hour) for range ticker.C { authRepo.CleanupExpiredTokens(context.Background()) } }() app := fiber.New(fiber.Config{ BodyLimit: 10 * 1024 * 1024, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, }) app.Use(logger.New()) app.Use(cors.New(cors.Config{ AllowOrigins: "*", AllowHeaders: "Origin, Content-Type, Accept, Authorization", AllowMethods: "GET, POST, PUT, DELETE, OPTIONS", AllowCredentials: true, })) app.Get("/health", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{"status": "ok"}) }) app.Get("/ready", func(c *fiber.Ctx) error { if err := db.Ping(); err != nil { return c.Status(503).JSON(fiber.Map{"status": "database unavailable"}) } return c.JSON(fiber.Map{"status": "ready"}) }) api := app.Group("/api/v1/auth") api.Post("/register", func(c *fiber.Ctx) error { var req auth.RegisterRequest if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) } if req.Email == "" || req.Password == "" || req.Name == "" { return c.Status(400).JSON(fiber.Map{"error": "Email, password and name are required"}) } if !emailRegex.MatchString(req.Email) { return c.Status(400).JSON(fiber.Map{"error": "Invalid email format"}) } if len(req.Password) < 8 { return c.Status(400).JSON(fiber.Map{"error": "Password must be at least 8 characters"}) } user, err := authRepo.CreateUser(c.Context(), req.Email, req.Password, req.Name) if err != nil { if errors.Is(err, auth.ErrEmailExists) { return c.Status(409).JSON(fiber.Map{"error": "Email already registered"}) } if errors.Is(err, auth.ErrWeakPassword) { return c.Status(400).JSON(fiber.Map{"error": "Password too weak"}) } log.Printf("Register error: %v", err) return c.Status(500).JSON(fiber.Map{"error": "Registration failed"}) } tokens, err := generateTokens(c, authRepo, user) if err != nil { log.Printf("Token generation error: %v", err) return c.Status(500).JSON(fiber.Map{"error": "Failed to generate tokens"}) } emailSender.SendAsync(func() error { return emailSender.SendWelcome(user.Email, user.Name) }) return c.Status(201).JSON(tokens) }) api.Post("/login", func(c *fiber.Ctx) error { var req auth.LoginRequest if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) } if req.Email == "" || req.Password == "" { return c.Status(400).JSON(fiber.Map{"error": "Email and password are required"}) } user, err := authRepo.ValidatePassword(c.Context(), req.Email, req.Password) if err != nil { if errors.Is(err, auth.ErrUserNotFound) || errors.Is(err, auth.ErrInvalidPassword) { return c.Status(401).JSON(fiber.Map{"error": "Invalid email or password"}) } log.Printf("Login error: %v", err) return c.Status(500).JSON(fiber.Map{"error": "Login failed"}) } tokens, err := generateTokens(c, authRepo, user) if err != nil { log.Printf("Token generation error: %v", err) return c.Status(500).JSON(fiber.Map{"error": "Failed to generate tokens"}) } return c.JSON(tokens) }) api.Post("/refresh", func(c *fiber.Ctx) error { var req auth.RefreshRequest if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) } if req.RefreshToken == "" { return c.Status(400).JSON(fiber.Map{"error": "Refresh token is required"}) } rt, err := authRepo.ValidateRefreshToken(c.Context(), req.RefreshToken) if err != nil { if errors.Is(err, auth.ErrTokenExpired) { return c.Status(401).JSON(fiber.Map{"error": "Refresh token expired"}) } if errors.Is(err, auth.ErrTokenInvalid) { return c.Status(401).JSON(fiber.Map{"error": "Invalid refresh token"}) } log.Printf("Refresh error: %v", err) return c.Status(500).JSON(fiber.Map{"error": "Token refresh failed"}) } authRepo.RevokeRefreshToken(c.Context(), req.RefreshToken) user, err := authRepo.GetUserByID(c.Context(), rt.UserID) if err != nil { return c.Status(401).JSON(fiber.Map{"error": "User not found"}) } tokens, err := generateTokens(c, authRepo, user) if err != nil { log.Printf("Token generation error: %v", err) return c.Status(500).JSON(fiber.Map{"error": "Failed to generate tokens"}) } return c.JSON(tokens) }) api.Post("/logout", jwtMiddleware, func(c *fiber.Ctx) error { var req auth.RefreshRequest if err := c.BodyParser(&req); err == nil && req.RefreshToken != "" { authRepo.RevokeRefreshToken(c.Context(), req.RefreshToken) } return c.JSON(fiber.Map{"message": "Logged out successfully"}) }) api.Post("/logout-all", jwtMiddleware, func(c *fiber.Ctx) error { userID := c.Locals("userId").(string) authRepo.RevokeAllRefreshTokens(c.Context(), userID) return c.JSON(fiber.Map{"message": "Logged out from all devices"}) }) api.Get("/validate", func(c *fiber.Ctx) error { tokenString := extractToken(c) if tokenString == "" { return c.JSON(auth.ValidateResponse{Valid: false}) } claims, err := validateJWT(tokenString) if err != nil { return c.JSON(auth.ValidateResponse{Valid: false}) } user, err := authRepo.GetUserByID(c.Context(), claims.UserID) if err != nil { return c.JSON(auth.ValidateResponse{Valid: false}) } return c.JSON(auth.ValidateResponse{ Valid: true, User: user, }) }) api.Get("/me", jwtMiddleware, func(c *fiber.Ctx) error { userID := c.Locals("userId").(string) user, err := authRepo.GetUserByID(c.Context(), userID) if err != nil { return c.Status(404).JSON(fiber.Map{"error": "User not found"}) } return c.JSON(user) }) api.Put("/me", jwtMiddleware, func(c *fiber.Ctx) error { userID := c.Locals("userId").(string) var req auth.UpdateProfileRequest if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) } if err := authRepo.UpdateProfile(c.Context(), userID, req.Name, req.Avatar); err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to update profile"}) } user, _ := authRepo.GetUserByID(c.Context(), userID) return c.JSON(user) }) api.Post("/change-password", jwtMiddleware, func(c *fiber.Ctx) error { userID := c.Locals("userId").(string) var req auth.ChangePasswordRequest if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) } if req.CurrentPassword == "" || req.NewPassword == "" { return c.Status(400).JSON(fiber.Map{"error": "Current and new passwords are required"}) } if len(req.NewPassword) < 8 { return c.Status(400).JSON(fiber.Map{"error": "New password must be at least 8 characters"}) } user, err := authRepo.GetUserByID(c.Context(), userID) if err != nil { return c.Status(404).JSON(fiber.Map{"error": "User not found"}) } _, err = authRepo.ValidatePassword(c.Context(), user.Email, req.CurrentPassword) if err != nil { return c.Status(401).JSON(fiber.Map{"error": "Current password is incorrect"}) } if err := authRepo.UpdatePassword(c.Context(), userID, req.NewPassword); err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to change password"}) } authRepo.RevokeAllRefreshTokens(c.Context(), userID) return c.JSON(fiber.Map{"message": "Password changed successfully"}) }) api.Post("/forgot-password", func(c *fiber.Ctx) error { var req auth.ResetPasswordRequest if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) } if req.Email == "" { return c.Status(400).JSON(fiber.Map{"error": "Email is required"}) } user, err := authRepo.GetUserByEmail(c.Context(), req.Email) if err == nil && user != nil { token, err := authRepo.CreatePasswordResetToken(c.Context(), user.ID) if err == nil { resetURL := fmt.Sprintf("%s/reset-password?token=%s", cfg.SiteURL, token.Token) emailSender.SendAsync(func() error { return emailSender.SendPasswordReset(user.Email, user.Name, resetURL) }) } } return c.JSON(fiber.Map{"message": "If the email exists, a reset link has been sent"}) }) api.Post("/reset-password", func(c *fiber.Ctx) error { var req auth.ResetPasswordConfirm if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) } if req.Token == "" || req.NewPassword == "" { return c.Status(400).JSON(fiber.Map{"error": "Token and new password are required"}) } if len(req.NewPassword) < 8 { return c.Status(400).JSON(fiber.Map{"error": "Password must be at least 8 characters"}) } prt, err := authRepo.ValidatePasswordResetToken(c.Context(), req.Token) if err != nil { if errors.Is(err, auth.ErrTokenExpired) { return c.Status(400).JSON(fiber.Map{"error": "Reset token has expired"}) } return c.Status(400).JSON(fiber.Map{"error": "Invalid reset token"}) } if err := authRepo.UpdatePassword(c.Context(), prt.UserID, req.NewPassword); err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to reset password"}) } authRepo.MarkPasswordResetTokenUsed(c.Context(), prt.ID) authRepo.RevokeAllRefreshTokens(c.Context(), prt.UserID) return c.JSON(fiber.Map{"message": "Password has been reset successfully"}) }) port := config.GetEnvInt("AUTH_SVC_PORT", 3050) log.Printf("auth-svc listening on :%d", port) log.Fatal(app.Listen(fmt.Sprintf(":%d", port))) } func generateTokens(c *fiber.Ctx, repo *auth.Repository, user *auth.User) (*auth.TokenResponse, error) { claims := JWTClaims{ UserID: user.ID, Email: user.Email, Role: user.Role, Tier: user.Tier, RegisteredClaims: jwt.RegisteredClaims{ Subject: user.ID, IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(accessTokenTTL)), Issuer: "gooseek", }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) accessToken, err := token.SignedString([]byte(jwtSecret)) if err != nil { return nil, err } userAgent := c.Get("User-Agent") ip := c.IP() refreshToken, err := repo.CreateRefreshToken(c.Context(), user.ID, userAgent, ip, refreshTokenTTL) if err != nil { return nil, err } return &auth.TokenResponse{ AccessToken: accessToken, RefreshToken: refreshToken.Token, ExpiresIn: int(accessTokenTTL.Seconds()), TokenType: "Bearer", User: user, }, nil } func validateJWT(tokenString string) (*JWTClaims, error) { token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return []byte(jwtSecret), nil }) if err != nil { return nil, err } claims, ok := token.Claims.(*JWTClaims) if !ok || !token.Valid { return nil, fmt.Errorf("invalid token") } return claims, nil } func extractToken(c *fiber.Ctx) string { auth := c.Get("Authorization") if len(auth) > 7 && auth[:7] == "Bearer " { return auth[7:] } return c.Query("token") } func jwtMiddleware(c *fiber.Ctx) error { tokenString := extractToken(c) if tokenString == "" { return c.Status(401).JSON(fiber.Map{"error": "Missing authorization token"}) } claims, err := validateJWT(tokenString) if err != nil { return c.Status(401).JSON(fiber.Map{"error": "Invalid token"}) } c.Locals("userId", claims.UserID) c.Locals("userEmail", claims.Email) c.Locals("userRole", claims.Role) c.Locals("userTier", claims.Tier) return c.Next() }