mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
feat(story-1.3): add user registration API with Argon2 password hashing
This commit is contained in:
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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user