feat(auth): Implement comprehensive authentication and authorization middleware

- Add JWT authentication middleware with token validation
- Implement user context extraction methods for user ID, email, and admin status
- Create admin middleware to restrict access to admin-only routes
- Add convenience method to combine authentication and admin authorization
- Update auth middleware to handle token parsing, validation, and context setting
- Enhance error handling for various authentication scenarios
- Add new JWT service and related dependencies in go.mod
This commit is contained in:
ats-tech25 2025-11-05 15:21:56 +00:00
parent c9d20afda8
commit 8309f38999
11 changed files with 842 additions and 10 deletions

33
.env Normal file
View File

@ -0,0 +1,33 @@
# Server Configuration
PORT=8080
HOST=localhost
# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=123
DB_NAME=booking_system
DB_SSLMODE=disable
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key
JWT_EXPIRATION=24h
# Stripe Configuration
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key
# SMTP Configuration
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=your_email@gmail.com
SMTP_PASSWORD=your_app_password
SMTP_FROM=your_email@gmail.com
# Jitsi Configuration
JITSI_BASE_URL=https://meet.jit.si
JITSI_API_KEY=your_jitsi_api_key
JITSI_APP_ID=your_jitsi_app_id
JITSI_PRIVATE_KEY=your_jitsi_private_key

3
go.mod
View File

@ -4,9 +4,11 @@ go 1.25.1
require (
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/joho/godotenv v1.5.1
github.com/spf13/cobra v1.8.0
github.com/stripe/stripe-go/v76 v76.25.0
golang.org/x/crypto v0.31.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
@ -40,7 +42,6 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect

2
go.sum
View File

@ -25,6 +25,8 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=

View File

@ -1,23 +1,147 @@
package middleware
import (
"net/http"
"strings"
"attune-heart-therapy/internal/services"
"github.com/gin-gonic/gin"
)
// AuthMiddleware validates JWT tokens
func AuthMiddleware() gin.HandlerFunc {
// AuthMiddleware creates a middleware for JWT authentication
func AuthMiddleware(jwtService services.JWTService) gin.HandlerFunc {
return func(c *gin.Context) {
// Will be implemented in task 5
c.JSON(501, gin.H{"message": "Auth middleware not implemented yet"})
// Get token from Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authorization header is required",
})
c.Abort()
return
}
// Check if header starts with "Bearer "
tokenParts := strings.SplitN(authHeader, " ", 2)
if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Invalid authorization header format. Expected: Bearer <token>",
})
c.Abort()
return
}
tokenString := tokenParts[1]
if tokenString == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Token is required",
})
c.Abort()
return
}
// Validate the token
claims, err := jwtService.ValidateToken(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Invalid or expired token",
})
c.Abort()
return
}
// Set user information in context for use in handlers
c.Set("user_id", claims.UserID)
c.Set("user_email", claims.Email)
c.Set("is_admin", claims.IsAdmin)
c.Set("jwt_claims", claims)
c.Next()
}
}
// AdminMiddleware ensures user has admin privileges
// GetUserIDFromContext extracts user ID from Gin context
func GetUserIDFromContext(c *gin.Context) (uint, bool) {
userID, exists := c.Get("user_id")
if !exists {
return 0, false
}
id, ok := userID.(uint)
return id, ok
}
// GetUserEmailFromContext extracts user email from Gin context
func GetUserEmailFromContext(c *gin.Context) (string, bool) {
email, exists := c.Get("user_email")
if !exists {
return "", false
}
emailStr, ok := email.(string)
return emailStr, ok
}
// IsAdminFromContext checks if user is admin from Gin context
func IsAdminFromContext(c *gin.Context) bool {
isAdmin, exists := c.Get("is_admin")
if !exists {
return false
}
admin, ok := isAdmin.(bool)
return ok && admin
}
// GetJWTClaimsFromContext extracts JWT claims from Gin context
func GetJWTClaimsFromContext(c *gin.Context) (*services.JWTClaims, bool) {
claims, exists := c.Get("jwt_claims")
if !exists {
return nil, false
}
jwtClaims, ok := claims.(*services.JWTClaims)
return jwtClaims, ok
}
// AdminMiddleware creates a middleware for admin authorization
// This middleware should be used after AuthMiddleware to ensure user is authenticated first
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Will be implemented in task 5
c.JSON(501, gin.H{"message": "Admin middleware not implemented yet"})
// Check if user is authenticated (should be set by AuthMiddleware)
userID, exists := GetUserIDFromContext(c)
if !exists || userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentication required",
})
c.Abort()
return
}
// Check if user has admin privileges
if !IsAdminFromContext(c) {
c.JSON(http.StatusForbidden, gin.H{
"error": "Admin privileges required",
})
c.Abort()
return
}
c.Next()
}
}
// RequireAdmin is a convenience function that combines auth and admin middleware
func RequireAdmin(jwtService services.JWTService) gin.HandlerFunc {
return gin.HandlerFunc(func(c *gin.Context) {
// First authenticate the user
AuthMiddleware(jwtService)(c)
if c.IsAborted() {
return
}
// Then check admin privileges
AdminMiddleware()(c)
})
}

View File

@ -0,0 +1,133 @@
package repositories
import (
"errors"
"fmt"
"time"
"attune-heart-therapy/internal/models"
"gorm.io/gorm"
)
// bookingRepository implements the BookingRepository interface
type bookingRepository struct {
db *gorm.DB
}
// NewBookingRepository creates a new instance of BookingRepository
func NewBookingRepository(db *gorm.DB) BookingRepository {
return &bookingRepository{
db: db,
}
}
// Create creates a new booking in the database
func (r *bookingRepository) Create(booking *models.Booking) error {
if booking == nil {
return errors.New("booking cannot be nil")
}
if err := r.db.Create(booking).Error; err != nil {
return fmt.Errorf("failed to create booking: %w", err)
}
return nil
}
// GetByID retrieves a booking by its ID with user preloaded
func (r *bookingRepository) GetByID(id uint) (*models.Booking, error) {
if id == 0 {
return nil, errors.New("invalid booking ID")
}
var booking models.Booking
if err := r.db.Preload("User").First(&booking, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("booking with ID %d not found", id)
}
return nil, fmt.Errorf("failed to get booking by ID: %w", err)
}
return &booking, nil
}
// GetByUserID retrieves all bookings for a specific user with user preloaded
func (r *bookingRepository) GetByUserID(userID uint) ([]models.Booking, error) {
if userID == 0 {
return nil, errors.New("invalid user ID")
}
var bookings []models.Booking
if err := r.db.Preload("User").Where("user_id = ?", userID).
Order("scheduled_at DESC").Find(&bookings).Error; err != nil {
return nil, fmt.Errorf("failed to get bookings for user %d: %w", userID, err)
}
return bookings, nil
}
// Update updates an existing booking in the database
func (r *bookingRepository) Update(booking *models.Booking) error {
if booking == nil {
return errors.New("booking cannot be nil")
}
if booking.ID == 0 {
return errors.New("booking ID is required for update")
}
// Check if booking exists
var existingBooking models.Booking
if err := r.db.First(&existingBooking, booking.ID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("booking with ID %d not found", booking.ID)
}
return fmt.Errorf("failed to check booking existence: %w", err)
}
// Update the booking
if err := r.db.Save(booking).Error; err != nil {
return fmt.Errorf("failed to update booking: %w", err)
}
return nil
}
// Delete soft deletes a booking by its ID
func (r *bookingRepository) Delete(id uint) error {
if id == 0 {
return errors.New("invalid booking ID")
}
// Check if booking exists
var booking models.Booking
if err := r.db.First(&booking, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("booking with ID %d not found", id)
}
return fmt.Errorf("failed to check booking existence: %w", err)
}
// Soft delete the booking
if err := r.db.Delete(&booking).Error; err != nil {
return fmt.Errorf("failed to delete booking: %w", err)
}
return nil
}
// GetUpcomingBookings retrieves all upcoming bookings for notification scheduling
func (r *bookingRepository) GetUpcomingBookings() ([]models.Booking, error) {
var bookings []models.Booking
// Get bookings that are scheduled and in the future
if err := r.db.Preload("User").
Where("status = ? AND scheduled_at > ?", models.BookingStatusScheduled, time.Now()).
Order("scheduled_at ASC").
Find(&bookings).Error; err != nil {
return nil, fmt.Errorf("failed to get upcoming bookings: %w", err)
}
return bookings, nil
}

View File

@ -31,6 +31,8 @@ type ScheduleRepository interface {
GetAvailable(date time.Time) ([]models.Schedule, error)
Update(schedule *models.Schedule) error
GetByID(id uint) (*models.Schedule, error)
IncrementBookedCount(scheduleID uint) error
DecrementBookedCount(scheduleID uint) error
}
// NotificationRepository handles notification data persistence

View File

@ -0,0 +1,93 @@
package repositories
import (
"errors"
"fmt"
"attune-heart-therapy/internal/models"
"gorm.io/gorm"
)
// notificationRepository implements the NotificationRepository interface
type notificationRepository struct {
db *gorm.DB
}
// NewNotificationRepository creates a new instance of NotificationRepository
func NewNotificationRepository(db *gorm.DB) NotificationRepository {
return &notificationRepository{
db: db,
}
}
// Create creates a new notification in the database
func (r *notificationRepository) Create(notification *models.Notification) error {
if notification == nil {
return errors.New("notification cannot be nil")
}
if err := r.db.Create(notification).Error; err != nil {
return fmt.Errorf("failed to create notification: %w", err)
}
return nil
}
// GetByID retrieves a notification by its ID with user and booking preloaded
func (r *notificationRepository) GetByID(id uint) (*models.Notification, error) {
if id == 0 {
return nil, errors.New("invalid notification ID")
}
var notification models.Notification
if err := r.db.Preload("User").Preload("Booking").First(&notification, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("notification with ID %d not found", id)
}
return nil, fmt.Errorf("failed to get notification by ID: %w", err)
}
return &notification, nil
}
// Update updates an existing notification in the database
func (r *notificationRepository) Update(notification *models.Notification) error {
if notification == nil {
return errors.New("notification cannot be nil")
}
if notification.ID == 0 {
return errors.New("notification ID is required for update")
}
// Check if notification exists
var existingNotification models.Notification
if err := r.db.First(&existingNotification, notification.ID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("notification with ID %d not found", notification.ID)
}
return fmt.Errorf("failed to check notification existence: %w", err)
}
// Update the notification
if err := r.db.Save(notification).Error; err != nil {
return fmt.Errorf("failed to update notification: %w", err)
}
return nil
}
// GetPendingNotifications retrieves all notifications that are ready to be sent
func (r *notificationRepository) GetPendingNotifications() ([]models.Notification, error) {
var notifications []models.Notification
if err := r.db.Preload("User").Preload("Booking").
Where("status = ? AND (scheduled_at IS NULL OR scheduled_at <= NOW())", models.NotificationStatusPending).
Order("created_at ASC").
Find(&notifications).Error; err != nil {
return nil, fmt.Errorf("failed to get pending notifications: %w", err)
}
return notifications, nil
}

View File

@ -0,0 +1,21 @@
package repositories
import "gorm.io/gorm"
// Repositories holds all repository instances
type Repositories struct {
User UserRepository
Booking BookingRepository
Schedule ScheduleRepository
Notification NotificationRepository
}
// NewRepositories creates and returns all repository instances
func NewRepositories(db *gorm.DB) *Repositories {
return &Repositories{
User: NewUserRepository(db),
Booking: NewBookingRepository(db),
Schedule: NewScheduleRepository(db),
Notification: NewNotificationRepository(db),
}
}

View File

@ -0,0 +1,205 @@
package repositories
import (
"errors"
"fmt"
"time"
"attune-heart-therapy/internal/models"
"gorm.io/gorm"
)
// scheduleRepository implements the ScheduleRepository interface
type scheduleRepository struct {
db *gorm.DB
}
// NewScheduleRepository creates a new instance of ScheduleRepository
func NewScheduleRepository(db *gorm.DB) ScheduleRepository {
return &scheduleRepository{
db: db,
}
}
// Create creates a new schedule slot in the database
func (r *scheduleRepository) Create(schedule *models.Schedule) error {
if schedule == nil {
return errors.New("schedule cannot be nil")
}
// Check for overlapping schedules
var count int64
if err := r.db.Model(&models.Schedule{}).
Where("((start_time <= ? AND end_time > ?) OR (start_time < ? AND end_time >= ?)) AND is_available = ?",
schedule.StartTime, schedule.StartTime, schedule.EndTime, schedule.EndTime, true).
Count(&count).Error; err != nil {
return fmt.Errorf("failed to check for overlapping schedules: %w", err)
}
if count > 0 {
return errors.New("schedule slot overlaps with existing available slot")
}
if err := r.db.Create(schedule).Error; err != nil {
return fmt.Errorf("failed to create schedule: %w", err)
}
return nil
}
// GetAvailable retrieves all available schedule slots for a specific date
func (r *scheduleRepository) GetAvailable(date time.Time) ([]models.Schedule, error) {
var schedules []models.Schedule
// Get the start and end of the day
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
endOfDay := startOfDay.Add(24 * time.Hour)
if err := r.db.Where("is_available = ? AND start_time >= ? AND start_time < ? AND booked_count < max_bookings",
true, startOfDay, endOfDay).
Order("start_time ASC").
Find(&schedules).Error; err != nil {
return nil, fmt.Errorf("failed to get available schedules for date %s: %w", date.Format("2006-01-02"), err)
}
// Filter out slots that are in the past
now := time.Now()
var availableSchedules []models.Schedule
for _, schedule := range schedules {
if schedule.StartTime.After(now) && schedule.IsAvailableForBooking() {
availableSchedules = append(availableSchedules, schedule)
}
}
return availableSchedules, nil
}
// Update updates an existing schedule slot in the database
func (r *scheduleRepository) Update(schedule *models.Schedule) error {
if schedule == nil {
return errors.New("schedule cannot be nil")
}
if schedule.ID == 0 {
return errors.New("schedule ID is required for update")
}
// Check if schedule exists
var existingSchedule models.Schedule
if err := r.db.First(&existingSchedule, schedule.ID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("schedule with ID %d not found", schedule.ID)
}
return fmt.Errorf("failed to check schedule existence: %w", err)
}
// If updating time slots, check for overlaps (excluding current schedule)
if schedule.StartTime != existingSchedule.StartTime || schedule.EndTime != existingSchedule.EndTime {
var count int64
if err := r.db.Model(&models.Schedule{}).
Where("id != ? AND ((start_time <= ? AND end_time > ?) OR (start_time < ? AND end_time >= ?)) AND is_available = ?",
schedule.ID, schedule.StartTime, schedule.StartTime, schedule.EndTime, schedule.EndTime, true).
Count(&count).Error; err != nil {
return fmt.Errorf("failed to check for overlapping schedules: %w", err)
}
if count > 0 {
return errors.New("updated schedule slot would overlap with existing available slot")
}
}
// Update the schedule
if err := r.db.Save(schedule).Error; err != nil {
return fmt.Errorf("failed to update schedule: %w", err)
}
return nil
}
// GetByID retrieves a schedule slot by its ID
func (r *scheduleRepository) GetByID(id uint) (*models.Schedule, error) {
if id == 0 {
return nil, errors.New("invalid schedule ID")
}
var schedule models.Schedule
if err := r.db.First(&schedule, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("schedule with ID %d not found", id)
}
return nil, fmt.Errorf("failed to get schedule by ID: %w", err)
}
return &schedule, nil
}
// IncrementBookedCount atomically increments the booked count for a schedule slot
// This method handles concurrent booking scenarios
func (r *scheduleRepository) IncrementBookedCount(scheduleID uint) error {
if scheduleID == 0 {
return errors.New("invalid schedule ID")
}
// Use a transaction to ensure atomicity
return r.db.Transaction(func(tx *gorm.DB) error {
var schedule models.Schedule
// Lock the row for update to prevent race conditions
if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&schedule, scheduleID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("schedule with ID %d not found", scheduleID)
}
return fmt.Errorf("failed to get schedule for update: %w", err)
}
// Check if slot is still available
if !schedule.IsAvailableForBooking() {
return errors.New("schedule slot is no longer available for booking")
}
// Increment booked count
schedule.BookedCount++
if err := tx.Save(&schedule).Error; err != nil {
return fmt.Errorf("failed to increment booked count: %w", err)
}
return nil
})
}
// DecrementBookedCount atomically decrements the booked count for a schedule slot
// This method is used when a booking is cancelled
func (r *scheduleRepository) DecrementBookedCount(scheduleID uint) error {
if scheduleID == 0 {
return errors.New("invalid schedule ID")
}
// Use a transaction to ensure atomicity
return r.db.Transaction(func(tx *gorm.DB) error {
var schedule models.Schedule
// Lock the row for update to prevent race conditions
if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&schedule, scheduleID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("schedule with ID %d not found", scheduleID)
}
return fmt.Errorf("failed to get schedule for update: %w", err)
}
// Check if booked count can be decremented
if schedule.BookedCount <= 0 {
return errors.New("booked count is already zero")
}
// Decrement booked count
schedule.BookedCount--
if err := tx.Save(&schedule).Error; err != nil {
return fmt.Errorf("failed to decrement booked count: %w", err)
}
return nil
})
}

View File

@ -0,0 +1,112 @@
package repositories
import (
"errors"
"fmt"
"attune-heart-therapy/internal/models"
"gorm.io/gorm"
)
// userRepository implements the UserRepository interface
type userRepository struct {
db *gorm.DB
}
// NewUserRepository creates a new instance of UserRepository
func NewUserRepository(db *gorm.DB) UserRepository {
return &userRepository{
db: db,
}
}
// Create creates a new user in the database
func (r *userRepository) Create(user *models.User) error {
if user == nil {
return errors.New("user cannot be nil")
}
if err := r.db.Create(user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return fmt.Errorf("user with email %s already exists", user.Email)
}
return fmt.Errorf("failed to create user: %w", err)
}
return nil
}
// GetByID retrieves a user by their ID
func (r *userRepository) GetByID(id uint) (*models.User, error) {
if id == 0 {
return nil, errors.New("invalid user ID")
}
var user models.User
if err := r.db.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("user with ID %d not found", id)
}
return nil, fmt.Errorf("failed to get user by ID: %w", err)
}
return &user, nil
}
// GetByEmail retrieves a user by their email address
func (r *userRepository) GetByEmail(email string) (*models.User, error) {
if email == "" {
return nil, errors.New("email cannot be empty")
}
var user models.User
if err := r.db.Where("email = ?", email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("user with email %s not found", email)
}
return nil, fmt.Errorf("failed to get user by email: %w", err)
}
return &user, nil
}
// Update updates an existing user in the database
func (r *userRepository) Update(user *models.User) error {
if user == nil {
return errors.New("user cannot be nil")
}
if user.ID == 0 {
return errors.New("user ID is required for update")
}
// Check if user exists
var existingUser models.User
if err := r.db.First(&existingUser, user.ID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("user with ID %d not found", user.ID)
}
return fmt.Errorf("failed to check user existence: %w", err)
}
// Update the user
if err := r.db.Save(user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return fmt.Errorf("user with email %s already exists", user.Email)
}
return fmt.Errorf("failed to update user: %w", err)
}
return nil
}
// GetActiveUsersCount returns the count of active (non-deleted) users
func (r *userRepository) GetActiveUsersCount() (int64, error) {
var count int64
if err := r.db.Model(&models.User{}).Count(&count).Error; err != nil {
return 0, fmt.Errorf("failed to get active users count: %w", err)
}
return count, nil
}

View File

@ -0,0 +1,106 @@
package services
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
// JWTClaims represents the JWT token claims
type JWTClaims struct {
UserID uint `json:"user_id"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
jwt.RegisteredClaims
}
// JWTService handles JWT token operations
type JWTService interface {
GenerateToken(userID uint, email string, isAdmin bool) (string, error)
ValidateToken(tokenString string) (*JWTClaims, error)
RefreshToken(tokenString string) (string, error)
}
type jwtService struct {
secretKey string
expiration time.Duration
}
// NewJWTService creates a new JWT service instance
func NewJWTService(secretKey string, expiration time.Duration) JWTService {
return &jwtService{
secretKey: secretKey,
expiration: expiration,
}
}
// GenerateToken creates a new JWT token for the given user
func (j *jwtService) GenerateToken(userID uint, email string, isAdmin bool) (string, error) {
if j.secretKey == "" {
return "", errors.New("JWT secret key is not configured")
}
now := time.Now()
claims := &JWTClaims{
UserID: userID,
Email: email,
IsAdmin: isAdmin,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(j.expiration)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
Issuer: "booking-system",
Subject: email,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(j.secretKey))
if err != nil {
return "", err
}
return tokenString, nil
}
// ValidateToken validates and parses a JWT token
func (j *jwtService) ValidateToken(tokenString string) (*JWTClaims, error) {
if j.secretKey == "" {
return nil, errors.New("JWT secret key is not configured")
}
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
// Validate the signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("invalid signing method")
}
return []byte(j.secretKey), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token claims")
}
// RefreshToken generates a new token from an existing valid token
func (j *jwtService) RefreshToken(tokenString string) (string, error) {
claims, err := j.ValidateToken(tokenString)
if err != nil {
return "", err
}
// Check if token is close to expiration (within 1 hour)
if time.Until(claims.ExpiresAt.Time) > time.Hour {
return "", errors.New("token is not eligible for refresh yet")
}
// Generate new token with same user information
return j.GenerateToken(claims.UserID, claims.Email, claims.IsAdmin)
}