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
+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,
)