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:
2025-11-28 08:21:51 +01:00
parent a95a703905
commit 00f6b51228
28 changed files with 890 additions and 99 deletions
+98 -13
View File
@@ -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
}