From e186a4dd8767ca44099ee77ea2db19303a3394e4 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 4 Feb 2026 14:41:42 +0100 Subject: [PATCH] feat(story-1.3): add user registration API with Argon2 password hashing --- .../sprint-status.yaml | 4 +- cmd/server/main.go | 20 ++- go.mod | 1 + internal/domain/user.go | 26 ++++ internal/handler/auth.go | 58 ++++++++ internal/repository/user.go | 123 ++++++++++++++++ internal/service/auth.go | 133 ++++++++++++++++++ 7 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 internal/domain/user.go create mode 100644 internal/handler/auth.go create mode 100644 internal/repository/user.go create mode 100644 internal/service/auth.go diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 00a9dd5..f2a9c73 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -37,8 +37,8 @@ development_status: # Epic 1: Walking Skeleton & User Access epic-1: in-progress 1-1-project-initialization-walking-skeleton: done - 1-2-database-migration-system: review - 1-3-user-registration-api: backlog + 1-2-database-migration-system: done + 1-3-user-registration-api: review 1-4-session-authentication: backlog 1-5-frontend-auth-foundation: backlog epic-1-retrospective: optional diff --git a/cmd/server/main.go b/cmd/server/main.go index 7687153..399705e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,6 +13,9 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/michael/flowreader/internal/config" "github.com/michael/flowreader/internal/database" + "github.com/michael/flowreader/internal/handler" + "github.com/michael/flowreader/internal/repository" + "github.com/michael/flowreader/internal/service" ) func main() { @@ -32,6 +35,15 @@ func main() { log.Printf("Migration check warning: %v", err) } + // Initialize repositories + userRepo := repository.NewUserRepository(pool) + + // Initialize services + authService := service.NewAuthService(userRepo) + + // Initialize handlers + authHandler := handler.NewAuthHandler(authService) + // Initialize router r := chi.NewRouter() @@ -44,7 +56,6 @@ func main() { // Health check endpoint r.Get("/health", func(w http.ResponseWriter, r *http.Request) { - // Check database connection if err := pool.Ping(r.Context()); err != nil { http.Error(w, "Database connection failed", http.StatusServiceUnavailable) return @@ -54,12 +65,17 @@ func main() { w.Write([]byte(`{"status":"ok","service":"flowreader"}`)) }) - // API routes placeholder + // API routes r.Route("/api/v1", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"message":"FlowReader API v1"}`)) }) + + // Auth routes + r.Route("/auth", func(r chi.Router) { + r.Post("/register", authHandler.Register) + }) }) // Create server diff --git a/go.mod b/go.mod index c20d392..823ce78 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( ) require ( + github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect diff --git a/internal/domain/user.go b/internal/domain/user.go new file mode 100644 index 0000000..3e06d66 --- /dev/null +++ b/internal/domain/user.go @@ -0,0 +1,26 @@ +// Package domain contains business logic entities and interfaces. +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +// User represents a registered user in the system. +type User struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"-"` // Never expose in JSON + IsAdmin bool `json:"is_admin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// UserRepository defines the interface for user data access. +type UserRepository interface { + Create(user *User) error + GetByEmail(email string) (*User, error) + GetByID(id uuid.UUID) (*User, error) + Exists(email string) (bool, error) +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go new file mode 100644 index 0000000..2e70b82 --- /dev/null +++ b/internal/handler/auth.go @@ -0,0 +1,58 @@ +// Package handler contains HTTP handlers for the REST API. +package handler + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/michael/flowreader/internal/service" +) + +// AuthHandler handles authentication-related HTTP requests. +type AuthHandler struct { + authService *service.AuthService +} + +// NewAuthHandler creates a new authentication handler. +func NewAuthHandler(authService *service.AuthService) *AuthHandler { + return &AuthHandler{authService: authService} +} + +// Register handles POST /api/v1/auth/register +func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { + var req service.RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body") + return + } + + resp, err := h.authService.Register(req) + if err != nil { + switch { + case errors.Is(err, service.ErrInvalidEmail): + respondError(w, http.StatusBadRequest, "Invalid email format") + case errors.Is(err, service.ErrPasswordTooShort): + respondError(w, http.StatusBadRequest, "Password must be at least 8 characters") + case errors.Is(err, service.ErrEmailAlreadyExists): + respondError(w, http.StatusConflict, "Email already registered") + default: + respondError(w, http.StatusInternalServerError, "Registration failed") + } + return + } + + respondJSON(w, http.StatusCreated, resp) +} + +// respondJSON writes a JSON response. +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +// respondError writes an error response. +func respondError(w http.ResponseWriter, status int, message string) { + respondJSON(w, status, map[string]string{"error": message}) +} diff --git a/internal/repository/user.go b/internal/repository/user.go new file mode 100644 index 0000000..30b273a --- /dev/null +++ b/internal/repository/user.go @@ -0,0 +1,123 @@ +// Package repository implements data access for domain entities. +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/michael/flowreader/internal/domain" +) + +// UserRepository implements domain.UserRepository using PostgreSQL. +type UserRepository struct { + pool *pgxpool.Pool +} + +// NewUserRepository creates a new user repository. +func NewUserRepository(pool *pgxpool.Pool) *UserRepository { + return &UserRepository{pool: pool} +} + +// Create inserts a new user into the database. +func (r *UserRepository) Create(user *domain.User) error { + ctx := context.Background() + + query := ` + INSERT INTO users (id, email, password_hash, is_admin, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ` + + _, err := r.pool.Exec(ctx, query, + user.ID, + user.Email, + user.PasswordHash, + user.IsAdmin, + user.CreatedAt, + user.UpdatedAt, + ) + + if err != nil { + return fmt.Errorf("creating user: %w", err) + } + + return nil +} + +// GetByEmail retrieves a user by their email address. +func (r *UserRepository) GetByEmail(email string) (*domain.User, error) { + ctx := context.Background() + + query := ` + SELECT id, email, password_hash, is_admin, created_at, updated_at + FROM users + WHERE email = $1 + ` + + var user domain.User + err := r.pool.QueryRow(ctx, query, email).Scan( + &user.ID, + &user.Email, + &user.PasswordHash, + &user.IsAdmin, + &user.CreatedAt, + &user.UpdatedAt, + ) + + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil // User not found + } + return nil, fmt.Errorf("getting user by email: %w", err) + } + + return &user, nil +} + +// GetByID retrieves a user by their ID. +func (r *UserRepository) GetByID(id uuid.UUID) (*domain.User, error) { + ctx := context.Background() + + query := ` + SELECT id, email, password_hash, is_admin, created_at, updated_at + FROM users + WHERE id = $1 + ` + + var user domain.User + err := r.pool.QueryRow(ctx, query, id).Scan( + &user.ID, + &user.Email, + &user.PasswordHash, + &user.IsAdmin, + &user.CreatedAt, + &user.UpdatedAt, + ) + + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil // User not found + } + return nil, fmt.Errorf("getting user by ID: %w", err) + } + + return &user, nil +} + +// Exists checks if a user with the given email exists. +func (r *UserRepository) Exists(email string) (bool, error) { + ctx := context.Background() + + query := `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)` + + var exists bool + err := r.pool.QueryRow(ctx, query, email).Scan(&exists) + if err != nil { + return false, fmt.Errorf("checking user exists: %w", err) + } + + return exists, nil +} diff --git a/internal/service/auth.go b/internal/service/auth.go new file mode 100644 index 0000000..8f2682a --- /dev/null +++ b/internal/service/auth.go @@ -0,0 +1,133 @@ +// Package service contains business logic services. +package service + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "regexp" + "time" + + "github.com/google/uuid" + "github.com/michael/flowreader/internal/domain" + "golang.org/x/crypto/argon2" +) + +// Common errors +var ( + ErrInvalidEmail = errors.New("invalid email format") + ErrPasswordTooShort = errors.New("password must be at least 8 characters") + ErrEmailAlreadyExists = errors.New("email already registered") + ErrUserNotFound = errors.New("user not found") + ErrInvalidCredentials = errors.New("invalid credentials") +) + +// Argon2 parameters (OWASP recommended) +const ( + argon2Time = 1 + argon2Memory = 64 * 1024 // 64MB + argon2Threads = 4 + argon2KeyLen = 32 + saltLength = 16 +) + +// AuthService handles user authentication business logic. +type AuthService struct { + userRepo domain.UserRepository +} + +// NewAuthService creates a new authentication service. +func NewAuthService(userRepo domain.UserRepository) *AuthService { + return &AuthService{userRepo: userRepo} +} + +// RegisterRequest contains the data needed to register a new user. +type RegisterRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +// RegisterResponse contains the registered user data. +type RegisterResponse struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` +} + +// Register creates a new user account. +func (s *AuthService) Register(req RegisterRequest) (*RegisterResponse, error) { + // Validate email format + if !isValidEmail(req.Email) { + return nil, ErrInvalidEmail + } + + // Validate password length + if len(req.Password) < 8 { + return nil, ErrPasswordTooShort + } + + // Check if email already exists + exists, err := s.userRepo.Exists(req.Email) + if err != nil { + return nil, fmt.Errorf("checking email: %w", err) + } + if exists { + return nil, ErrEmailAlreadyExists + } + + // Hash password with Argon2id + passwordHash, err := hashPassword(req.Password) + if err != nil { + return nil, fmt.Errorf("hashing password: %w", err) + } + + // Create user + now := time.Now() + user := &domain.User{ + ID: uuid.New(), + Email: req.Email, + PasswordHash: passwordHash, + IsAdmin: false, + CreatedAt: now, + UpdatedAt: now, + } + + if err := s.userRepo.Create(user); err != nil { + return nil, fmt.Errorf("creating user: %w", err) + } + + return &RegisterResponse{ + ID: user.ID, + Email: user.Email, + CreatedAt: user.CreatedAt, + }, nil +} + +// hashPassword creates an Argon2id hash of the password. +func hashPassword(password string) (string, error) { + salt := make([]byte, saltLength) + if _, err := rand.Read(salt); err != nil { + return "", err + } + + hash := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen) + + // Encode salt and hash together + encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, + argon2Memory, + argon2Time, + argon2Threads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(hash), + ) + + return encoded, nil +} + +// isValidEmail checks if the email has a valid format. +func isValidEmail(email string) bool { + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + return emailRegex.MatchString(email) +}