mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
277 lines
6.8 KiB
Go
277 lines
6.8 KiB
Go
// Package service contains business logic services.
|
|
package service
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"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
|
|
tokenLength = 32
|
|
sessionDuration = 7 * 24 * time.Hour // 7 days
|
|
)
|
|
|
|
// AuthService handles user authentication business logic.
|
|
type AuthService struct {
|
|
userRepo domain.UserRepository
|
|
sessionRepo domain.SessionRepository
|
|
}
|
|
|
|
// NewAuthService creates a new authentication service.
|
|
func NewAuthService(userRepo domain.UserRepository, sessionRepo domain.SessionRepository) *AuthService {
|
|
return &AuthService{
|
|
userRepo: userRepo,
|
|
sessionRepo: sessionRepo,
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// LoginRequest contains the data needed to log in.
|
|
type LoginRequest struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
UserAgent string `json:"-"`
|
|
IPAddress string `json:"-"`
|
|
}
|
|
|
|
// LoginResponse contains the session token.
|
|
type LoginResponse struct {
|
|
Token string `json:"token"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
User UserInfo `json:"user"`
|
|
}
|
|
|
|
// UserInfo contains basic user information.
|
|
type UserInfo struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Email string `json:"email"`
|
|
IsAdmin bool `json:"is_admin"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Login authenticates a user and creates a session.
|
|
func (s *AuthService) Login(req LoginRequest) (*LoginResponse, error) {
|
|
// Find user by email
|
|
user, err := s.userRepo.GetByEmail(req.Email)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("finding user: %w", err)
|
|
}
|
|
if user == nil {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
|
|
// Verify password
|
|
if !verifyPassword(req.Password, user.PasswordHash) {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
|
|
// Generate session token
|
|
token, err := generateToken()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generating token: %w", err)
|
|
}
|
|
|
|
// Create session
|
|
now := time.Now()
|
|
session := &domain.Session{
|
|
ID: uuid.New(),
|
|
UserID: user.ID,
|
|
Token: token,
|
|
ExpiresAt: now.Add(sessionDuration),
|
|
CreatedAt: now,
|
|
UserAgent: req.UserAgent,
|
|
IPAddress: req.IPAddress,
|
|
}
|
|
|
|
if err := s.sessionRepo.Create(session); err != nil {
|
|
return nil, fmt.Errorf("creating session: %w", err)
|
|
}
|
|
|
|
return &LoginResponse{
|
|
Token: token,
|
|
ExpiresAt: session.ExpiresAt,
|
|
User: UserInfo{
|
|
ID: user.ID,
|
|
Email: user.Email,
|
|
IsAdmin: user.IsAdmin,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// Logout invalidates a session.
|
|
func (s *AuthService) Logout(token string) error {
|
|
return s.sessionRepo.Delete(token)
|
|
}
|
|
|
|
// GetUserByToken retrieves the user associated with a session token.
|
|
func (s *AuthService) GetUserByToken(token string) (*domain.User, error) {
|
|
session, err := s.sessionRepo.GetByToken(token)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting session: %w", err)
|
|
}
|
|
if session == nil {
|
|
return nil, nil // Invalid or expired session
|
|
}
|
|
|
|
user, err := s.userRepo.GetByID(session.UserID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting user: %w", err)
|
|
}
|
|
|
|
return user, 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
|
|
}
|
|
|
|
// verifyPassword checks if the password matches the hash.
|
|
func verifyPassword(password, encodedHash string) bool {
|
|
// Parse the encoded hash
|
|
parts := strings.Split(encodedHash, "$")
|
|
if len(parts) != 6 {
|
|
return false
|
|
}
|
|
|
|
var memory, time uint32
|
|
var threads uint8
|
|
_, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Compute hash with same parameters
|
|
computedHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(expectedHash)))
|
|
|
|
// Constant-time comparison
|
|
return subtle.ConstantTimeCompare(expectedHash, computedHash) == 1
|
|
}
|
|
|
|
// generateToken creates a cryptographically secure random token.
|
|
func generateToken() (string, error) {
|
|
bytes := make([]byte, tokenLength)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.URLEncoding.EncodeToString(bytes), 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)
|
|
}
|