package auth import ( "context" "crypto/rand" "encoding/base64" "errors" "fmt" "time" "bookra/apps/backend/internal/db" "github.com/golang-jwt/jwt/v5" "github.com/jackc/pgx/v5" "golang.org/x/crypto/bcrypt" ) const ( accessTokenTTL = 24 * time.Hour refreshTokenTTL = 30 * 24 * time.Hour magicLinkTTL = 15 * time.Minute passwordResetTTL = 30 * time.Minute ) var ( ErrInvalidCredentials = errors.New("invalid credentials") ErrInvalidToken = errors.New("invalid or expired token") ErrUserNotFound = errors.New("user not found") ErrEmailAlreadyExists = errors.New("email already exists") ErrPasswordTooShort = errors.New("password must be at least 8 characters") ErrMagicLinkExpired = errors.New("magic link expired") ErrMagicLinkUsed = errors.New("magic link already used") ErrInvalidResetToken = errors.New("invalid or expired reset token") ) type TokenPair struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken,omitempty"` TokenType string `json:"tokenType"` ExpiresIn int `json:"expiresIn"` } type Claims struct { UserID string `json:"sub"` Email string `json:"email"` Name string `json:"name,omitempty"` Role string `json:"role"` Type string `json:"type"` jwt.RegisteredClaims } type Service struct { repo db.Repository jwtSecret []byte } func NewService(repo db.Repository, jwtSecret string) *Service { return &Service{ repo: repo, jwtSecret: []byte(jwtSecret), } } // RegisterWithPassword creates a new user with email and password func (s *Service) RegisterWithPassword(ctx context.Context, email, password, name string) (*db.UserRecord, *TokenPair, error) { if len(password) < 8 { return nil, nil, ErrPasswordTooShort } // Check if user exists existing, err := s.repo.GetUserByEmail(ctx, email) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, nil, err } if existing != nil { return nil, nil, ErrEmailAlreadyExists } // Hash password hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, nil, err } // Create user user, err := s.repo.CreateUser(ctx, email, string(hash), name, "email", "user") if err != nil { return nil, nil, err } // Generate tokens tokens, err := s.generateTokenPair(user.ID.String(), email, name, "user") if err != nil { return nil, nil, err } return user, tokens, nil } // LoginWithPassword authenticates a user with email and password func (s *Service) LoginWithPassword(ctx context.Context, email, password string) (*db.UserRecord, *TokenPair, error) { user, err := s.repo.GetUserByEmail(ctx, email) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil, ErrInvalidCredentials } return nil, nil, err } if user.PasswordHash == nil { return nil, nil, ErrInvalidCredentials } if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil { return nil, nil, ErrInvalidCredentials } // Update last login if err := s.repo.UpdateLastLogin(ctx, user.ID.String()); err != nil { // Log but don't fail } tokens, err := s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role) if err != nil { return nil, nil, err } return user, tokens, nil } // CreateMagicLink generates a magic link for passwordless auth func (s *Service) CreateMagicLink(ctx context.Context, email string) (string, error) { // Get or create user user, err := s.repo.GetUserByEmail(ctx, email) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return "", err } if user == nil { user, err = s.repo.CreateUser(ctx, email, "", "", "email", "user") if err != nil { return "", err } } // Generate token token := generateRandomToken(32) expiresAt := time.Now().Add(magicLinkTTL) if err := s.repo.CreateMagicLink(ctx, token, user.ID.String(), email, expiresAt); err != nil { return "", err } return token, nil } // VerifyMagicLink validates a magic link and returns tokens func (s *Service) VerifyMagicLink(ctx context.Context, token string) (*db.UserRecord, *TokenPair, error) { ml, err := s.repo.GetMagicLink(ctx, token) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil, ErrInvalidToken } return nil, nil, err } if ml.Used { return nil, nil, ErrMagicLinkUsed } if time.Now().After(ml.ExpiresAt) { return nil, nil, ErrMagicLinkExpired } // Mark as used if err := s.repo.MarkMagicLinkUsed(ctx, token); err != nil { return nil, nil, err } // Get user user, err := s.repo.GetUserByID(ctx, ml.UserID.String()) if err != nil { return nil, nil, err } // Mark email as verified if err := s.repo.MarkEmailVerified(ctx, user.ID.String()); err != nil { // Log but don't fail } // Update last login if err := s.repo.UpdateLastLogin(ctx, user.ID.String()); err != nil { // Log but don't fail } tokens, err := s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role) if err != nil { return nil, nil, err } return user, tokens, nil } // RefreshToken refreshes an access token using a refresh token func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (*TokenPair, error) { claims, err := s.ValidateToken(refreshToken) if err != nil { return nil, ErrInvalidToken } if claims.Type != "refresh" { return nil, ErrInvalidToken } user, err := s.repo.GetUserByID(ctx, claims.UserID) if err != nil { return nil, ErrUserNotFound } return s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role) } // ValidateToken validates a JWT token and returns claims func (s *Service) ValidateToken(tokenString string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, 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 s.jwtSecret, nil }) if err != nil { return nil, err } if claims, ok := token.Claims.(*Claims); ok && token.Valid { return claims, nil } return nil, ErrInvalidToken } // GetUser retrieves a user by ID func (s *Service) GetUser(ctx context.Context, userID string) (*db.UserRecord, error) { return s.repo.GetUserByID(ctx, userID) } // IsAdmin checks if the user has admin role func (s *Service) IsAdmin(ctx context.Context, userID string) (bool, error) { user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return false, err } return user.Role == "admin" || user.Role == "superadmin", nil } // generateTokenPair creates access and refresh tokens func (s *Service) generateTokenPair(userID, email, name, role string) (*TokenPair, error) { now := time.Now() // Access token accessClaims := Claims{ UserID: userID, Email: email, Name: name, Role: role, Type: "access", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenTTL)), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), }, } accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) accessTokenString, err := accessToken.SignedString(s.jwtSecret) if err != nil { return nil, err } // Refresh token refreshClaims := Claims{ UserID: userID, Email: email, Role: role, Type: "refresh", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(now.Add(refreshTokenTTL)), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), }, } refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) refreshTokenString, err := refreshToken.SignedString(s.jwtSecret) if err != nil { return nil, err } return &TokenPair{ AccessToken: accessTokenString, RefreshToken: refreshTokenString, TokenType: "Bearer", ExpiresIn: int(accessTokenTTL.Seconds()), }, nil } func generateRandomToken(length int) string { b := make([]byte, length) rand.Read(b) return base64.URLEncoding.EncodeToString(b) } func derefString(s *string) string { if s == nil { return "" } return *s }