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
+15
View File
@@ -10,3 +10,18 @@ REFRESH_TOKEN_EXPIRY=7d
CORS_ORIGINS=http://localhost:3000
DEFAULT_TIMEZONE=UTC
# Email Configuration (optional - required for email features)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
# Application Branding (optional - override support email if needed)
SUPPORT_EMAIL=contact@apocapoc.app
# Email Features
SEND_WELCOME_EMAIL=false
# Registration Control
REGISTRATION_MODE=open
+20 -1
View File
@@ -13,8 +13,10 @@ import (
"apocapoc-api/internal/infrastructure/auth"
"apocapoc-api/internal/infrastructure/config"
"apocapoc-api/internal/infrastructure/crypto"
"apocapoc-api/internal/infrastructure/email"
httpInfra "apocapoc-api/internal/infrastructure/http"
"apocapoc-api/internal/infrastructure/persistence/sqlite"
"apocapoc-api/internal/shared/constants"
)
// @title Apocapoc API
@@ -60,12 +62,29 @@ func main() {
jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours)
passwordHasher := crypto.NewBcryptHasher()
var emailService *email.SMTPService
if cfg.SMTPHost != "" {
smtpPort, err := strconv.Atoi(cfg.SMTPPort)
if err != nil {
log.Fatalf("Invalid SMTP_PORT: %v", err)
}
emailService = email.NewSMTPService(email.SMTPConfig{
Host: cfg.SMTPHost,
Port: smtpPort,
Username: cfg.SMTPUser,
Password: cfg.SMTPPassword,
From: constants.DefaultFrom,
SupportEmail: cfg.SupportEmail,
})
}
userRepo := sqlite.NewUserRepository(db.Conn())
habitRepo := sqlite.NewHabitRepository(db.Conn())
entryRepo := sqlite.NewHabitEntryRepository(db.Conn())
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db.Conn())
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher)
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, emailService, constants.AppURL, cfg.RegistrationMode)
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
+2
View File
@@ -35,6 +35,8 @@ require (
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/tools v0.36.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/mail.v2 v2.3.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
+4
View File
@@ -85,10 +85,14 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+93 -8
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 RegisterUserResult struct {
UserID string
EmailVerificationRequired bool
}
type RegisterUserHandler struct {
userRepo repositories.UserRepository
passwordHasher services.PasswordHasher
emailService services.EmailService
appURL string
registrationMode string
}
func NewRegisterUserHandler(userRepo repositories.UserRepository, passwordHasher services.PasswordHasher) *RegisterUserHandler {
func NewRegisterUserHandler(
userRepo repositories.UserRepository,
passwordHasher services.PasswordHasher,
emailService services.EmailService,
appURL string,
registrationMode string,
) *RegisterUserHandler {
return &RegisterUserHandler{
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)
}
return user.ID, nil
expiry := time.Now().Add(24 * time.Hour)
user.EmailVerificationToken = &token
user.EmailVerificationExpiry = &expiry
emailVerificationRequired = true
} else {
user.EmailVerified = true
}
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
}
+3
View File
@@ -7,6 +7,9 @@ type User struct {
Email string
PasswordHash string
Timezone string
EmailVerified bool
EmailVerificationToken *string
EmailVerificationExpiry *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -10,5 +10,6 @@ type UserRepository interface {
Create(ctx context.Context, user *entities.User) error
FindByID(ctx context.Context, id string) (*entities.User, error)
FindByEmail(ctx context.Context, email string) (*entities.User, error)
FindByVerificationToken(ctx context.Context, token string) (*entities.User, error)
Update(ctx context.Context, user *entities.User) error
}
+12
View File
@@ -0,0 +1,12 @@
package services
type EmailMessage struct {
To string
Subject string
Body string
IsHTML bool
}
type EmailService interface {
Send(message EmailMessage) error
}
+14
View File
@@ -16,6 +16,13 @@ type Config struct {
RefreshTokenExpiry string
CORSOrigins string
DefaultTimezone string
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPassword string
SupportEmail string
SendWelcomeEmail string
RegistrationMode string
}
func Load() (*Config, error) {
@@ -30,6 +37,13 @@ func Load() (*Config, error) {
RefreshTokenExpiry: os.Getenv("REFRESH_TOKEN_EXPIRY"),
CORSOrigins: os.Getenv("CORS_ORIGINS"),
DefaultTimezone: os.Getenv("DEFAULT_TIMEZONE"),
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: getEnvOrDefault("SMTP_PORT", "587"),
SMTPUser: os.Getenv("SMTP_USER"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
SupportEmail: getEnvOrDefault("SUPPORT_EMAIL", "contact@apocapoc.app"),
SendWelcomeEmail: getEnvOrDefault("SEND_WELCOME_EMAIL", "false"),
RegistrationMode: getEnvOrDefault("REGISTRATION_MODE", "open"),
}
if cfg.DBPath == "" {
@@ -0,0 +1,76 @@
package email
import (
"crypto/tls"
"fmt"
"time"
"apocapoc-api/internal/domain/services"
"gopkg.in/mail.v2"
)
type SMTPConfig struct {
Host string
Port int
Username string
Password string
From string
SupportEmail string
}
type SMTPService struct {
config SMTPConfig
}
func NewSMTPService(config SMTPConfig) *SMTPService {
return &SMTPService{
config: config,
}
}
func (s *SMTPService) Send(message services.EmailMessage) error {
m := mail.NewMessage()
m.SetHeader("From", s.config.From)
m.SetHeader("To", message.To)
m.SetHeader("Subject", message.Subject)
if message.IsHTML {
m.SetBody("text/html", message.Body)
} else {
m.SetBody("text/plain", message.Body)
}
dialer := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password)
dialer.TLSConfig = &tls.Config{
ServerName: s.config.Host,
}
if err := s.sendWithRetry(dialer, m); err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
return nil
}
func (s *SMTPService) sendWithRetry(dialer *mail.Dialer, message *mail.Message) error {
maxRetries := 3
var lastErr error
for i := 0; i < maxRetries; i++ {
if err := dialer.DialAndSend(message); err == nil {
return nil
} else {
lastErr = err
if i < maxRetries-1 {
time.Sleep(time.Second * time.Duration(i+1))
}
}
}
return lastErr
}
func (s *SMTPService) GetConfig() SMTPConfig {
return s.config
}
@@ -0,0 +1,64 @@
package email
import (
"testing"
"apocapoc-api/internal/domain/services"
)
func TestNewSMTPService(t *testing.T) {
config := SMTPConfig{
Host: "smtp.example.com",
Port: 587,
Username: "user@example.com",
Password: "password",
From: "noreply@example.com",
SupportEmail: "support@example.com",
}
service := NewSMTPService(config)
if service == nil {
t.Fatal("Expected service to be created")
}
if service.GetConfig().Host != config.Host {
t.Errorf("Expected host %s, got %s", config.Host, service.GetConfig().Host)
}
}
func TestSMTPService_MessageConstruction(t *testing.T) {
config := SMTPConfig{
Host: "smtp.example.com",
Port: 587,
Username: "user@example.com",
Password: "password",
From: "noreply@example.com",
SupportEmail: "support@example.com",
}
service := NewSMTPService(config)
message := services.EmailMessage{
To: "recipient@example.com",
Subject: "Test Email",
Body: "<h1>Test</h1>",
IsHTML: true,
}
if message.To == "" {
t.Error("Expected recipient to be set")
}
if message.Subject == "" {
t.Error("Expected subject to be set")
}
if !message.IsHTML {
t.Error("Expected message to be HTML")
}
if service == nil {
t.Fatal("Service should not be nil")
}
}
@@ -0,0 +1,49 @@
package email
import (
"bytes"
"fmt"
"html/template"
)
type TemplateData struct {
AppName string
AppURL string
SupportEmail string
Data map[string]interface{}
}
type TemplateRenderer struct {
appName string
appURL string
supportEmail string
}
func NewTemplateRenderer(appName, appURL, supportEmail string) *TemplateRenderer {
return &TemplateRenderer{
appName: appName,
appURL: appURL,
supportEmail: supportEmail,
}
}
func (r *TemplateRenderer) Render(templateContent string, data map[string]interface{}) (string, error) {
tmpl, err := template.New("email").Parse(templateContent)
if err != nil {
return "", fmt.Errorf("failed to parse template: %w", err)
}
templateData := TemplateData{
AppName: r.appName,
AppURL: r.appURL,
SupportEmail: r.supportEmail,
Data: data,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, templateData); err != nil {
return "", fmt.Errorf("failed to execute template: %w", err)
}
return buf.String(), nil
}
@@ -0,0 +1,97 @@
package email
import (
"strings"
"testing"
)
func TestNewTemplateRenderer(t *testing.T) {
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
if renderer == nil {
t.Fatal("Expected renderer to be created")
}
if renderer.appName != "Test App" {
t.Errorf("Expected app name 'Test App', got '%s'", renderer.appName)
}
}
func TestTemplateRenderer_Render(t *testing.T) {
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
template := `Hello {{.Data.Name}}, welcome to {{.AppName}}!`
data := map[string]interface{}{
"Name": "John",
}
result, err := renderer.Render(template, data)
if err != nil {
t.Fatalf("Failed to render template: %v", err)
}
expected := "Hello John, welcome to Test App!"
if result != expected {
t.Errorf("Expected '%s', got '%s'", expected, result)
}
}
func TestTemplateRenderer_RenderWithAllVariables(t *testing.T) {
renderer := NewTemplateRenderer("My App", "https://myapp.com", "help@myapp.com")
template := `
App: {{.AppName}}
URL: {{.AppURL}}
Support: {{.SupportEmail}}
User: {{.Data.User}}
`
data := map[string]interface{}{
"User": "Alice",
}
result, err := renderer.Render(template, data)
if err != nil {
t.Fatalf("Failed to render template: %v", err)
}
if !strings.Contains(result, "My App") {
t.Error("Expected result to contain app name")
}
if !strings.Contains(result, "https://myapp.com") {
t.Error("Expected result to contain app URL")
}
if !strings.Contains(result, "help@myapp.com") {
t.Error("Expected result to contain support email")
}
if !strings.Contains(result, "Alice") {
t.Error("Expected result to contain user name")
}
}
func TestTemplateRenderer_RenderInvalidTemplate(t *testing.T) {
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
template := `{{.Data.Invalid}}`
data := map[string]interface{}{}
result, err := renderer.Render(template, data)
if err != nil {
t.Fatalf("Template should render even with missing data: %v", err)
}
if result != "<no value>" {
t.Logf("Got result: %s", result)
}
}
func TestTemplateRenderer_RenderSyntaxError(t *testing.T) {
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
template := `{{.Data.Name`
data := map[string]interface{}{}
_, err := renderer.Render(template, data)
if err == nil {
t.Error("Expected error for invalid template syntax")
}
}
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f4f4f4;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #f0f0f0;
}
.header h1 {
margin: 0;
color: #2c3e50;
font-size: 24px;
}
.content {
margin-bottom: 30px;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #3498db;
color: #ffffff !important;
text-decoration: none;
border-radius: 4px;
margin: 20px 0;
font-weight: 600;
}
.button:hover {
background-color: #2980b9;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 2px solid #f0f0f0;
font-size: 12px;
color: #7f8c8d;
}
.footer a {
color: #3498db;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>{{.AppName}}</h1>
</div>
<div class="content">
{{.Content}}
</div>
<div class="footer">
<p>Need help? Contact us at <a href="mailto:{{.SupportEmail}}">{{.SupportEmail}}</a></p>
<p>&copy; {{.AppName}}. All rights reserved.</p>
</div>
</div>
</body>
</html>
+26 -22
View File
@@ -62,6 +62,11 @@ type AuthResponse struct {
UserID string `json:"user_id"`
}
type RegisterResponse struct {
UserID string `json:"user_id"`
Message string `json:"message"`
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
@@ -72,13 +77,14 @@ type LogoutRequest struct {
// Register godoc
// @Summary Register a new user
// @Description Create a new user account and receive both access token and refresh token. Store both tokens securely - the refresh token is used to obtain new access tokens when they expire.
// @Description Create a new user account. If email verification is enabled, you will receive a verification email. Otherwise, you can login immediately.
// @Tags auth
// @Accept json
// @Produce json
// @Param request body RegisterRequest true "Registration data (password requires: min 8 chars, uppercase, lowercase, digit, special char)"
// @Success 201 {object} AuthResponse "Returns access token, refresh token, and user ID"
// @Success 201 {object} RegisterResponse "Returns user ID and message about next steps"
// @Failure 400 {object} ErrorResponse "Invalid input: email format, password requirements, or timezone"
// @Failure 403 {object} ErrorResponse "Registration is closed"
// @Failure 409 {object} ErrorResponse "Email already registered"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /auth/register [post]
@@ -95,7 +101,7 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
Timezone: req.Timezone,
}
userID, err := h.registerHandler.Handle(r.Context(), cmd)
result, err := h.registerHandler.Handle(r.Context(), cmd)
if err != nil {
if err == errors.ErrInvalidInput {
respondError(w, http.StatusBadRequest, "Invalid email or password (min 8 characters)")
@@ -105,31 +111,24 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusConflict, "Email already registered")
return
}
if err == errors.ErrRegistrationClosed {
respondError(w, http.StatusForbidden, "Registration is currently closed")
return
}
respondError(w, http.StatusInternalServerError, "Failed to register user")
return
}
token, err := h.jwtService.GenerateToken(userID, req.Email)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to generate token")
return
var message string
if result.EmailVerificationRequired {
message = "Registration successful. Please check your email to verify your account."
} else {
message = "Registration successful. You can now login."
}
refreshToken, err := queries.CreateRefreshToken(userID, h.refreshTokenExpiry)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to create refresh token")
return
}
if err := h.refreshTokenRepo.Create(r.Context(), refreshToken); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save refresh token")
return
}
respondJSON(w, http.StatusCreated, AuthResponse{
Token: token,
RefreshToken: refreshToken.Token,
UserID: userID,
respondJSON(w, http.StatusCreated, RegisterResponse{
UserID: result.UserID,
Message: message,
})
}
@@ -143,6 +142,7 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
// @Success 200 {object} AuthResponse "Returns access token, refresh token, and user ID"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 401 {object} ErrorResponse "Invalid email or password"
// @Failure 403 {object} ErrorResponse "Email not verified"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /auth/login [post]
func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
@@ -163,6 +163,10 @@ func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "Invalid email or password")
return
}
if err == errors.ErrEmailNotVerified {
respondError(w, http.StatusForbidden, "Please verify your email before logging in")
return
}
respondError(w, http.StatusInternalServerError, "Failed to login")
return
}
@@ -22,15 +22,15 @@ func TestAuthFlow(t *testing.T) {
t.Errorf("Expected status 201, got %d. Body: %s", rr.Code, rr.Body.String())
}
var resp AuthResponse
var resp RegisterResponse
decodeResponse(t, rr, &resp)
if resp.Token == "" {
t.Error("Expected token in response")
}
if resp.UserID == "" {
t.Error("Expected user ID in response")
}
if resp.Message == "" {
t.Error("Expected message in response")
}
})
t.Run("Register duplicate email", func(t *testing.T) {
@@ -10,22 +10,14 @@ func TestHabitEntriesFlow(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
registerBody := RegisterRequest{
Email: "entryuser@example.com",
Password: "Password123!",
Timezone: "UTC",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
var authResp AuthResponse
decodeResponse(t, rr, &authResp)
token := authResp.Token
token := registerAndLogin(t, *ts.Router, "entryuser@example.com", "Password123!")
habitBody := CreateHabitRequest{
Name: "Reading",
Type: "BOOLEAN",
Frequency: "DAILY",
}
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
var habitResp map[string]string
decodeResponse(t, rr, &habitResp)
habitID := habitResp["id"]
@@ -9,15 +9,7 @@ func TestHabitCRUDFlow(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
registerBody := RegisterRequest{
Email: "habituser@example.com",
Password: "Password123!",
Timezone: "UTC",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
var authResp AuthResponse
decodeResponse(t, rr, &authResp)
token := authResp.Token
token := registerAndLogin(t, *ts.Router, "habituser@example.com", "Password123!")
var habitID string
@@ -131,30 +123,22 @@ func TestHabitCRUDFlow(t *testing.T) {
})
t.Run("Access other user's habit", func(t *testing.T) {
registerBody := RegisterRequest{
Email: "otheruser@example.com",
Password: "Password123!",
Timezone: "UTC",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
var authResp AuthResponse
decodeResponse(t, rr, &authResp)
otherToken := authResp.Token
otherToken := registerAndLogin(t, *ts.Router, "otheruser@example.com", "Password123!")
reqBody := CreateHabitRequest{
Name: "Other User Habit",
Type: "BOOLEAN",
Frequency: "DAILY",
}
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, otherToken)
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, otherToken)
var createResp map[string]string
decodeResponse(t, rr, &createResp)
otherHabitID := createResp["id"]
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+otherHabitID, nil, token)
rr2 := makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+otherHabitID, nil, token)
if rr.Code != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", rr.Code)
if rr2.Code != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", rr2.Code)
}
})
}
@@ -41,7 +41,7 @@ func setupTestServer(t *testing.T) *TestServer {
entryRepo := sqlite.NewHabitEntryRepository(db)
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db)
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher)
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, nil, "", "open")
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
@@ -104,3 +104,22 @@ func decodeResponse(t *testing.T, rr *httptest.ResponseRecorder, target interfac
t.Fatalf("Failed to decode response: %v", err)
}
}
func registerAndLogin(t *testing.T, router http.Handler, email, password string) string {
registerBody := RegisterRequest{
Email: email,
Password: password,
Timezone: "UTC",
}
makeRequest(t, router, "POST", "/api/v1/auth/register", registerBody, "")
loginBody := LoginRequest{
Email: email,
Password: password,
}
rr := makeRequest(t, router, "POST", "/api/v1/auth/login", loginBody, "")
var authResp AuthResponse
decodeResponse(t, rr, &authResp)
return authResp.Token
}
@@ -2,6 +2,7 @@ package sqlite
import (
"database/sql"
"fmt"
)
func RunMigrations(db *sql.DB) error {
@@ -18,9 +19,50 @@ func RunMigrations(db *sql.DB) error {
return err
}
}
if err := addEmailVerificationColumns(db); err != nil {
return err
}
return nil
}
func addEmailVerificationColumns(db *sql.DB) error {
columns := []struct {
name string
definition string
}{
{"email_verified", "ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT 0"},
{"email_verification_token", "ALTER TABLE users ADD COLUMN email_verification_token TEXT"},
{"email_verification_expiry", "ALTER TABLE users ADD COLUMN email_verification_expiry DATETIME"},
}
for _, col := range columns {
exists, err := columnExists(db, "users", col.name)
if err != nil {
return err
}
if !exists {
if _, err := db.Exec(col.definition); err != nil {
return err
}
}
}
return nil
}
func columnExists(db *sql.DB, table, column string) (bool, error) {
query := fmt.Sprintf("SELECT COUNT(*) FROM pragma_table_info('%s') WHERE name = ?", table)
var count int
err := db.QueryRow(query, column).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
const createUsersTable = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
@@ -24,8 +24,8 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
user.ID = uuid.New().String()
query := `
INSERT INTO users (id, email, password_hash, timezone, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO users (id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, err := r.db.ExecContext(ctx, query,
@@ -33,6 +33,9 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
user.Email,
user.PasswordHash,
user.Timezone,
user.EmailVerified,
user.EmailVerificationToken,
user.EmailVerificationExpiry,
user.CreatedAt,
user.UpdatedAt,
)
@@ -49,7 +52,7 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.User, error) {
query := `
SELECT id, email, password_hash, timezone, created_at, updated_at
SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
FROM users
WHERE id = ?
`
@@ -60,6 +63,9 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use
&user.Email,
&user.PasswordHash,
&user.Timezone,
&user.EmailVerified,
&user.EmailVerificationToken,
&user.EmailVerificationExpiry,
&user.CreatedAt,
&user.UpdatedAt,
)
@@ -76,7 +82,7 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
query := `
SELECT id, email, password_hash, timezone, created_at, updated_at
SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
FROM users
WHERE email = ?
`
@@ -87,6 +93,9 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti
&user.Email,
&user.PasswordHash,
&user.Timezone,
&user.EmailVerified,
&user.EmailVerificationToken,
&user.EmailVerificationExpiry,
&user.CreatedAt,
&user.UpdatedAt,
)
@@ -101,10 +110,40 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti
return &user, nil
}
func (r *UserRepository) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
query := `
SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
FROM users
WHERE email_verification_token = ?
`
var user entities.User
err := r.db.QueryRowContext(ctx, query, token).Scan(
&user.ID,
&user.Email,
&user.PasswordHash,
&user.Timezone,
&user.EmailVerified,
&user.EmailVerificationToken,
&user.EmailVerificationExpiry,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, errors.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to find user by verification token: %w", err)
}
return &user, nil
}
func (r *UserRepository) Update(ctx context.Context, user *entities.User) error {
query := `
UPDATE users
SET email = ?, password_hash = ?, timezone = ?, updated_at = ?
SET email = ?, password_hash = ?, timezone = ?, email_verified = ?, email_verification_token = ?, email_verification_expiry = ?, updated_at = ?
WHERE id = ?
`
@@ -112,6 +151,9 @@ func (r *UserRepository) Update(ctx context.Context, user *entities.User) error
user.Email,
user.PasswordHash,
user.Timezone,
user.EmailVerified,
user.EmailVerificationToken,
user.EmailVerificationExpiry,
user.UpdatedAt,
user.ID,
)
+7
View File
@@ -0,0 +1,7 @@
package constants
const (
AppName = "Apocapoc"
AppURL = "https://apocapoc.app"
DefaultFrom = "noreply@apocapoc.app"
)
+2
View File
@@ -8,4 +8,6 @@ var (
ErrInvalidInput = errors.New("invalid input")
ErrUnauthorized = errors.New("unauthorized")
ErrInvalidCredentials = errors.New("invalid credentials")
ErrEmailNotVerified = errors.New("email not verified")
ErrRegistrationClosed = errors.New("registration is closed")
)