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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ package entities
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
Timezone string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
Timezone string
|
||||
EmailVerified bool
|
||||
EmailVerificationToken *string
|
||||
EmailVerificationExpiry *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func NewUser(email, passwordHash, timezone string) *User {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package services
|
||||
|
||||
type EmailMessage struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
IsHTML bool
|
||||
}
|
||||
|
||||
type EmailService interface {
|
||||
Send(message EmailMessage) error
|
||||
}
|
||||
@@ -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>© {{.AppName}}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package constants
|
||||
|
||||
const (
|
||||
AppName = "Apocapoc"
|
||||
AppURL = "https://apocapoc.app"
|
||||
DefaultFrom = "noreply@apocapoc.app"
|
||||
)
|
||||
@@ -3,9 +3,11 @@ package errors
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrAlreadyExists = errors.New("resource already exists")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrAlreadyExists = errors.New("resource already exists")
|
||||
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")
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user