mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
164a37e997
This commit performs a major architectural refactor by migrating the standalone `auth-service` into the main `backend` application, enabling a unified codebase and simplified deployment. It also introduces comprehensive Stripe billing support and a new administrative dashboard.
Key changes:
- **Architecture**: Deleted `apps/auth-service` and integrated its functionality (JWT, magic links, OAuth, user management) into `apps/backend`.
- **Billing**: Added Stripe integration to `backend`, supporting both monthly and yearly subscription cycles with automatic plan entitlement enforcement (e.g., location limits).
- **Admin Dashboard**: Implemented a new administrative service and API endpoints to manage tenants, users, and view platform-wide statistics.
- **Frontend**:
- Added a new pricing page with monthly/yearly toggle and comparison table.
- Integrated Stripe and Sentry for payments and error tracking.
- Improved dashboard UX/UI and added i18n support for new features.
- Enhanced the public booking flow with better validation and contact form integration.
- **Database**: Added migrations for users, magic links, password resets, OAuth states, admin audit logs, and refresh tokens.
- **DevOps**: Updated environment configurations for Railway and Vercel, and streamlined the project's `package.json` scripts.
320 lines
8.0 KiB
Go
320 lines
8.0 KiB
Go
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
|
|
}
|