Add optional email verification and registration control
Implemented email service infrastructure with SMTP support and optional email verification for self-hosted deployments. Registration flow now supports open/closed modes and hardcoded Apocapoc branding. Key features: - Email service with SMTP and template rendering - Optional email verification (auto-verified without SMTP config) - Registration modes: open/closed for access control - Hardcoded Apocapoc branding (AppName, AppURL, DefaultFrom) - Separate registration and login flows (registration no longer returns tokens)
This commit is contained in:
@@ -2,6 +2,10 @@ package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
@@ -16,38 +20,119 @@ type RegisterUserCommand struct {
|
||||
Timezone string
|
||||
}
|
||||
|
||||
type RegisterUserHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
passwordHasher services.PasswordHasher
|
||||
type RegisterUserResult struct {
|
||||
UserID string
|
||||
EmailVerificationRequired bool
|
||||
}
|
||||
|
||||
func NewRegisterUserHandler(userRepo repositories.UserRepository, passwordHasher services.PasswordHasher) *RegisterUserHandler {
|
||||
type RegisterUserHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
passwordHasher services.PasswordHasher
|
||||
emailService services.EmailService
|
||||
appURL string
|
||||
registrationMode string
|
||||
}
|
||||
|
||||
func NewRegisterUserHandler(
|
||||
userRepo repositories.UserRepository,
|
||||
passwordHasher services.PasswordHasher,
|
||||
emailService services.EmailService,
|
||||
appURL string,
|
||||
registrationMode string,
|
||||
) *RegisterUserHandler {
|
||||
return &RegisterUserHandler{
|
||||
userRepo: userRepo,
|
||||
passwordHasher: passwordHasher,
|
||||
userRepo: userRepo,
|
||||
passwordHasher: passwordHasher,
|
||||
emailService: emailService,
|
||||
appURL: appURL,
|
||||
registrationMode: registrationMode,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserCommand) (string, error) {
|
||||
func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserCommand) (*RegisterUserResult, error) {
|
||||
if h.registrationMode == "closed" {
|
||||
return nil, errors.ErrRegistrationClosed
|
||||
}
|
||||
|
||||
if err := validation.ValidateRegistration(cmd.Email, cmd.Password, cmd.Timezone); err != nil {
|
||||
return "", errors.ErrInvalidInput
|
||||
return nil, errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
existing, _ := h.userRepo.FindByEmail(ctx, cmd.Email)
|
||||
if existing != nil {
|
||||
return "", errors.ErrAlreadyExists
|
||||
return nil, errors.ErrAlreadyExists
|
||||
}
|
||||
|
||||
hashedPassword, err := h.passwordHasher.Hash(cmd.Password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := entities.NewUser(cmd.Email, hashedPassword, cmd.Timezone)
|
||||
|
||||
if err := h.userRepo.Create(ctx, user); err != nil {
|
||||
return "", err
|
||||
emailVerificationRequired := false
|
||||
if h.emailService != nil {
|
||||
token, err := h.generateVerificationToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate verification token: %w", err)
|
||||
}
|
||||
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
emailVerificationRequired = true
|
||||
} else {
|
||||
user.EmailVerified = true
|
||||
}
|
||||
|
||||
return user.ID, nil
|
||||
if err := h.userRepo.Create(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if h.emailService != nil && user.EmailVerificationToken != nil {
|
||||
if err := h.sendVerificationEmail(user); err != nil {
|
||||
return &RegisterUserResult{
|
||||
UserID: user.ID,
|
||||
EmailVerificationRequired: emailVerificationRequired,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &RegisterUserResult{
|
||||
UserID: user.ID,
|
||||
EmailVerificationRequired: emailVerificationRequired,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *RegisterUserHandler) generateVerificationToken() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func (h *RegisterUserHandler) sendVerificationEmail(user *entities.User) error {
|
||||
if user.EmailVerificationToken == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
verificationLink := fmt.Sprintf("%s/verify-email?token=%s", h.appURL, *user.EmailVerificationToken)
|
||||
|
||||
emailBody := fmt.Sprintf(`
|
||||
<h2>Welcome! Please verify your email</h2>
|
||||
<p>Thank you for registering. Please click the link below to verify your email address:</p>
|
||||
<p><a href="%s">Verify Email</a></p>
|
||||
<p>This link will expire in 24 hours.</p>
|
||||
<p>If you didn't create an account, you can safely ignore this email.</p>
|
||||
`, verificationLink)
|
||||
|
||||
message := services.EmailMessage{
|
||||
To: user.Email,
|
||||
Subject: "Verify your email address",
|
||||
Body: emailBody,
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
return h.emailService.Send(message)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ func (m *mockUserRepo) FindByID(ctx context.Context, id string) (*entities.User,
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||
return nil, appErrors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Create(ctx context.Context, user *entities.User) error {
|
||||
if m.createFunc != nil {
|
||||
return m.createFunc(ctx, user)
|
||||
@@ -61,7 +65,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
||||
},
|
||||
}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
@@ -69,15 +73,19 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
userID, err := handler.Handle(context.Background(), cmd)
|
||||
result, err := handler.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
if result.UserID == "" {
|
||||
t.Error("expected user ID, got empty string")
|
||||
}
|
||||
|
||||
if result.EmailVerificationRequired {
|
||||
t.Error("expected email verification to not be required when emailService is nil")
|
||||
}
|
||||
|
||||
if createdUser == nil {
|
||||
t.Fatal("expected user to be created")
|
||||
}
|
||||
@@ -94,7 +102,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
||||
func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -127,7 +135,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
||||
func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -162,7 +170,7 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
|
||||
func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -198,7 +206,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
|
||||
},
|
||||
}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
@@ -220,7 +228,7 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) {
|
||||
return "", expectedErr
|
||||
},
|
||||
}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
@@ -242,7 +250,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
|
||||
},
|
||||
}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
@@ -259,7 +267,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
|
||||
func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -322,3 +330,19 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed")
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
if err != appErrors.ErrRegistrationClosed {
|
||||
t.Errorf("expected ErrRegistrationClosed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type ResendVerificationEmailCommand struct {
|
||||
Email string
|
||||
}
|
||||
|
||||
type ResendVerificationEmailHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
emailService services.EmailService
|
||||
appURL string
|
||||
}
|
||||
|
||||
func NewResendVerificationEmailHandler(
|
||||
userRepo repositories.UserRepository,
|
||||
emailService services.EmailService,
|
||||
appURL string,
|
||||
) *ResendVerificationEmailHandler {
|
||||
return &ResendVerificationEmailHandler{
|
||||
userRepo: userRepo,
|
||||
emailService: emailService,
|
||||
appURL: appURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ResendVerificationEmailHandler) Handle(ctx context.Context, cmd ResendVerificationEmailCommand) error {
|
||||
if cmd.Email == "" {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
user, err := h.userRepo.FindByEmail(ctx, cmd.Email)
|
||||
if err != nil {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
if user.EmailVerified {
|
||||
return errors.ErrAlreadyExists
|
||||
}
|
||||
|
||||
token, err := generateVerificationToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate verification token: %w", err)
|
||||
}
|
||||
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
if err := h.userRepo.Update(ctx, user); err != nil {
|
||||
return fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
|
||||
verificationLink := fmt.Sprintf("%s/verify-email?token=%s", h.appURL, token)
|
||||
|
||||
emailBody := fmt.Sprintf(`
|
||||
<h2>Verify your email address</h2>
|
||||
<p>Please click the link below to verify your email address:</p>
|
||||
<p><a href="%s">Verify Email</a></p>
|
||||
<p>This link will expire in 24 hours.</p>
|
||||
<p>If you didn't create an account, you can safely ignore this email.</p>
|
||||
`, verificationLink)
|
||||
|
||||
message := services.EmailMessage{
|
||||
To: user.Email,
|
||||
Subject: "Verify your email address",
|
||||
Body: emailBody,
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
if err := h.emailService.Send(message); err != nil {
|
||||
return fmt.Errorf("failed to send verification email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateVerificationToken() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type VerifyEmailCommand struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
type VerifyEmailHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
}
|
||||
|
||||
func NewVerifyEmailHandler(userRepo repositories.UserRepository) *VerifyEmailHandler {
|
||||
return &VerifyEmailHandler{
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VerifyEmailHandler) Handle(ctx context.Context, cmd VerifyEmailCommand) error {
|
||||
if cmd.Token == "" {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
user, err := h.userRepo.FindByVerificationToken(ctx, cmd.Token)
|
||||
if err != nil {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if user.EmailVerified {
|
||||
return errors.ErrAlreadyExists
|
||||
}
|
||||
|
||||
if user.EmailVerificationExpiry == nil || user.EmailVerificationExpiry.Before(time.Now()) {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
user.EmailVerified = true
|
||||
user.EmailVerificationToken = nil
|
||||
user.EmailVerificationExpiry = nil
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
if err := h.userRepo.Update(ctx, user); err != nil {
|
||||
return fmt.Errorf("failed to verify email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -45,6 +45,10 @@ func (h *LoginUserHandler) Handle(ctx context.Context, query LoginUserQuery) (*L
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
if !user.EmailVerified {
|
||||
return nil, errors.ErrEmailNotVerified
|
||||
}
|
||||
|
||||
return &LoginUserResult{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
|
||||
@@ -59,6 +59,10 @@ func (m *mockUserRepositoryForRefresh) FindByEmail(ctx context.Context, email st
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepositoryForRefresh) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockUserRepositoryForRefresh) Update(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user