mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
feat(core): consolidate auth service into backend and implement stripe billing
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.
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user