Files
Bookra/apps/backend/internal/auth/service.go
T
Tomas Dvorak 164a37e997
CI / Frontend (push) Successful in 9m54s
CI / Go - apps/auth-service (push) Failing after 24s
CI / Go - apps/backend (push) Failing after 5m43s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped
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.
2026-05-09 18:25:25 +02:00

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
}