Files
FlowReader/internal/service/auth.go
T

134 lines
3.3 KiB
Go

// Package service contains business logic services.
package service
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"regexp"
"time"
"github.com/google/uuid"
"github.com/michael/flowreader/internal/domain"
"golang.org/x/crypto/argon2"
)
// Common errors
var (
ErrInvalidEmail = errors.New("invalid email format")
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
ErrEmailAlreadyExists = errors.New("email already registered")
ErrUserNotFound = errors.New("user not found")
ErrInvalidCredentials = errors.New("invalid credentials")
)
// Argon2 parameters (OWASP recommended)
const (
argon2Time = 1
argon2Memory = 64 * 1024 // 64MB
argon2Threads = 4
argon2KeyLen = 32
saltLength = 16
)
// AuthService handles user authentication business logic.
type AuthService struct {
userRepo domain.UserRepository
}
// NewAuthService creates a new authentication service.
func NewAuthService(userRepo domain.UserRepository) *AuthService {
return &AuthService{userRepo: userRepo}
}
// RegisterRequest contains the data needed to register a new user.
type RegisterRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// RegisterResponse contains the registered user data.
type RegisterResponse struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
// Register creates a new user account.
func (s *AuthService) Register(req RegisterRequest) (*RegisterResponse, error) {
// Validate email format
if !isValidEmail(req.Email) {
return nil, ErrInvalidEmail
}
// Validate password length
if len(req.Password) < 8 {
return nil, ErrPasswordTooShort
}
// Check if email already exists
exists, err := s.userRepo.Exists(req.Email)
if err != nil {
return nil, fmt.Errorf("checking email: %w", err)
}
if exists {
return nil, ErrEmailAlreadyExists
}
// Hash password with Argon2id
passwordHash, err := hashPassword(req.Password)
if err != nil {
return nil, fmt.Errorf("hashing password: %w", err)
}
// Create user
now := time.Now()
user := &domain.User{
ID: uuid.New(),
Email: req.Email,
PasswordHash: passwordHash,
IsAdmin: false,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.userRepo.Create(user); err != nil {
return nil, fmt.Errorf("creating user: %w", err)
}
return &RegisterResponse{
ID: user.ID,
Email: user.Email,
CreatedAt: user.CreatedAt,
}, nil
}
// hashPassword creates an Argon2id hash of the password.
func hashPassword(password string) (string, error) {
salt := make([]byte, saltLength)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
// Encode salt and hash together
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
argon2Memory,
argon2Time,
argon2Threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
)
return encoded, nil
}
// isValidEmail checks if the email has a valid format.
func isValidEmail(email string) bool {
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
return emailRegex.MatchString(email)
}