mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-18 12:48:07 +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: Walking Skeleton & User Access
|
||||||
epic-1: in-progress
|
epic-1: in-progress
|
||||||
1-1-project-initialization-walking-skeleton: done
|
1-1-project-initialization-walking-skeleton: done
|
||||||
1-2-database-migration-system: review
|
1-2-database-migration-system: done
|
||||||
1-3-user-registration-api: backlog
|
1-3-user-registration-api: review
|
||||||
1-4-session-authentication: backlog
|
1-4-session-authentication: backlog
|
||||||
1-5-frontend-auth-foundation: backlog
|
1-5-frontend-auth-foundation: backlog
|
||||||
epic-1-retrospective: optional
|
epic-1-retrospective: optional
|
||||||
|
|||||||
+18
-2
@@ -13,6 +13,9 @@ import (
|
|||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/michael/flowreader/internal/config"
|
"github.com/michael/flowreader/internal/config"
|
||||||
"github.com/michael/flowreader/internal/database"
|
"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() {
|
func main() {
|
||||||
@@ -32,6 +35,15 @@ func main() {
|
|||||||
log.Printf("Migration check warning: %v", err)
|
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
|
// Initialize router
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
@@ -44,7 +56,6 @@ func main() {
|
|||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Check database connection
|
|
||||||
if err := pool.Ping(r.Context()); err != nil {
|
if err := pool.Ping(r.Context()); err != nil {
|
||||||
http.Error(w, "Database connection failed", http.StatusServiceUnavailable)
|
http.Error(w, "Database connection failed", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
@@ -54,12 +65,17 @@ func main() {
|
|||||||
w.Write([]byte(`{"status":"ok","service":"flowreader"}`))
|
w.Write([]byte(`{"status":"ok","service":"flowreader"}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
// API routes placeholder
|
// API routes
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Write([]byte(`{"message":"FlowReader API v1"}`))
|
w.Write([]byte(`{"message":"FlowReader API v1"}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Auth routes
|
||||||
|
r.Route("/auth", func(r chi.Router) {
|
||||||
|
r.Post("/register", authHandler.Register)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create server
|
// Create server
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // 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