Some checks failed
Build and Deploy GooSeek / build-and-deploy (push) Failing after 8m22s
- Create pkg/email package (sender, templates, types) - SMTP client with TLS, rate limiting, async sending - HTML email templates with GooSeek branding - Integrate welcome + password reset emails in auth-svc - Add limit warning emails (80%/100%) in llm-svc middleware - Add space invite endpoint with email notification in thread-svc - Add GetUserEmail helper in JWT middleware - Add SMTP config to .env, config.go, K8s configmap Made-with: Cursor
485 lines
14 KiB
Go
485 lines
14 KiB
Go
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()
|
|
}
|