// 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) }