Files
home 52134df4d1
Some checks failed
Build and Deploy GooSeek / build-and-deploy (push) Failing after 8m22s
feat: add email notification service with SMTP support
- 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
2026-03-03 02:50:17 +03:00

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()
}