feat(story-1.3): add user registration API with Argon2 password hashing

This commit is contained in:
Michael committed 2026-02-04 14:41:42 +01:00
1 parent caa1d885b6
commit e186a4dd87
7 files changed
+361 -4

No files matched your search

@@ -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
+18 -2
View File
@@ -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
+1
View File
@@ -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
+26
View File
@@ -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)
}
+58
View File
@@ -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})
}
+123
View File
@@ -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
}
+133
View File
@@ -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)
}