Improve email verification flow and error handling

- Send verification email before creating user to prevent orphaned accounts
- Detect SMTP authentication errors and fail fast without retries
- Add field-level validation errors for better frontend UX
- Configure docker-compose with explicit environment variables
- Document dollar sign escaping in .env.example (use $$)
- Implement welcome email on successful verification
- Clean up unnecessary comments
This commit is contained in:
2025-11-28 11:32:51 +01:00
parent 00f6b51228
commit f37c1ac19b
24 changed files with 850 additions and 78 deletions
@@ -0,0 +1,39 @@
package commands
import (
"context"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/errors"
)
type DeleteUserCommand struct {
UserID string
}
type DeleteUserHandler struct {
userRepo repositories.UserRepository
}
func NewDeleteUserHandler(userRepo repositories.UserRepository) *DeleteUserHandler {
return &DeleteUserHandler{
userRepo: userRepo,
}
}
func (h *DeleteUserHandler) Handle(ctx context.Context, cmd DeleteUserCommand) error {
if cmd.UserID == "" {
return errors.ErrInvalidInput
}
user, err := h.userRepo.FindByID(ctx, cmd.UserID)
if err != nil {
return errors.ErrNotFound
}
if err := h.userRepo.Delete(ctx, user.ID); err != nil {
return err
}
return nil
}
+8 -10
View File
@@ -31,6 +31,7 @@ type RegisterUserHandler struct {
emailService services.EmailService
appURL string
registrationMode string
sendWelcomeEmail bool
}
func NewRegisterUserHandler(
@@ -39,6 +40,7 @@ func NewRegisterUserHandler(
emailService services.EmailService,
appURL string,
registrationMode string,
sendWelcomeEmail bool,
) *RegisterUserHandler {
return &RegisterUserHandler{
userRepo: userRepo,
@@ -46,6 +48,7 @@ func NewRegisterUserHandler(
emailService: emailService,
appURL: appURL,
registrationMode: registrationMode,
sendWelcomeEmail: sendWelcomeEmail,
}
}
@@ -55,7 +58,7 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
}
if err := validation.ValidateRegistration(cmd.Email, cmd.Password, cmd.Timezone); err != nil {
return nil, errors.ErrInvalidInput
return nil, fmt.Errorf("%w: %v", errors.ErrInvalidInput, err)
}
existing, _ := h.userRepo.FindByEmail(ctx, cmd.Email)
@@ -81,6 +84,10 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
user.EmailVerificationToken = &token
user.EmailVerificationExpiry = &expiry
emailVerificationRequired = true
if err := h.sendVerificationEmail(user); err != nil {
return nil, fmt.Errorf("failed to send verification email: %w", err)
}
} else {
user.EmailVerified = true
}
@@ -89,15 +96,6 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
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,
@@ -40,6 +40,10 @@ func (m *mockUserRepo) Update(ctx context.Context, user *entities.User) error {
return nil
}
func (m *mockUserRepo) Delete(ctx context.Context, id string) error {
return nil
}
type mockPasswordHasher struct {
hashFunc func(password string) (string, error)
}
@@ -65,7 +69,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
},
}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
cmd := RegisterUserCommand{
Email: "test@example.com",
@@ -102,7 +106,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
tests := []struct {
name string
@@ -125,7 +129,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
}
_, err := handler.Handle(context.Background(), cmd)
if err != appErrors.ErrInvalidInput {
if !errors.Is(err, appErrors.ErrInvalidInput) {
t.Errorf("expected ErrInvalidInput, got %v", err)
}
})
@@ -135,7 +139,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
tests := []struct {
name string
@@ -160,7 +164,7 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
}
_, err := handler.Handle(context.Background(), cmd)
if err != appErrors.ErrInvalidInput {
if !errors.Is(err, appErrors.ErrInvalidInput) {
t.Errorf("expected ErrInvalidInput, got %v", err)
}
})
@@ -170,7 +174,7 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
tests := []struct {
name string
@@ -191,7 +195,7 @@ func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
}
_, err := handler.Handle(context.Background(), cmd)
if err != appErrors.ErrInvalidInput {
if !errors.Is(err, appErrors.ErrInvalidInput) {
t.Errorf("expected ErrInvalidInput, got %v", err)
}
})
@@ -206,7 +210,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
},
}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
cmd := RegisterUserCommand{
Email: "test@example.com",
@@ -215,7 +219,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
}
_, err := handler.Handle(context.Background(), cmd)
if err != appErrors.ErrAlreadyExists {
if !errors.Is(err, appErrors.ErrAlreadyExists) {
t.Errorf("expected ErrAlreadyExists, got %v", err)
}
}
@@ -228,7 +232,7 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) {
return "", expectedErr
},
}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
cmd := RegisterUserCommand{
Email: "test@example.com",
@@ -250,7 +254,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
},
}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
cmd := RegisterUserCommand{
Email: "test@example.com",
@@ -267,7 +271,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
func TestRegisterUserHandler_EdgeCases(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
tests := []struct {
name string
@@ -333,7 +337,7 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed")
handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed", false)
cmd := RegisterUserCommand{
Email: "test@example.com",
@@ -342,7 +346,7 @@ func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
}
_, err := handler.Handle(context.Background(), cmd)
if err != appErrors.ErrRegistrationClosed {
if !errors.Is(err, appErrors.ErrRegistrationClosed) {
t.Errorf("expected ErrRegistrationClosed, got %v", err)
}
}
@@ -0,0 +1,97 @@
package commands
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/shared/errors"
)
type RequestPasswordResetCommand struct {
Email string
}
type RequestPasswordResetHandler struct {
userRepo repositories.UserRepository
passwordResetTokenRepo repositories.PasswordResetTokenRepository
emailService services.EmailService
appURL string
}
func NewRequestPasswordResetHandler(
userRepo repositories.UserRepository,
passwordResetTokenRepo repositories.PasswordResetTokenRepository,
emailService services.EmailService,
appURL string,
) *RequestPasswordResetHandler {
return &RequestPasswordResetHandler{
userRepo: userRepo,
passwordResetTokenRepo: passwordResetTokenRepo,
emailService: emailService,
appURL: appURL,
}
}
func (h *RequestPasswordResetHandler) Handle(ctx context.Context, cmd RequestPasswordResetCommand) 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.ErrEmailNotVerified
}
tokenStr, err := generatePasswordResetToken()
if err != nil {
return fmt.Errorf("failed to generate reset token: %w", err)
}
expiresAt := time.Now().Add(1 * time.Hour)
resetToken := entities.NewPasswordResetToken(user.ID, tokenStr, expiresAt)
if err := h.passwordResetTokenRepo.Create(ctx, resetToken); err != nil {
return fmt.Errorf("failed to save reset token: %w", err)
}
resetLink := fmt.Sprintf("%s/reset-password?token=%s", h.appURL, tokenStr)
emailBody := fmt.Sprintf(`
<h2>Password Reset Request</h2>
<p>You requested to reset your password. Click the link below to reset it:</p>
<p><a href="%s">Reset Password</a></p>
<p>This link will expire in 1 hour.</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`, resetLink)
message := services.EmailMessage{
To: user.Email,
Subject: "Password Reset Request",
Body: emailBody,
IsHTML: true,
}
if err := h.emailService.Send(message); err != nil {
return fmt.Errorf("failed to send reset email: %w", err)
}
return nil
}
func generatePasswordResetToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
@@ -0,0 +1,80 @@
package commands
import (
"context"
"fmt"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/shared/errors"
"apocapoc-api/internal/shared/validation"
)
type ResetPasswordCommand struct {
Token string
NewPassword string
}
type ResetPasswordHandler struct {
userRepo repositories.UserRepository
passwordResetTokenRepo repositories.PasswordResetTokenRepository
passwordHasher services.PasswordHasher
}
func NewResetPasswordHandler(
userRepo repositories.UserRepository,
passwordResetTokenRepo repositories.PasswordResetTokenRepository,
passwordHasher services.PasswordHasher,
) *ResetPasswordHandler {
return &ResetPasswordHandler{
userRepo: userRepo,
passwordResetTokenRepo: passwordResetTokenRepo,
passwordHasher: passwordHasher,
}
}
func (h *ResetPasswordHandler) Handle(ctx context.Context, cmd ResetPasswordCommand) error {
if cmd.Token == "" || cmd.NewPassword == "" {
return errors.ErrInvalidInput
}
if err := validation.ValidatePassword(cmd.NewPassword); err != nil {
return errors.ErrInvalidInput
}
resetToken, err := h.passwordResetTokenRepo.FindByToken(ctx, cmd.Token)
if err != nil {
return errors.ErrInvalidInput
}
if resetToken.IsExpired() {
return errors.ErrInvalidInput
}
if resetToken.IsUsed() {
return errors.ErrInvalidInput
}
user, err := h.userRepo.FindByID(ctx, resetToken.UserID)
if err != nil {
return errors.ErrNotFound
}
hashedPassword, err := h.passwordHasher.Hash(cmd.NewPassword)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
user.PasswordHash = hashedPassword
if err := h.userRepo.Update(ctx, user); err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
resetToken.MarkAsUsed()
if err := h.passwordResetTokenRepo.Update(ctx, resetToken); err != nil {
return fmt.Errorf("failed to mark token as used: %w", err)
}
return nil
}
@@ -5,7 +5,9 @@ import (
"fmt"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/shared/errors"
)
@@ -14,12 +16,20 @@ type VerifyEmailCommand struct {
}
type VerifyEmailHandler struct {
userRepo repositories.UserRepository
userRepo repositories.UserRepository
emailService services.EmailService
sendWelcomeEmail bool
}
func NewVerifyEmailHandler(userRepo repositories.UserRepository) *VerifyEmailHandler {
func NewVerifyEmailHandler(
userRepo repositories.UserRepository,
emailService services.EmailService,
sendWelcomeEmail bool,
) *VerifyEmailHandler {
return &VerifyEmailHandler{
userRepo: userRepo,
userRepo: userRepo,
emailService: emailService,
sendWelcomeEmail: sendWelcomeEmail,
}
}
@@ -50,5 +60,27 @@ func (h *VerifyEmailHandler) Handle(ctx context.Context, cmd VerifyEmailCommand)
return fmt.Errorf("failed to verify email: %w", err)
}
if h.sendWelcomeEmail && h.emailService != nil {
h.sendWelcomeEmailToUser(user)
}
return nil
}
func (h *VerifyEmailHandler) sendWelcomeEmailToUser(user *entities.User) error {
emailBody := fmt.Sprintf(`
<h2>Welcome to Apocapoc!</h2>
<p>Your email has been successfully verified.</p>
<p>You can now start tracking your habits and building better routines.</p>
<p>If you have any questions or need help, please don't hesitate to contact us.</p>
`)
message := services.EmailMessage{
To: user.Email,
Subject: "Welcome to Apocapoc!",
Body: emailBody,
IsHTML: true,
}
return h.emailService.Send(message)
}