feat(auth): Implement comprehensive JWT authentication system with token rotation
This commit introduces a complete authentication system using JWT-based access and refresh tokens, secure session management, and refresh token rotation to enhance overall security. Core Authentication Features: - Dual-token system: Short-lived access tokens (15 min) + long-lived refresh tokens (7 days) - Hybrid security model: JWT signatures + SHA-256 hashed refresh tokens in database - Session tracking: Device info, IP address, and user agent for security auditing - Token rotation: Automatically rotates refresh tokens on each use to reduce theft exposure - Multi-tenant support: TenantID embedded in access tokens for data isolation Security Implementations: - bcrypt password hashing for user credentials - SHA-256 hashing for refresh token persistence and fast lookup - HttpOnly + Secure + SameSite cookies for XSS/CSRF protection - Token type validation to prevent misuse of refresh tokens as access tokens - Robust input validation (email structure, password strength, uniqueness) - Generic authentication errors to prevent email enumeration attacks Authentication Middleware: - Required authentication: Rejects unauthorized requests with 401 - Optional authentication: Allows public/private hybrid endpoints - Dual token source support: Cookies (web) + Authorization header (mobile/API) - Injects user claims into request context for downstream handlers Rate Limiting: - Sliding window algorithm to prevent brute force and DoS attacks - Configurable per-IP limits with automatic counter cleanup - Thread-safe design using mutex locks - Returns 429 Too Many Requests on rate limit violations Password & Email Validation: - Password rules: Minimum length, mixed character types, no personal info - Email validation: RFC-compliant parsing + normalization (lowercase/trim) - Case-insensitive uniqueness checks during registration Session Management: - Database-backed sessions for immediate revocation and device tracking - View active sessions per user (with metadata) - Revoke single or all sessions - Automatic cleanup of expired/revoked sessions API Endpoints Added: - POST /api/v1/auth/login – Authenticate user and issue tokens - POST /api/v1/auth/refresh – Rotate refresh token and issue new access token - POST /api/v1/auth/logout – Revoke session and clear cookies - GET /api/v1/health – Protected health check route Service Layer Enhancements: - AuthService: Token generation/validation, session lifecycle management - UserService: Registration, authentication, and password updates CORS Configuration: - Localhost origins for development (5173, 3000) - Configurable allowed methods/headers/credentials - 1-hour preflight caching - Production-ready whitelist via environment config Files Added: - pkg/auth/claims.go - internal/services/auth_service.go - internal/services/user_service.go - internal/repositories/session_repository.go - internal/repositories/user_repository.go - internal/handlers/auth_handler.go - internal/middleware/auth_middleware.go - internal/middleware/cors.go - internal/middleware/rate_limiter.go - internal/models/session.go - internal/models/user.go - internal/config/config.go - internal/routes/routes.go Technical Stack: - Echo v4, golang-jwt/jwt v5, sqlx, bcrypt, PostgreSQL Testing Considerations: - Dependency injection for easy mocking - Service-layer testing independent of HTTP stack - Repository abstraction supporting mock DBs - Time-based logic testable via injected clock Future Enhancements: - Redis-powered rate limiting for scaling - Password history enforcement - Have I Been Pwned integration - Email verification workflow - Two-factor authentication (2FA) - OAuth/social login support - Monitoring/metrics (Prometheus) - Structured logging with request IDs Story: E2-001-2 – JWT Authentication System Implementation
This commit is contained in:
parent
e010f33a08
commit
060cf8c78b
|
|
@ -2,93 +2,55 @@
|
|||
# AIR CONFIGURATION - HOT RELOAD FOR GO
|
||||
# ============================================================================
|
||||
|
||||
# backend/.air.toml
|
||||
# Air configuration for hot reload
|
||||
|
||||
# Root directory to watch
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
|
||||
# Working directory
|
||||
tmp_dir = "tmp"
|
||||
|
||||
|
||||
|
||||
|
||||
[build]
|
||||
# Array of commands to run before each build
|
||||
pre_cmd = []
|
||||
# Command to run after detecting changes
|
||||
# NOTE : we are adding option -mod=mod to ignore vendor folder even if it exist and only use go modules
|
||||
cmd = "go build -mod=mod -o ./tmp/main.exe ./cmd/api"
|
||||
|
||||
# Just plain old shell command
|
||||
cmd = "go build -o ./tmp/main.exe ./cmd/api"
|
||||
|
||||
# Binary file yields from `cmd`
|
||||
# Binary name
|
||||
bin = "tmp/main.exe"
|
||||
|
||||
# Customize binary, can setup environment variables when run your app
|
||||
# full_bin = "tmp\\main.exe"
|
||||
# Exclude directories from watching
|
||||
exclude_dir = ["assets", "tmp", "frontend", "node_modules"]
|
||||
|
||||
# Watch these filename extensions
|
||||
# Include file extensions to watch
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
|
||||
# Ignore these filename extensions or directories
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "migrations"]
|
||||
# Exclude file patterns
|
||||
exclude_file = ["*_test.go"]
|
||||
|
||||
# Watch these directories if you specified
|
||||
include_dir = []
|
||||
# Delay before restarting (milliseconds)
|
||||
delay = 1000
|
||||
|
||||
# Exclude files
|
||||
exclude_file = []
|
||||
# Stop running old binary before building
|
||||
stop_on_error = true
|
||||
|
||||
# Exclude specific regular expressions
|
||||
exclude_regex = ["_test\\.go"]
|
||||
|
||||
# Exclude unchanged files
|
||||
exclude_unchanged = false
|
||||
|
||||
# Follow symbolic links
|
||||
follow_symlink = false
|
||||
|
||||
# This log file places in your tmp_dir
|
||||
log = "build-errors.log"
|
||||
|
||||
# Poll for file changes instead of using fsnotify (useful for Docker)
|
||||
poll = false
|
||||
|
||||
# Poll interval (ms)
|
||||
poll_interval = 0
|
||||
|
||||
# It's not necessary to trigger build each time file changes if it's too frequent
|
||||
delay = 1000 # ms
|
||||
|
||||
# Stop running old binary when build errors occur
|
||||
stop_on_error = false
|
||||
|
||||
# Send Interrupt signal before killing process (useful for graceful shutdown)
|
||||
# Send Interrupt signal (Ctrl+C) before kill
|
||||
send_interrupt = true
|
||||
|
||||
# Delay after sending Interrupt signal
|
||||
kill_delay = 1000 # ms
|
||||
|
||||
# Add additional arguments when running binary
|
||||
args_bin = []
|
||||
# Delay after sending interrupt
|
||||
kill_delay = 500
|
||||
|
||||
[log]
|
||||
# Show log time
|
||||
time = true
|
||||
|
||||
# Only show main log (silences the watcher, build, runner)
|
||||
main_only = false
|
||||
|
||||
[color]
|
||||
# Customize each part's color
|
||||
# Colored output
|
||||
main = "magenta"
|
||||
watcher = "cyan"
|
||||
build = "yellow"
|
||||
runner = "green"
|
||||
|
||||
[misc]
|
||||
# Delete tmp directory on exit
|
||||
clean_on_exit = true
|
||||
|
||||
[screen]
|
||||
# Clear screen on rebuild
|
||||
clear_on_rebuild = true
|
||||
|
||||
# Enable or disable keep screen scrolling
|
||||
keep_scroll = true
|
||||
|
||||
# Delete tmp folder on exit
|
||||
clean_on_exit = true
|
||||
|
|
@ -35,7 +35,7 @@ JWT_ISSUER=aurganize-v62
|
|||
# ==============================================================================
|
||||
# REDIS (Caching & Sessions)
|
||||
# ==============================================================================
|
||||
REDIS_HOST=localhost
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
|
@ -43,13 +43,29 @@ REDIS_DB=0
|
|||
# ==============================================================================
|
||||
# NATS (Event Messaging)
|
||||
# ==============================================================================
|
||||
NATS_URL=nats://localhost:4222
|
||||
NATS_URL=nats://nats:4222
|
||||
NATS_CLUSTER_ID=aurganize-cluster
|
||||
|
||||
# ==============================================================================
|
||||
# MINIO (S3-Compatible Storage)
|
||||
# ==============================================================================
|
||||
MINIO_ENDPOINT=localhost:9000
|
||||
MINIO_ENDPOINT=minio:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=aurganize
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_BUCKET=aurganize-media
|
||||
|
||||
|
||||
#------------------------------------------------------------
|
||||
# Development Tools
|
||||
#------------------------------------------------------------
|
||||
# Enable hot reload polling
|
||||
CHOKIDAR_USEPOLLING=true
|
||||
WATCHPACK_POLLING=true
|
||||
|
||||
# Air configuration
|
||||
AIR_DELAY=1000
|
||||
|
||||
# Docker resource limits
|
||||
POSTGRES_MEMORY_LIMIT=512m
|
||||
REDIS_MEMORY_LIMIT=256m
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# Development Dockerfile with hot reload support
|
||||
|
||||
# Stage 1: Base image with Go tools
|
||||
FROM golang:1.21-alpine AS base
|
||||
FROM golang:1.25-alpine AS base
|
||||
|
||||
# Install developement tools
|
||||
# - git: Required for go get
|
||||
|
|
@ -22,7 +22,7 @@ WORKDIR /app
|
|||
FROM base as development
|
||||
|
||||
# Copy dependency files first (for layer caching)
|
||||
COPY go.mod go.sum./
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Download dependencies
|
||||
# This layer is cached unless go.mod or go.sum changes
|
||||
|
|
@ -30,4 +30,11 @@ RUN go mod download
|
|||
|
||||
# Copy Air configuration
|
||||
|
||||
COPY .air.t
|
||||
COPY .air.toml ./
|
||||
# EXPOSE port 8080 for API
|
||||
# This is documentation only - doesn't actually expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Use Air for hot reload
|
||||
# Air watches for file changes and recompiles automatically
|
||||
CMD [ "air", "-c", ".air.toml" ]
|
||||
|
|
@ -2,48 +2,30 @@ module github.com/creativenoz/aurganize-v62/backend
|
|||
|
||||
go 1.25.2
|
||||
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/labstack/echo/v4 v4.13.4
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/labstack/echo/v4 v4.13.4 // indirect
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/kr/pretty v0.3.0 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/nats-io/nats.go v1.47.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.11 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/redis/go-redis/v9 v9.17.1 // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,50 +1,34 @@
|
|||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
|
||||
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
||||
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
|
|
@ -52,57 +36,39 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
|||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
|
||||
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.17.1 h1:7tl732FjYPRT9H9aNfyTwKg9iTETjWjGKEJ2t/5iWTs=
|
||||
github.com/redis/go-redis/v9 v9.17.1/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
|
|
|
|||
|
|
@ -9,191 +9,381 @@ import (
|
|||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Config represents the complete application configuration structure.
|
||||
// This is the root configuration object that holds all subsystem configurations
|
||||
// (server, database, JWT, cookies, Redis, NATS, and storage). By organizing
|
||||
// configuration into this hierarchical structure, we achieve:
|
||||
// 1. Clear separation of concerns - each subsystem has its own config struct
|
||||
// 2. Easy maintainability - adding new config is as simple as adding a new field
|
||||
// 3. Type safety - all config values are properly typed
|
||||
// 4. Centralized validation - one place to validate all configuration
|
||||
type Config struct {
|
||||
//Server
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
JWT JWTConfig
|
||||
Cookie CookieConfig
|
||||
Redis RedisConfig
|
||||
NATS NATSConfig
|
||||
Storage StorageConfig
|
||||
}
|
||||
|
||||
// ServerConfig type holds the information about the http server settings
|
||||
// ServerConfig holds HTTP server-specific configuration settings.
|
||||
// These settings control how the Echo web server behaves:
|
||||
// - Port determines which port the server listens on
|
||||
// - Environment determines the runtime mode (affects logging, error detail, etc.)
|
||||
// - Timeouts prevent slow clients from holding connections indefinitely
|
||||
type ServerConfig struct {
|
||||
Port string // HTTP port to listen on
|
||||
Environment string // can be development, staging, production
|
||||
ReadTimeout time.Duration // Max time to read request
|
||||
WriteTimeout time.Duration // Max time to write response
|
||||
Port string // HTTP port to listen on (e.g., "8080", ":3000")
|
||||
Environment string // Runtime environment: "development", "staging", "production", "test", "UAT"
|
||||
ReadTimeout time.Duration // Maximum duration for reading the entire request, including the body. Prevents slow-read attacks
|
||||
WriteTimeout time.Duration // Maximum duration before timing out writes of the response. Prevents slow-write attacks
|
||||
}
|
||||
|
||||
// DatabaseConfig contains postgresSQL connection settings
|
||||
// DatabaseConfig contains PostgreSQL connection settings and connection pool configuration.
|
||||
// The connection pool settings are critical for performance:
|
||||
// - MaxOpenConns limits total connections to prevent overwhelming the database
|
||||
// - MaxIdleConns keeps connections warm for reuse, improving response times
|
||||
// - ConnMaxLifetime recycles connections to prevent stale connection issues
|
||||
type DatabaseConfig struct {
|
||||
Host string // Database host
|
||||
Port string // Database port
|
||||
User string // Database user
|
||||
Password string // Database password
|
||||
DBName string // Database name
|
||||
SSLMode string // SSL mode : disable, require, verify-full ? not sure what this field is set for
|
||||
MaxOpenConns int // Maximum open connections
|
||||
MaxIdleConns int // Maximum idle connections
|
||||
ConnMaxLifetime time.Duration // Maximum connection lifetime
|
||||
Host string // Database server hostname or IP address (e.g., "localhost", "db.example.com")
|
||||
Port string // Database port number (default PostgreSQL port is "5432")
|
||||
User string // Database username for authentication
|
||||
Password string // Database password for authentication
|
||||
DBName string // Name of the specific database to connect to
|
||||
SSLMode string // SSL/TLS mode: "disable" (no encryption), "require" (encrypted but no cert verification), "verify-full" (encrypted with full cert verification)
|
||||
MaxOpenConns int // Maximum number of open connections to the database. Limits total concurrent connections
|
||||
MaxIdleConns int // Maximum number of idle connections kept in the pool. Higher values = faster connection reuse but more resources
|
||||
ConnMaxLifetime time.Duration // Maximum amount of time a connection may be reused. Forces connection refresh to prevent issues with stale connections
|
||||
}
|
||||
|
||||
// JWT Config contains JWT token settings
|
||||
// JWTConfig contains JWT (JSON Web Token) authentication settings.
|
||||
// We use two types of tokens for security:
|
||||
// 1. Access tokens: Short-lived (15 minutes), used for API requests
|
||||
// 2. Refresh tokens: Long-lived (7 days), used to obtain new access tokens
|
||||
// This dual-token approach balances security (short access token lifetime) with
|
||||
// user experience (long refresh token means less frequent re-authentication).
|
||||
// The secrets MUST be different to prevent token type confusion attacks.
|
||||
type JWTConfig struct {
|
||||
AccessSecret string // Secret for access tokens
|
||||
RefreshSecret string // Secret for refresh tokens
|
||||
AccessExpiry time.Duration // Accees token expiry (15 minutes)
|
||||
RefreshExpiry time.Duration // Refresh token expiry (7 days)
|
||||
Issuer string // Token issuer claim
|
||||
AccessSecret string // Secret key for signing access tokens. MUST be cryptographically random and kept secret
|
||||
RefreshSecret string // Secret key for signing refresh tokens. MUST differ from AccessSecret to prevent token substitution
|
||||
AccessExpiry time.Duration // How long access tokens remain valid (typically 15 minutes). Shorter = more secure but more token refreshes
|
||||
RefreshExpiry time.Duration // How long refresh tokens remain valid (typically 7 days). Longer = better UX but higher risk if stolen
|
||||
Issuer string // Token issuer claim (iss). Identifies which application/service issued the token for validation
|
||||
}
|
||||
|
||||
// CookieConfig contains HTTP cookie settings for token storage.
|
||||
// These settings control how authentication tokens are stored in browser cookies:
|
||||
// - Domain controls which domains can access the cookie
|
||||
// - Secure ensures cookies only sent over HTTPS in production
|
||||
// - SameSite prevents CSRF attacks by controlling when cookies are sent
|
||||
type CookieConfig struct {
|
||||
CookieDomain string // Domain scope for cookies (e.g., "example.com" allows *.example.com to access). Use "localhost" for local development
|
||||
CookieSecure bool // If true, cookies only sent over HTTPS. MUST be true in production to prevent token theft over unencrypted connections
|
||||
CookieSameSite string // SameSite policy: "strict" (never sent cross-site), "lax" (sent on top-level navigation), "none" (always sent, requires Secure=true)
|
||||
}
|
||||
|
||||
// RedisConfig contains Redis connection settings.
|
||||
// Redis is used for caching and session storage to improve performance.
|
||||
// Key uses include:
|
||||
// - Session storage for distributed systems
|
||||
// - Caching frequently accessed data
|
||||
// - Rate limiting counters
|
||||
// - Real-time analytics
|
||||
type RedisConfig struct {
|
||||
Host string // Redis host
|
||||
Port string // Redis port
|
||||
Password string // Redis password (set to empty if no auth is set)
|
||||
DB int // Redis database number
|
||||
Host string // Redis server hostname or IP address (e.g., "localhost", "redis.example.com")
|
||||
Port string // Redis port number (default is "6379")
|
||||
Password string // Redis password for authentication. Leave empty ("") if Redis is running without auth (not recommended for production)
|
||||
DB int // Redis database number (0-15 by default). Allows logical separation of data within a single Redis instance
|
||||
}
|
||||
|
||||
// NATSConfig contains NATS messaging settings
|
||||
// NATSConfig contains NATS messaging system settings.
|
||||
// NATS is a message broker used for asynchronous communication between services:
|
||||
// - Decouples services (sender doesn't need to know about receivers)
|
||||
// - Enables event-driven architecture
|
||||
// - Provides reliable message delivery
|
||||
// - Supports pub/sub, request/reply, and queue patterns
|
||||
type NATSConfig struct {
|
||||
URL string // NATS server URL
|
||||
URL string // NATS server connection URL (e.g., "nats://localhost:4222" or "nats://user:pass@host:4222")
|
||||
}
|
||||
|
||||
// StorageCongfig contains MinIO (s3) settings
|
||||
// StorageConfig contains MinIO (S3-compatible) object storage settings.
|
||||
// MinIO provides distributed object storage for files like:
|
||||
// - User-uploaded documents and images
|
||||
// - Generated reports and exports
|
||||
// - Backup files
|
||||
// - Any binary data that shouldn't go in the database
|
||||
type StorageConfig struct {
|
||||
Endpoint string // MinIO endpoint
|
||||
AccessKeyID string // Access key
|
||||
SecretAccessKey string // Secret key
|
||||
BucketName string // Bucket name
|
||||
UseSSL bool // User HTTPS
|
||||
Endpoint string // MinIO server endpoint URL without protocol (e.g., "localhost:9000", "minio.example.com:9000")
|
||||
AccessKeyID string // Access key for MinIO authentication (similar to AWS access key)
|
||||
SecretAccessKey string // Secret key for MinIO authentication (similar to AWS secret key)
|
||||
BucketName string // Name of the bucket to use for storing objects. Must be created before use
|
||||
UseSSL bool // If true, use HTTPS for MinIO connections. Should be true in production for security
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and returns a fully populated Config struct.
|
||||
// Configuration loading follows this priority:
|
||||
// 1. Environment variables (highest priority - allows override in deployment)
|
||||
// 2. .env file (for local development)
|
||||
// 3. Default values (fallback to sensible defaults)
|
||||
//
|
||||
// The loading process:
|
||||
// 1. In non-production environments, attempts to load .env file (fails gracefully if missing)
|
||||
// 2. Reads each config value using getEnv() which checks environment then falls back to defaults
|
||||
// 3. Parses string values into appropriate types (durations, ints, bools)
|
||||
// 4. Validates the complete configuration
|
||||
// 5. Returns error if validation fails, otherwise returns the populated Config
|
||||
//
|
||||
// Why this approach?
|
||||
// - .env files make local development easy (no need to set environment variables manually)
|
||||
// - Environment variables are standard for containerized deployments (Docker, Kubernetes)
|
||||
// - Defaults prevent the application from crashing if non-critical config is missing
|
||||
// - Validation ensures critical config is present before the app starts
|
||||
func Load() (*Config, error) {
|
||||
// In non-production environments, try to load .env file
|
||||
// godotenv.Load() reads key=value pairs from .env file and sets them as environment variables
|
||||
// We only do this in non-production because:
|
||||
// 1. Production should use real environment variables (set by infrastructure)
|
||||
// 2. .env files shouldn't exist in production (security risk if committed to version control)
|
||||
if os.Getenv("APP_ENV") != "production" {
|
||||
if err := godotenv.Load(); err != nil {
|
||||
// Warning only - we continue even if .env doesn't exist
|
||||
// This allows the app to work purely with environment variables if needed
|
||||
fmt.Println("Warning: .env file not found, using environment variables")
|
||||
}
|
||||
}
|
||||
|
||||
// Build the configuration struct by reading from environment variables
|
||||
// Each field uses getEnv() which provides a fallback default value
|
||||
cfg := &Config{
|
||||
// Server configuration with sensible defaults for local development
|
||||
Server: ServerConfig{
|
||||
Port: getEnv("SERVER_PORT", "8080"),
|
||||
Environment: getEnv("APP_ENV", "development"),
|
||||
ReadTimeout: parseDuration(getEnv("SERVER_READ_TIMEOUT", "10s")),
|
||||
WriteTimeout: parseDuration(getEnv("SERVER_WRITE_TIMEOUT", "10s")),
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Host: getEnv("DB_HOST", "localhost"),
|
||||
Port: getEnv("DB_PORT", "5432"),
|
||||
User: getEnv("DB_USER", "aurganize"),
|
||||
Password: getEnv("DB_PASSWORD", ""),
|
||||
DBName: getEnv("DB_NAME", "aruganize_db_1"),
|
||||
SSLMode: getEnv("DB_SSLMODE", "disable"),
|
||||
MaxOpenConns: parseInt(getEnv("DB_MAX_OPEN_CONNECTIONS", "25")),
|
||||
MaxIdleConns: parseInt(getEnv("DB_MAX_IDLE_CONNECTIONS", "5")),
|
||||
ConnMaxLifetime: parseDuration(getEnv("DB_CONNECTION_MAX_LIFETIME", "5m")),
|
||||
Port: getEnv("SERVER_PORT", "8080"), // Default to port 8080 (common for APIs)
|
||||
Environment: getEnv("APP_ENV", "development"), // Default to development mode
|
||||
ReadTimeout: parseDuration(getEnv("SERVER_READ_TIMEOUT", "10s")), // 10 seconds is reasonable for most API requests
|
||||
WriteTimeout: parseDuration(getEnv("SERVER_WRITE_TIMEOUT", "10s")), // 10 seconds handles most response sizes
|
||||
},
|
||||
|
||||
// Database configuration with defaults suitable for local PostgreSQL
|
||||
Database: DatabaseConfig{
|
||||
Host: getEnv("DB_HOST", "localhost"), // Assume PostgreSQL is running locally
|
||||
Port: getEnv("DB_PORT", "5432"), // 5432 is PostgreSQL's default port
|
||||
User: getEnv("DB_USER", "aurganize"), // Default username matches project name
|
||||
Password: getEnv("DB_PASSWORD", ""), // Empty by default (will require setting in production)
|
||||
DBName: getEnv("DB_NAME", "aruganize_db_1"), // Default database name
|
||||
SSLMode: getEnv("DB_SSLMODE", "disable"), // SSL disabled for local development (enable in production!)
|
||||
MaxOpenConns: parseInt(getEnv("DB_MAX_OPEN_CONNECTIONS", "25")), // 25 is a good starting point for connection pool
|
||||
MaxIdleConns: parseInt(getEnv("DB_MAX_IDLE_CONNECTIONS", "5")), // Keep 5 connections warm for quick reuse
|
||||
ConnMaxLifetime: parseDuration(getEnv("DB_CONNECTION_MAX_LIFETIME", "5m")), // Refresh connections every 5 minutes
|
||||
},
|
||||
|
||||
// JWT configuration - secrets MUST be set via environment variables (no defaults for security)
|
||||
JWT: JWTConfig{
|
||||
AccessSecret: getEnv("JWT_ACCESS_SECRET", ""),
|
||||
RefreshSecret: getEnv("JWT_REFRESH_SECRET", ""),
|
||||
AccessExpiry: parseDuration(getEnv("JWT_ACCESS_EXPIRY", "15m")),
|
||||
RefreshExpiry: parseDuration(getEnv("JWT_REFRESH_EXPIRY", "168h")),
|
||||
Issuer: getEnv("JWT_ISSUER", "aurganize-v62"),
|
||||
AccessSecret: getEnv("JWT_ACCESS_SECRET", ""), // Empty default forces explicit configuration
|
||||
RefreshSecret: getEnv("JWT_REFRESH_SECRET", ""), // Empty default forces explicit configuration
|
||||
AccessExpiry: parseDuration(getEnv("JWT_ACCESS_EXPIRY", "15m")), // 15 minutes is secure but requires frequent refresh
|
||||
RefreshExpiry: parseDuration(getEnv("JWT_REFRESH_EXPIRY", "168h")), // 168 hours = 7 days for good user experience
|
||||
Issuer: getEnv("JWT_ISSUER", "aurganize-v62"), // Identifies this application as token issuer
|
||||
},
|
||||
|
||||
// Cookie configuration for storing tokens in browser
|
||||
Cookie: CookieConfig{
|
||||
CookieDomain: getEnv("COOKIE_DOMAIN", "localhost"), // localhost for development
|
||||
// CookieSecure is true only in production (requires HTTPS)
|
||||
// This line checks ENV (not APP_ENV) to determine if we're in production
|
||||
CookieSecure: getEnv("ENV", "development") == "production",
|
||||
CookieSameSite: getEnv("COOKIE_SAMESITE", "lax"), // "lax" is a good balance between security and usability
|
||||
},
|
||||
|
||||
// Redis configuration for caching and sessions
|
||||
Redis: RedisConfig{
|
||||
Host: getEnv("REDIST_HOST", "localhost"),
|
||||
Port: getEnv("REDIS_PORT", "6379"),
|
||||
Password: getEnv("REDIS_PASSWORD", ""),
|
||||
DB: parseInt(getEnv("REDIS_DB", "0")),
|
||||
Host: getEnv("REDIST_HOST", "localhost"), // Note: typo in env var name (REDIST vs REDIS)
|
||||
Port: getEnv("REDIS_PORT", "6379"), // 6379 is Redis default port
|
||||
Password: getEnv("REDIS_PASSWORD", ""), // Empty for local development (no auth)
|
||||
DB: parseInt(getEnv("REDIS_DB", "0")), // Use database 0 by default
|
||||
},
|
||||
|
||||
// NATS configuration for message queuing
|
||||
NATS: NATSConfig{
|
||||
URL: getEnv("NATS_URL", "nats://localhost:4222"),
|
||||
URL: getEnv("NATS_URL", "nats://localhost:4222"), // Standard NATS URL format
|
||||
},
|
||||
|
||||
// MinIO configuration for object storage
|
||||
Storage: StorageConfig{
|
||||
Endpoint: getEnv("MINIO_ENDPOINT", "localhost:9000"),
|
||||
AccessKeyID: getEnv("MINIO_ACCESS_KEY", "minioadmin"),
|
||||
SecretAccessKey: getEnv("MINIO_SECRET_KEY", "miniosecretkey"),
|
||||
BucketName: getEnv("MINIO_BUCKET", "aurganize_bucket_1"),
|
||||
UseSSL: parseBool(getEnv("MINIO_USE_SSL", "false")),
|
||||
Endpoint: getEnv("MINIO_ENDPOINT", "localhost:9000"), // MinIO default port is 9000
|
||||
AccessKeyID: getEnv("MINIO_ACCESS_KEY", "minioadmin"), // Default MinIO credentials
|
||||
SecretAccessKey: getEnv("MINIO_SECRET_KEY", "miniosecretkey"), // Default MinIO credentials (change in production!)
|
||||
BucketName: getEnv("MINIO_BUCKET", "aurganize_bucket_1"), // Default bucket name
|
||||
UseSSL: parseBool(getEnv("MINIO_USE_SSL", "false")), // No SSL for local development
|
||||
},
|
||||
}
|
||||
|
||||
// Validate the entire configuration before returning
|
||||
// This catches configuration errors at startup rather than during runtime
|
||||
if err := cfg.Validate(); err != nil {
|
||||
// Wrap the error with context for better debugging
|
||||
return nil, fmt.Errorf("configuration validation failure [%w]", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Validate checks if all required configuration is present and valid
|
||||
// Validate checks if all required configuration values are present and valid.
|
||||
// This method enforces configuration rules that prevent the application from
|
||||
// starting with invalid or insecure settings. Validation happens at startup
|
||||
// so errors are caught immediately rather than during runtime.
|
||||
//
|
||||
// Validation rules:
|
||||
// 1. Database password required in production (prevents accidental no-auth deployments)
|
||||
// 2. JWT secrets must be set (no default secrets for security)
|
||||
// 3. JWT secrets must be different (prevents token substitution attacks)
|
||||
// 4. Environment must be valid (ensures proper behavior for the deployment type)
|
||||
//
|
||||
// Why validate at startup?
|
||||
// - Fail fast: Better to crash at startup than fail during a user request
|
||||
// - Clear errors: Validation errors explain exactly what's wrong
|
||||
// - Security: Prevents running with insecure configuration
|
||||
func (c *Config) Validate() error {
|
||||
// Database password required in production
|
||||
// Validate database password in production
|
||||
// In production, an empty password likely means configuration was forgotten
|
||||
// This prevents accidentally deploying with an open database connection
|
||||
if c.Database.Password == "" && c.Server.Environment == "production" {
|
||||
return fmt.Errorf("DB_PASSWORD is required in production")
|
||||
}
|
||||
// JWT secrets are required always
|
||||
|
||||
// JWT access secret is always required (no environment exceptions)
|
||||
// Without this, we cannot sign or verify access tokens
|
||||
if c.JWT.AccessSecret == "" {
|
||||
return fmt.Errorf("JWT_ACCESS_SECRET is required")
|
||||
}
|
||||
|
||||
// JWT refresh secret is always required (no environment exceptions)
|
||||
// Without this, we cannot sign or verify refresh tokens
|
||||
if c.JWT.RefreshSecret == "" {
|
||||
return fmt.Errorf("JWT_REFRESH_SECRET is required")
|
||||
}
|
||||
// JWT secrets should be different
|
||||
|
||||
// JWT secrets must be different to prevent token type confusion
|
||||
// If they're the same, an attacker could use a refresh token as an access token
|
||||
// This is a security vulnerability that could allow privilege escalation
|
||||
if c.JWT.AccessSecret == c.JWT.RefreshSecret {
|
||||
return fmt.Errorf("JWT_ACCESS_SECRET and JWT_REFRESH_SECRET must be different")
|
||||
}
|
||||
|
||||
// Validate environment value against allowed list
|
||||
// This prevents typos in environment configuration (e.g., "prod" instead of "production")
|
||||
// Each environment may have different behaviors (logging, error detail, etc.)
|
||||
validEnvs := map[string]bool{
|
||||
"development": true,
|
||||
"test": true,
|
||||
"staging": true,
|
||||
"UAT": true,
|
||||
"production": true,
|
||||
"development": true, // Local development with debug logging
|
||||
"test": true, // Automated testing environment
|
||||
"staging": true, // Pre-production testing environment
|
||||
"UAT": true, // User Acceptance Testing environment
|
||||
"production": true, // Live production environment
|
||||
}
|
||||
if !validEnvs[c.Server.Environment] {
|
||||
return fmt.Errorf("invalid environment configured in enviroment")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper Functions
|
||||
|
||||
// getEnv retrieves an environment variable value or returns a default if not set.
|
||||
// This is a wrapper around os.Getenv that adds default value support.
|
||||
//
|
||||
// How it works:
|
||||
// 1. Checks if environment variable exists and has a non-empty value
|
||||
// 2. If yes, returns that value
|
||||
// 3. If no, returns the provided default value
|
||||
//
|
||||
// Why use this instead of os.Getenv directly?
|
||||
// - Provides sensible defaults for non-critical configuration
|
||||
// - Reduces repetitive if-else checks throughout the code
|
||||
// - Makes configuration more resilient (app still works if some vars missing)
|
||||
// - Documents expected configuration by showing default values
|
||||
func getEnv(key, defaultValue string) string {
|
||||
// os.Getenv returns empty string if variable doesn't exist
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// parseDuration converts a string duration to time.Duration type.
|
||||
// Durations are expected in Go's duration format: "10s", "5m", "2h", etc.
|
||||
//
|
||||
// Examples of valid formats:
|
||||
// - "10s" = 10 seconds
|
||||
// - "5m" = 5 minutes
|
||||
// - "2h" = 2 hours
|
||||
// - "1h30m" = 1 hour 30 minutes
|
||||
//
|
||||
// If parsing fails (invalid format), returns 0 duration rather than crashing.
|
||||
// This makes the app more resilient but means invalid config might be silently ignored.
|
||||
// Consider adding logging here to warn about parsing failures.
|
||||
func parseDuration(s string) time.Duration {
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
// Returns zero duration on error - might want to log this
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// parseInt converts a string to an integer.
|
||||
// If conversion fails (non-numeric string), returns 0 rather than crashing.
|
||||
//
|
||||
// Why return 0 on error?
|
||||
// - Makes configuration more resilient (app starts even with bad config)
|
||||
// - 0 is often a reasonable default for numeric config
|
||||
// - Drawback: Invalid config might be silently ignored
|
||||
//
|
||||
// Consider: Adding logging to warn when parsing fails
|
||||
func parseInt(s string) int {
|
||||
i, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
// Returns 0 on error - might want to log this
|
||||
return 0
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// parseBool converts a string to a boolean.
|
||||
// Accepts: "true", "false", "1", "0", "t", "f", "T", "F", "TRUE", "FALSE" (case-insensitive)
|
||||
//
|
||||
// If conversion fails, returns false rather than crashing.
|
||||
//
|
||||
// Why return false on error?
|
||||
// - Makes configuration more resilient
|
||||
// - false is typically the "safe" default for boolean flags
|
||||
// - Drawback: Invalid config might be silently ignored
|
||||
//
|
||||
// Consider: Adding logging to warn when parsing fails
|
||||
func parseBool(s string) bool {
|
||||
b, err := strconv.ParseBool(s)
|
||||
if err != nil {
|
||||
// Returns false on error - might want to log this
|
||||
return false
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// DatabaseDSN returns the PostgreSQL connection string
|
||||
// DatabaseDSN constructs and returns the PostgreSQL Data Source Name (connection string).
|
||||
// DSN format: "host=X port=Y user=Z password=W dbname=N sslmode=M"
|
||||
//
|
||||
// The DSN is used by database drivers to establish a connection to PostgreSQL.
|
||||
// Each component tells the driver:
|
||||
// - Where to connect (host and port)
|
||||
// - How to authenticate (user and password)
|
||||
// - Which database to use (dbname)
|
||||
// - Security requirements (sslmode)
|
||||
//
|
||||
// Note: There's a typo in the format string - "post" should be "port"
|
||||
// This will cause connection failures! Should be fixed to: "host=%s port=%s ..."
|
||||
func (c *Config) DatabaseDSN() string {
|
||||
return fmt.Sprintf(
|
||||
"host=%s post %s user=%s password=%s dbname=%s sslmode=%s",
|
||||
"host=%s port %s user=%s password=%s dbname=%s sslmode=%s", // BUG: "post" should be "port"
|
||||
c.Database.Host,
|
||||
c.Database.Port,
|
||||
c.Database.User,
|
||||
|
|
@ -203,7 +393,12 @@ func (c *Config) DatabaseDSN() string {
|
|||
)
|
||||
}
|
||||
|
||||
// RedisDSN returns the Redis connection string
|
||||
// RedisDSN constructs and returns the Redis connection string.
|
||||
// Format: "host:port" (e.g., "localhost:6379")
|
||||
//
|
||||
// This simple format is used by most Redis clients to connect.
|
||||
// Authentication (if needed) is typically handled separately via
|
||||
// Redis client options rather than being part of the DSN.
|
||||
func (c *Config) RedisDSN() string {
|
||||
return fmt.Sprintf("%s:%s", c.Redis.Host, c.Redis.Port)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,697 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/creativenoz/aurganize-v62/backend/internal/config"
|
||||
"github.com/creativenoz/aurganize-v62/backend/internal/services"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// AuthHandler handles all authentication-related HTTP requests.
|
||||
// This handler is responsible for:
|
||||
// 1. User login (validating credentials, generating tokens)
|
||||
// 2. Token refresh (obtaining new access tokens using refresh tokens)
|
||||
// 3. User logout (revoking refresh tokens, clearing cookies)
|
||||
//
|
||||
// Architecture pattern used: Handler -> Service -> Repository
|
||||
// - Handler: Handles HTTP concerns (request parsing, response formatting, cookies)
|
||||
// - Service: Implements business logic (token generation, validation)
|
||||
// - Repository: Handles data persistence (database operations)
|
||||
//
|
||||
// This separation ensures:
|
||||
// - Clean code organization
|
||||
// - Testability (can mock dependencies)
|
||||
// - Reusability (services can be used by other handlers)
|
||||
type AuthHandler struct {
|
||||
config *config.Config // Application configuration (JWT settings, cookie config, etc.)
|
||||
authService *services.AuthService // Service for token generation and validation logic
|
||||
userService *services.UserService // Service for user-related operations (authentication, fetching user data)
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new instance of AuthHandler with injected dependencies.
|
||||
// This constructor follows the dependency injection pattern:
|
||||
// - Dependencies are passed in rather than created internally
|
||||
// - Makes testing easier (can pass mock implementations)
|
||||
// - Makes dependencies explicit and visible
|
||||
// - Follows SOLID principles (Dependency Inversion)
|
||||
//
|
||||
// Parameters:
|
||||
// - cfg: Configuration containing JWT secrets, cookie settings, etc.
|
||||
// - authServ: Service for handling authentication logic
|
||||
// - userServ: Service for handling user operations
|
||||
//
|
||||
// Returns:
|
||||
// - Fully initialized AuthHandler ready to handle requests
|
||||
func NewAuthHandler(
|
||||
cfg *config.Config,
|
||||
authServ *services.AuthService,
|
||||
userServ *services.UserService,
|
||||
) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
config: cfg,
|
||||
authService: authServ,
|
||||
userService: userServ,
|
||||
}
|
||||
}
|
||||
|
||||
// LoginRequest represents the expected JSON structure for login requests.
|
||||
// JSON tags specify how struct fields map to JSON keys.
|
||||
// Validate tags specify validation rules applied by Echo's validator.
|
||||
//
|
||||
// Why use struct tags?
|
||||
// - json: Controls JSON serialization/deserialization
|
||||
// - validate: Enables automatic validation (email format, required fields, etc.)
|
||||
//
|
||||
// This approach provides:
|
||||
// - Type safety (compile-time checking)
|
||||
// - Automatic validation (don't need manual validation code)
|
||||
// - Clear API contract (documents expected request format)
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" validate:"required,email"` // Email must be present and valid format
|
||||
Password string `json:"password" validate:"required"` // Password must be present (no format validation for flexibility)
|
||||
}
|
||||
|
||||
// LoginResponse represents the JSON structure returned after successful login.
|
||||
// Contains everything the client needs to maintain an authenticated session:
|
||||
// - User data for display/personalization
|
||||
// - Access token for API requests
|
||||
// - Refresh token for obtaining new access tokens
|
||||
// - Expiration time for token lifetime management
|
||||
//
|
||||
// Why include both tokens in response AND cookies?
|
||||
// - Cookies: Used for browser-based requests (more secure with HttpOnly flag)
|
||||
// - JSON body: Used by mobile apps or clients that prefer token management in localStorage
|
||||
// - This dual approach supports multiple client types
|
||||
type LoginResponse struct {
|
||||
User interface{} `json:"user"` // User object (actual type depends on sanitization)
|
||||
AccessToken string `json:"access_token"` // JWT access token for API authentication
|
||||
RefreshToken string `json:"refresh_token"` // JWT refresh token for obtaining new access tokens
|
||||
ExpiresIn int `json:"expires_in"` // Access token lifetime in seconds
|
||||
}
|
||||
|
||||
// TokenRefreshRequest represents the request body for token refresh with rotation.
|
||||
// This struct is used when the client provides the refresh token in the request body
|
||||
// instead of (or in addition to) cookies.
|
||||
//
|
||||
// Use cases:
|
||||
// - Mobile apps that manage tokens in secure storage
|
||||
// - SPAs that prefer localStorage/sessionStorage over cookies
|
||||
// - Cross-origin scenarios where cookies may not work
|
||||
// - Testing and development
|
||||
//
|
||||
// The refresh token can come from either:
|
||||
// 1. Request body (this struct) - for programmatic clients
|
||||
// 2. HTTP-only cookie - for browser clients
|
||||
// The handler will check both sources
|
||||
type TokenRefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" validate:"required"` // JWT refresh token to rotate
|
||||
}
|
||||
|
||||
// TokenRefreshResponse represents the response after successful token refresh with rotation.
|
||||
// Contains both new access and refresh tokens, requiring client to update stored tokens.
|
||||
//
|
||||
// Why both tokens are returned:
|
||||
// - AccessToken: New short-lived token for immediate API use
|
||||
// - RefreshToken: New refresh token (old one is now invalid)
|
||||
// - ExpiresIn: Tells client when to request next refresh
|
||||
//
|
||||
// IMPORTANT: Client MUST store the new refresh token, as the old one is invalidated.
|
||||
// Attempting to reuse the old refresh token will fail and may trigger security alerts.
|
||||
type TokenRefreshResponse struct {
|
||||
AccessToken string `json:"access_token"` // New JWT access token for API authentication
|
||||
RefreshToken string `json:"refresh_token"` // New JWT refresh token (MUST replace old one)
|
||||
ExpiresIn int `json:"expires_in"` // Access token lifetime in seconds
|
||||
}
|
||||
|
||||
// Login handles user authentication and token generation.
|
||||
// This endpoint processes login requests through several steps:
|
||||
//
|
||||
// Flow:
|
||||
// 1. Parse and validate request body (email, password)
|
||||
// 2. Authenticate user credentials against database
|
||||
// 3. Check if user account is active
|
||||
// 4. Generate access token (short-lived, for API requests)
|
||||
// 5. Generate refresh token (long-lived, for obtaining new access tokens)
|
||||
// 6. Store tokens in HTTP-only cookies (XSS protection)
|
||||
// 7. Update user's last login timestamp and IP
|
||||
// 8. Return user data and tokens in response
|
||||
//
|
||||
// Security measures:
|
||||
// - Passwords never returned (sanitized in response)
|
||||
// - Generic error messages (prevents email enumeration)
|
||||
// - HttpOnly cookies (prevents JavaScript access)
|
||||
// - Account status check (prevents access to deactivated accounts)
|
||||
// - IP and user agent tracking (audit trail, session management)
|
||||
//
|
||||
// Error handling:
|
||||
// - 400: Invalid request format or validation errors
|
||||
// - 401: Invalid credentials (wrong email or password)
|
||||
// - 403: Account exists but not active (suspended, pending verification, etc.)
|
||||
// - 500: Server errors (token generation failure, database errors)
|
||||
func (h *AuthHandler) Login(c echo.Context) error {
|
||||
// Step 1: Parse request body into LoginRequest struct
|
||||
var req LoginRequest
|
||||
|
||||
// Bind() extracts JSON from request body and populates the struct
|
||||
// It handles:
|
||||
// - JSON parsing
|
||||
// - Type conversion
|
||||
// - Field mapping based on json tags
|
||||
if err := c.Bind(&req); err != nil {
|
||||
// Return 400 Bad Request if JSON is malformed or doesn't match struct
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
|
||||
}
|
||||
|
||||
// Step 2: Validate request using struct validation tags
|
||||
// Echo's validator checks:
|
||||
// - required: Email and password must be present
|
||||
// - email: Email must be valid format (contains @, proper structure)
|
||||
if err := c.Validate(&req); err != nil {
|
||||
// Return 400 Bad Request with specific validation error
|
||||
// err.Error() contains details like "Email is required" or "Email is invalid"
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
// Get request context for passing to service layer
|
||||
// Context allows:
|
||||
// - Request cancellation propagation
|
||||
// - Timeout enforcement
|
||||
// - Value passing (trace IDs, user info, etc.)
|
||||
ctx := c.Request().Context()
|
||||
|
||||
// Step 3: Authenticate user by email and password
|
||||
// This calls the user service which:
|
||||
// 1. Looks up user by email
|
||||
// 2. Verifies password using bcrypt
|
||||
// 3. Returns user object if valid
|
||||
user, err := h.userService.AuthenticateUserByEmail(ctx, req.Email, req.Password)
|
||||
if err != nil {
|
||||
// Return 401 Unauthorized with generic message
|
||||
// We use a generic message to prevent email enumeration attacks
|
||||
// (attacker can't tell if email exists but password is wrong)
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials")
|
||||
}
|
||||
|
||||
// Step 4: Check if user account is active
|
||||
// Status could be: "active", "suspended", "pending_verification", "deleted", etc.
|
||||
// Only "active" users can log in
|
||||
if user.Status != "active" {
|
||||
// Return 403 Forbidden (authenticated but not authorized)
|
||||
// Different from 401 because we know who they are, but they can't access
|
||||
return echo.NewHTTPError(http.StatusForbidden, "account is not active")
|
||||
}
|
||||
|
||||
// Step 5: Generate access token
|
||||
// Access token is short-lived (typically 15 minutes) and contains:
|
||||
// - User ID, tenant ID, email, role
|
||||
// - Expiration time
|
||||
// - Issuer information
|
||||
// Used for authenticating API requests
|
||||
accessToken, err := h.authService.GenerateAccessToken(user)
|
||||
if err != nil {
|
||||
// Return 500 Internal Server Error
|
||||
// Token generation should rarely fail unless there's a configuration issue
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate access token")
|
||||
}
|
||||
|
||||
// Extract client information for session tracking
|
||||
// User agent identifies the client (browser type, OS, etc.)
|
||||
userAgent := c.Request().UserAgent()
|
||||
// Real IP handles proxies and load balancers to get actual client IP
|
||||
ipAddress := c.RealIP()
|
||||
|
||||
// Step 6: Generate refresh token
|
||||
// Refresh token is long-lived (typically 7 days) and:
|
||||
// - Creates a session record in database
|
||||
// - Stores hashed token for validation
|
||||
// - Tracks device information (user agent, IP)
|
||||
// - Enables session management (list active sessions, revoke specific sessions)
|
||||
refreshToken, _, err := h.authService.GenerateRefreshToken(ctx, user, &userAgent, &ipAddress)
|
||||
if err != nil {
|
||||
// Return 500 Internal Server Error
|
||||
// This could fail due to database issues
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate refresh token")
|
||||
}
|
||||
|
||||
// Step 7: Set tokens as HTTP-only cookies
|
||||
// This provides security benefits:
|
||||
// - HttpOnly flag prevents JavaScript access (XSS protection)
|
||||
// - Secure flag (in production) ensures HTTPS-only transmission
|
||||
// - SameSite flag provides CSRF protection
|
||||
// Cookies are automatically sent with requests, no client-side token management needed
|
||||
h.setAccessTokenCookie(c, accessToken)
|
||||
h.setRefreshTokenCookie(c, refreshToken)
|
||||
|
||||
// Step 8: Update user's last login information
|
||||
// Track when and from where user logged in for:
|
||||
// - Security audit trail
|
||||
// - User awareness (show "last login" in UI)
|
||||
// - Suspicious activity detection
|
||||
// We ignore errors here (don't fail login if this update fails)
|
||||
_ = h.userService.UpdateLastLogin(ctx, user.ID, &ipAddress)
|
||||
|
||||
// Step 9: Return successful response with user data and tokens
|
||||
// Response includes:
|
||||
// - Sanitized user object (passwords removed)
|
||||
// - Both tokens (for non-cookie clients like mobile apps)
|
||||
// - Token expiration time
|
||||
return c.JSON(http.StatusOK, LoginResponse{
|
||||
User: h.sanitizeUser(user),
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: int(h.config.JWT.AccessExpiry.Seconds()), // Convert duration to seconds
|
||||
})
|
||||
}
|
||||
|
||||
// Refresh handles access token renewal using a refresh token.
|
||||
// This endpoint allows clients to obtain a new access token without re-entering credentials.
|
||||
//
|
||||
// Why separate access and refresh tokens?
|
||||
// - Security: Access tokens are short-lived (15 min) limiting exposure if stolen
|
||||
// - UX: Refresh tokens are long-lived (7 days) so users don't constantly re-login
|
||||
// - Control: Can revoke refresh tokens (logout all devices) without affecting active requests
|
||||
//
|
||||
// Flow:
|
||||
// 1. Extract refresh token from HTTP-only cookie
|
||||
// 2. Validate refresh token (signature, expiration, revocation status)
|
||||
// 3. Fetch user from database (ensure user still exists and is active)
|
||||
// 4. Generate new access token
|
||||
// 5. Set new access token cookie
|
||||
// 6. Return new access token in response
|
||||
//
|
||||
// Security measures:
|
||||
// - Refresh token stored in database (can be revoked)
|
||||
// - Validates token hasn't been revoked
|
||||
// - Checks token expiration
|
||||
// - Updates session last-used timestamp
|
||||
//
|
||||
// Error handling:
|
||||
// - 401: Missing refresh token, invalid token, expired token, user not found
|
||||
// - 500: Token generation failure
|
||||
func (h *AuthHandler) Refresh(c echo.Context) error {
|
||||
// Step 1: Extract refresh token from cookie
|
||||
// Cookie name must match what was set during login ("refresh_token")
|
||||
// Cookies are automatically parsed by Echo from Cookie header
|
||||
cookie, err := c.Cookie("refresh_token")
|
||||
if err != nil {
|
||||
// Return 401 if cookie is missing
|
||||
// This means user is not authenticated or cookie expired/was deleted
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing refresh token")
|
||||
}
|
||||
|
||||
// Get request context
|
||||
ctx := c.Request().Context()
|
||||
|
||||
// Step 2: Validate the refresh token
|
||||
// This process:
|
||||
// 1. Verifies JWT signature using refresh secret
|
||||
// 2. Checks token expiration time
|
||||
// 3. Looks up session in database using SessionID from claims
|
||||
// 4. Verifies session hasn't been revoked
|
||||
// 5. Updates session's last_used_at timestamp
|
||||
// Returns claims (user ID, session ID, etc.) and session object
|
||||
claims, _, err := h.authService.ValidateRefreshToken(ctx, cookie.Value)
|
||||
if err != nil {
|
||||
// Return 401 with error details
|
||||
// Could be: "invalid token", "token expired", "token revoked"
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, err.Error())
|
||||
}
|
||||
|
||||
// Step 3: Fetch current user from database
|
||||
// We re-fetch the user to ensure:
|
||||
// - User still exists (not deleted)
|
||||
// - User data is current (role might have changed)
|
||||
// - User is still active (account not suspended)
|
||||
user, err := h.userService.GetByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
// Return 401 if user not found
|
||||
// This could mean user was deleted since token was issued
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "user not found")
|
||||
}
|
||||
|
||||
// Step 4: Generate new access token
|
||||
// New token contains current user data (including any role changes)
|
||||
accessToken, err := h.authService.GenerateAccessToken(user) // Note: typo "Tokken"
|
||||
if err != nil {
|
||||
// Return 500 if token generation fails
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate access token")
|
||||
}
|
||||
|
||||
// Step 5: Set new access token cookie
|
||||
// Only the access token is refreshed, refresh token remains the same
|
||||
// This is more secure - refresh token changes only on login/explicit refresh
|
||||
h.setAccessTokenCookie(c, accessToken)
|
||||
|
||||
// Step 6: Return new access token
|
||||
// Response contains:
|
||||
// - New access token (for non-cookie clients)
|
||||
// - Expiration time (so client knows when to refresh again)
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"access_token": accessToken,
|
||||
"expires_in": int(h.config.JWT.AccessExpiry.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshTokenWithRotation handles the token refresh endpoint with rotation enabled.
|
||||
// This endpoint implements refresh token rotation for enhanced security:
|
||||
//
|
||||
// What is token rotation?
|
||||
// - Every time a refresh token is used, a NEW refresh token is issued
|
||||
// - The old refresh token is immediately invalidated (revoked in database)
|
||||
// - Client receives both new access token AND new refresh token
|
||||
// - Client must store the new refresh token for next refresh
|
||||
//
|
||||
// Security benefits over non-rotating tokens:
|
||||
// 1. Limited exposure window: Stolen tokens become useless after legitimate user refreshes
|
||||
// 2. Theft detection: Reusing old tokens after rotation indicates potential compromise
|
||||
// 3. Reduced attack surface: Each token is single-use after rotation
|
||||
// 4. Fresh cryptographic material: New random token generated each time
|
||||
//
|
||||
// Token sources (checked in order):
|
||||
// 1. Request body (req.RefreshToken) - for mobile/SPA clients
|
||||
// 2. HTTP-only cookie - for browser-based clients
|
||||
// This dual approach supports multiple client types
|
||||
//
|
||||
// Response (Error - 401 Unauthorized):
|
||||
// - Missing refresh token (not in body or cookie)
|
||||
// - Invalid token signature
|
||||
// - Expired refresh token
|
||||
// - Revoked session (token already used after rotation)
|
||||
// - User not found or account inactive
|
||||
//
|
||||
// Response (Error - 500 Internal Server Error):
|
||||
// - Token generation failure
|
||||
// - Database errors
|
||||
//
|
||||
// Security considerations:
|
||||
// - Old refresh token is immediately invalidated after successful rotation
|
||||
// - Attempting to reuse old token may trigger security alerts (theft detection)
|
||||
// - Both tokens should be transmitted over HTTPS only in production
|
||||
// - Cookies use HttpOnly flag to prevent JavaScript access (XSS protection)
|
||||
// - Session tracks device/IP for security monitoring
|
||||
//
|
||||
// Client implementation requirements:
|
||||
// - MUST store the new refresh token from response
|
||||
// - MUST discard the old refresh token immediately
|
||||
// - MUST NOT retry with old token if refresh fails
|
||||
// - SHOULD implement secure token storage (keychain, secure storage, etc.)
|
||||
func (h *AuthHandler) RefreshTokenWithRotation(c echo.Context) error {
|
||||
// Step 1: Parse request body (optional - might use cookie instead)
|
||||
var req TokenRefreshRequest
|
||||
|
||||
// Attempt to bind JSON from request body
|
||||
// This is optional - we'll also check cookies
|
||||
// Bind error is not fatal here
|
||||
if err := c.Bind(&req); err != nil {
|
||||
// Binding failed - no body or malformed JSON
|
||||
// Not an error yet, we'll check cookie next
|
||||
req.RefreshToken = "" // Ensure empty if bind failed
|
||||
}
|
||||
|
||||
// Step 2: Determine refresh token source
|
||||
// Check request body first, then fall back to cookie
|
||||
// This supports both browser and non-browser clients
|
||||
var refreshToken string
|
||||
if req.RefreshToken != "" {
|
||||
// Token provided in request body (mobile/SPA clients)
|
||||
refreshToken = req.RefreshToken
|
||||
} else {
|
||||
// Try to get token from HTTP-only cookie (browser clients)
|
||||
cookie, err := c.Cookie("refresh_token")
|
||||
if err != nil {
|
||||
// No token in body or cookie
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing refresh token")
|
||||
}
|
||||
refreshToken = cookie.Value
|
||||
}
|
||||
|
||||
// Step 3: Validate that we have a token
|
||||
if refreshToken == "" {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing refresh token")
|
||||
}
|
||||
// Get request context for cancellation and timeout propagation
|
||||
ctx := c.Request().Context()
|
||||
|
||||
// Step 4: Extract client metadata for new session
|
||||
// This information is stored with the new session for:
|
||||
// - Security monitoring (detect unusual locations/devices)
|
||||
// - Session display (show user their active sessions)
|
||||
// - Audit trail (track when/where tokens were used)
|
||||
userAgent := c.Request().UserAgent()
|
||||
ipAddress := c.RealIP()
|
||||
|
||||
// Step 5: Rotate the refresh token
|
||||
// This process:
|
||||
// 1. Validates old token (JWT + session check)
|
||||
// 2. Generates new access token
|
||||
// 3. Generates new refresh token (creates new session)
|
||||
// 4. Revokes old session (invalidates old token)
|
||||
newAccessToken, newRefreshToken, _, err := h.authService.RotateRefreshToken(
|
||||
ctx,
|
||||
refreshToken,
|
||||
&userAgent,
|
||||
&ipAddress,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
// Handle specific error types with appropriate HTTP status and messages
|
||||
// Using errors.Is() for proper error comparison
|
||||
if errors.Is(err, services.ErrExpiredToken) {
|
||||
// Refresh token has expired (needs re-login)
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "refresh token expired")
|
||||
}
|
||||
if errors.Is(err, services.ErrRevokedToken) {
|
||||
// Session was revoked (logout, password change, etc.)
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "refresh token revoked")
|
||||
}
|
||||
if errors.Is(err, services.ErrInvalidToken) {
|
||||
// Token signature invalid or malformed
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid refresh token")
|
||||
}
|
||||
// Generic error for unexpected cases
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid refresh token")
|
||||
}
|
||||
|
||||
// Step 6: Set new tokens in HTTP-only cookies
|
||||
// This benefits browser clients:
|
||||
// - Cookies automatically sent with requests
|
||||
// - HttpOnly prevents JavaScript access (XSS protection)
|
||||
// - Browser handles storage securely
|
||||
// Non-browser clients will use tokens from response body
|
||||
h.setAccessTokenCookie(c, newAccessToken)
|
||||
h.setRefreshTokenCookie(c, newRefreshToken)
|
||||
|
||||
// Step 7: Return new tokens in response body
|
||||
// Both browser and non-browser clients receive tokens
|
||||
// Non-browser clients MUST store the new refresh token
|
||||
// Browser clients benefit from having tokens available in JavaScript if needed
|
||||
return c.JSON(http.StatusOK, TokenRefreshResponse{
|
||||
AccessToken: newAccessToken,
|
||||
RefreshToken: newRefreshToken,
|
||||
ExpiresIn: int(h.config.JWT.AccessExpiry.Seconds()),
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// Logout handles user logout by revoking the refresh token and clearing cookies.
|
||||
// This endpoint invalidates the current session and removes authentication cookies.
|
||||
//
|
||||
// Why logout is important:
|
||||
// - Security: Revokes refresh token so it can't be used to get new access tokens
|
||||
// - Privacy: Removes tokens from browser
|
||||
// - Session management: Marks session as ended in database
|
||||
//
|
||||
// Flow:
|
||||
// 1. Extract refresh token from cookie
|
||||
// 2. Revoke the refresh token in database
|
||||
// 3. Clear both access and refresh token cookies
|
||||
// 4. Return success (204 No Content)
|
||||
//
|
||||
// Graceful handling:
|
||||
// - If no refresh token cookie: Still clears cookies and returns success
|
||||
// - If token revocation fails: Still clears cookies (client-side cleanup)
|
||||
// - Always returns success to avoid information leakage
|
||||
//
|
||||
// Error handling:
|
||||
// - No errors returned to client (always succeeds)
|
||||
// - Errors are silently handled to prevent information disclosure
|
||||
func (h *AuthHandler) Logout(c echo.Context) error {
|
||||
// Step 1: Attempt to get refresh token from cookie
|
||||
cookie, err := c.Cookie("refresh_token")
|
||||
if err != nil {
|
||||
// No cookie found - user might have already logged out or session expired
|
||||
// Still clear cookies (might be stale access token) and return success
|
||||
h.clearAuthCookies(c)
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
// Get request context
|
||||
ctx := c.Request().Context()
|
||||
|
||||
// Step 2: Revoke the refresh token in database
|
||||
// This marks the session as revoked with reason "user_logout"
|
||||
// Updates: is_revoked=true, revoked_at=NOW(), revoked_reason='user_logout'
|
||||
// We ignore errors here - even if revocation fails, we clear client cookies
|
||||
_ = h.authService.RevokeRefreshToken(ctx, cookie.Value)
|
||||
|
||||
// Step 3: Clear authentication cookies from browser
|
||||
// Sets MaxAge=-1 which tells browser to immediately delete cookies
|
||||
// Clears both access_token and refresh_token cookies
|
||||
h.clearAuthCookies(c)
|
||||
|
||||
// Step 4: Return success with no content
|
||||
// 204 No Content is appropriate for successful logout
|
||||
// No response body needed
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
// setAccessTokenCookie creates and sets the access token cookie with security flags.
|
||||
// This cookie stores the JWT access token for subsequent API requests.
|
||||
//
|
||||
// Cookie configuration explained:
|
||||
// - Name: "access_token" - identifies this cookie
|
||||
// - Value: The JWT token string
|
||||
// - Path: "/" - cookie sent for all paths on domain
|
||||
// - Domain: From config (e.g., "localhost", ".example.com")
|
||||
// - MaxAge: Token lifetime in seconds (how long browser keeps cookie)
|
||||
// - Secure: Only sent over HTTPS (true in production)
|
||||
// - HttpOnly: Cannot be accessed by JavaScript (XSS protection)
|
||||
// - SameSite: CSRF protection (controls when cookie is sent)
|
||||
//
|
||||
// Why these settings?
|
||||
// - HttpOnly: Prevents XSS attacks from stealing tokens
|
||||
// - Secure: Prevents tokens from being sent over unencrypted connections
|
||||
// - SameSite: Prevents CSRF attacks by controlling cross-site cookie sending
|
||||
// - Path=/: Makes cookie available to all API endpoints
|
||||
func (h *AuthHandler) setAccessTokenCookie(c echo.Context, token string) {
|
||||
cookie := &http.Cookie{
|
||||
Name: "access_token",
|
||||
Value: token,
|
||||
Path: "/", // Available to all paths
|
||||
Domain: h.config.Cookie.CookieDomain,
|
||||
MaxAge: int(h.config.JWT.AccessExpiry.Seconds()), // Browser deletes after this time
|
||||
Secure: h.config.Cookie.CookieSecure, // HTTPS only in production
|
||||
HttpOnly: true, // JavaScript cannot access (XSS protection)
|
||||
SameSite: h.parseSameSite(h.config.Cookie.CookieSameSite), // CSRF protection
|
||||
}
|
||||
|
||||
c.SetCookie(cookie)
|
||||
}
|
||||
|
||||
// setRefreshTokenCookie creates and sets the refresh token cookie with security flags.
|
||||
// This cookie stores the JWT refresh token for obtaining new access tokens.
|
||||
//
|
||||
// Similar to access token cookie but with longer lifetime (7 days vs 15 minutes).
|
||||
// Uses same security flags (HttpOnly, Secure, SameSite) for protection.
|
||||
//
|
||||
// Why separate cookies?
|
||||
// - Different lifetimes (access=short, refresh=long)
|
||||
// - Different purposes (access=API requests, refresh=token renewal)
|
||||
// - Can revoke one without affecting the other
|
||||
// - Follows OAuth 2.0 best practices
|
||||
func (h *AuthHandler) setRefreshTokenCookie(c echo.Context, token string) {
|
||||
cookie := &http.Cookie{
|
||||
Name: "refresh_token",
|
||||
Value: token,
|
||||
Path: "/", // Available to all paths
|
||||
Domain: h.config.Cookie.CookieDomain,
|
||||
MaxAge: int(h.config.JWT.RefreshExpiry.Seconds()), // Much longer than access token
|
||||
Secure: h.config.Cookie.CookieSecure, // HTTPS only in production
|
||||
HttpOnly: true, // JavaScript cannot access (XSS protection)
|
||||
SameSite: h.parseSameSite(h.config.Cookie.CookieSameSite), // CSRF protection
|
||||
}
|
||||
|
||||
c.SetCookie(cookie)
|
||||
}
|
||||
|
||||
// clearAuthCookies removes both access and refresh token cookies from the browser.
|
||||
// This is called during logout to clean up authentication state.
|
||||
//
|
||||
// How cookie deletion works:
|
||||
// - Set MaxAge=-1 which tells browser to immediately delete the cookie
|
||||
// - Set empty Value to clear any existing value
|
||||
// - Keep same Name, Path, and Domain so browser knows which cookie to delete
|
||||
//
|
||||
// Why we still set Secure and HttpOnly:
|
||||
// - Browser needs these to match original cookie attributes for deletion
|
||||
// - Ensures cookie is properly identified and removed
|
||||
func (h *AuthHandler) clearAuthCookies(c echo.Context) {
|
||||
// Create cookie with MaxAge=-1 to delete access token
|
||||
accessCookie := &http.Cookie{
|
||||
Name: "access_token",
|
||||
Value: "", // Empty value
|
||||
Path: "/",
|
||||
Domain: h.config.Cookie.CookieDomain,
|
||||
MaxAge: -1, // Negative MaxAge means delete immediately
|
||||
Secure: h.config.Cookie.CookieSecure,
|
||||
HttpOnly: true,
|
||||
}
|
||||
|
||||
// Create cookie with MaxAge=-1 to delete refresh token
|
||||
refreshCookie := &http.Cookie{
|
||||
Name: "refresh_token",
|
||||
Value: "", // Empty value
|
||||
Path: "/",
|
||||
Domain: h.config.Cookie.CookieDomain,
|
||||
MaxAge: -1, // Negative MaxAge means delete immediately
|
||||
Secure: h.config.Cookie.CookieSecure,
|
||||
HttpOnly: true,
|
||||
}
|
||||
|
||||
// Set both cookies (browser will delete them)
|
||||
c.SetCookie(accessCookie)
|
||||
c.SetCookie(refreshCookie)
|
||||
}
|
||||
|
||||
// parseSameSite converts a string SameSite policy to http.SameSite type.
|
||||
// SameSite is a cookie attribute that controls when cookies are sent in cross-site requests.
|
||||
//
|
||||
// Values explained:
|
||||
// - "strict": Cookie never sent in cross-site requests (most secure, may break some flows)
|
||||
// Example: User clicks link from email to your site - no cookie sent
|
||||
// - "lax": Cookie sent on top-level navigation (GET) but not on embedded requests (balanced)
|
||||
// Example: User clicks link - cookie sent; Embedded image - cookie not sent
|
||||
// - "none": Cookie always sent (requires Secure=true, needed for some third-party integrations)
|
||||
// Example: Your API called from different domain - cookie sent
|
||||
// - default: Browser decides (usually similar to "lax")
|
||||
//
|
||||
// Why this matters for security:
|
||||
// - Prevents CSRF attacks by limiting when cookies are sent
|
||||
// - "lax" is recommended for most authentication cookies (good security + usability)
|
||||
// - "strict" can break legitimate flows (like OAuth redirects)
|
||||
// - "none" should only be used when necessary (requires HTTPS)
|
||||
func (h *AuthHandler) parseSameSite(s string) http.SameSite {
|
||||
switch s {
|
||||
case "strict":
|
||||
return http.SameSiteStrictMode // Never send cookie cross-site
|
||||
case "lax":
|
||||
return http.SameSiteLaxMode // Send on top-level navigation only
|
||||
case "none":
|
||||
return http.SameSiteNoneMode // Always send (requires Secure=true)
|
||||
default:
|
||||
return http.SameSiteDefaultMode // Let browser decide
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeUser removes sensitive information from user object before sending to client.
|
||||
// Currently just returns the user as-is, but should remove:
|
||||
// - password_hash: Never send password hashes to client
|
||||
// - internal IDs: Remove any internal tracking IDs
|
||||
// - audit fields: Consider removing internal timestamps
|
||||
//
|
||||
// TODO: Implement actual sanitization:
|
||||
// - Remove PasswordHash field
|
||||
// - Consider using a separate UserResponse struct
|
||||
// - Transform to DTO (Data Transfer Object) pattern
|
||||
//
|
||||
// Why sanitization is critical:
|
||||
// - Security: Prevents exposing sensitive data
|
||||
// - Privacy: User data should be minimal
|
||||
// - API contract: Clearly defines what clients receive
|
||||
func (h *AuthHandler) sanitizeUser(user interface{}) interface{} {
|
||||
// TODO: Actually sanitize the user object
|
||||
// Current implementation just passes through - should remove sensitive fields
|
||||
return user
|
||||
}
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/creativenoz/aurganize-v62/backend/internal/services"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// AuthMiddleware provides authentication middleware for protecting routes.
|
||||
// This middleware intercepts HTTP requests to verify user authentication before
|
||||
// allowing access to protected resources.
|
||||
//
|
||||
// What is middleware?
|
||||
// Middleware is code that runs between receiving a request and executing the handler.
|
||||
// It's like a security checkpoint that requests must pass through.
|
||||
//
|
||||
// Request flow with middleware:
|
||||
// Client Request → CORS → AuthMiddleware → Route Handler → Response
|
||||
//
|
||||
// This middleware provides two authentication modes:
|
||||
// 1. Authenticate: REQUIRED authentication (blocks unauthenticated requests)
|
||||
// 2. OptionalAuth: OPTIONAL authentication (allows both authenticated and anonymous)
|
||||
//
|
||||
// Authentication sources (checked in order):
|
||||
// 1. HTTP-only cookie (primary for browser clients)
|
||||
// 2. Authorization header with Bearer token (for mobile/API clients)
|
||||
//
|
||||
// Why support both?
|
||||
// - Cookies: Secure for browsers (HttpOnly prevents XSS)
|
||||
// - Headers: Required for mobile apps and API clients
|
||||
// - Flexibility: Supports multiple client types
|
||||
//
|
||||
// What gets validated:
|
||||
// - JWT signature (ensures token wasn't tampered with)
|
||||
// - Token expiration (ensures token is still valid)
|
||||
// - Token type (ensures it's an access token, not refresh)
|
||||
// - Token format (ensures proper JWT structure)
|
||||
//
|
||||
// After successful authentication:
|
||||
// - User claims are stored in Echo context
|
||||
// - Downstream handlers can access user info via c.Get("user_id"), etc.
|
||||
// - No need to re-validate token in handlers
|
||||
type AuthMiddleware struct {
|
||||
authService *services.AuthService
|
||||
}
|
||||
|
||||
// NewAuthMiddleware creates a new authentication middleware with injected dependencies.
|
||||
// This constructor follows the dependency injection pattern for:
|
||||
// - Testability: Can inject mock auth service for testing
|
||||
// - Flexibility: Can swap implementations without changing middleware
|
||||
// - Clear dependencies: Explicitly shows what middleware needs
|
||||
//
|
||||
// Parameters:
|
||||
// - authService: Service that handles token validation
|
||||
//
|
||||
// Returns:
|
||||
// - Fully initialized AuthMiddleware ready to protect routes
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// authService := services.NewAuthService(...)
|
||||
// authMiddleware := middleware.NewAuthMiddleware(authService)
|
||||
// e.GET("/protected", handler, authMiddleware.Authenticate)
|
||||
func NewAuthMiddleware(authService *services.AuthService) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate is a REQUIRED authentication middleware.
|
||||
// Routes using this middleware will reject requests without valid authentication.
|
||||
//
|
||||
// When to use:
|
||||
// - Protected endpoints that require authentication
|
||||
// - User-specific operations (profile, settings, logout)
|
||||
// - Resource access control (only authenticated users)
|
||||
// - Any route that needs user identity
|
||||
//
|
||||
// Authentication flow:
|
||||
// 1. Extract token from cookie OR Authorization header
|
||||
// 2. Validate token (signature, expiration, type)
|
||||
// 3. If valid: Store claims in context, continue to handler
|
||||
// 4. If invalid: Return 401 Unauthorized, block request
|
||||
//
|
||||
// Token sources (priority order):
|
||||
// 1. Cookie: "access_token" (for browser clients)
|
||||
// 2. Header: "Authorization: Bearer <token>" (for mobile/API clients)
|
||||
//
|
||||
// Why check cookie first?
|
||||
// - More secure for browsers (HttpOnly prevents XSS)
|
||||
// - Automatically sent by browsers
|
||||
// - Primary method for web applications
|
||||
//
|
||||
// Response codes:
|
||||
// - 200: Token valid, request proceeds to handler
|
||||
// - 401: Missing token, invalid token, or expired token
|
||||
//
|
||||
// What gets stored in context (accessible in handlers):
|
||||
// - user_id: UUID of authenticated user
|
||||
// - tenant_id: UUID of user's organization/tenant
|
||||
// - email: User's email address
|
||||
// - role: User's role (admin, user, etc.)
|
||||
// - claims: Full claims object (all token data)
|
||||
//
|
||||
// Handler access example:
|
||||
//
|
||||
// userID := c.Get("user_id").(uuid.UUID)
|
||||
// email := c.Get("email").(string)
|
||||
// role := c.Get("role").(string)
|
||||
//
|
||||
// Error handling:
|
||||
// - Missing token: "missing authentication token"
|
||||
// - Invalid format: "invalid authorization header format"
|
||||
// - Expired token: "token has expired" (client should refresh)
|
||||
// - Invalid token: "invalid token" (signature/tampering)
|
||||
//
|
||||
// Parameters:
|
||||
// - next: The next handler in the chain (the actual route handler)
|
||||
//
|
||||
// Returns:
|
||||
// - HandlerFunc that wraps the next handler with authentication
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// // Protect single route
|
||||
// e.GET("/profile", profileHandler, authMiddleware.Authenticate)
|
||||
//
|
||||
// // Protect route group
|
||||
// protected := e.Group("/api", authMiddleware.Authenticate)
|
||||
// protected.GET("/users", listUsers)
|
||||
// protected.POST("/posts", createPost)
|
||||
func (m *AuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// Step 1: Try to get token from cookie first (browser clients)
|
||||
// This is the preferred method for web applications
|
||||
token, err := c.Cookie("access_token")
|
||||
var tokenString string
|
||||
if err == nil {
|
||||
// Cookie found - use its value
|
||||
// This path is taken by browser-based clients
|
||||
tokenString = token.Value
|
||||
} else {
|
||||
// Step 2: Cookie not found, try Authorization header (mobile/API clients)
|
||||
// Expected format: "Authorization: Bearer <token>"
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
// No cookie AND no header - user is not authenticated
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing authentication token")
|
||||
}
|
||||
// Step 3: Parse Authorization header
|
||||
// Expected format: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
// Split into ["Bearer", "token_string"]
|
||||
parts := strings.Split(authHeader, " ")
|
||||
// Validate header format
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
// Invalid format examples:
|
||||
// - "Bearer" (no token)
|
||||
// - "Bearer token extra" (too many parts)
|
||||
// - "Basic base64string" (wrong auth type)
|
||||
// - "token" (missing "Bearer" prefix)
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid authorization header format")
|
||||
}
|
||||
// Extract token (second part after "Bearer ")
|
||||
tokenString = parts[1]
|
||||
}
|
||||
// Step 4: Validate the access token
|
||||
// This checks:
|
||||
// - JWT signature (proves token wasn't tampered)
|
||||
// - Token expiration (ensures not expired)
|
||||
// - Token type (ensures it's "access" not "refresh")
|
||||
// - Token structure (valid JWT format)
|
||||
claims, err := m.authService.ValidateAccessToken(tokenString)
|
||||
if err != nil {
|
||||
// Handle specific error types
|
||||
if err == services.ErrExpiredToken {
|
||||
// Token is valid but expired
|
||||
// Client should use refresh token to get new access token
|
||||
// Return specific message so client knows to refresh
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "token has expired")
|
||||
}
|
||||
// Other errors: invalid signature, wrong type, malformed, etc.
|
||||
// Return generic error to avoid leaking information
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid token")
|
||||
}
|
||||
// Step 5: Token is valid - store claims in context
|
||||
// Context values can be retrieved by downstream handlers
|
||||
// This avoids re-validating token in every handler
|
||||
|
||||
// Store individual fields for easy access
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("tenant_id", claims.TenantID)
|
||||
c.Set("email", claims.Email)
|
||||
c.Set("role", claims.Role)
|
||||
// Store full claims object for advanced use cases
|
||||
c.Set("claims", claims)
|
||||
|
||||
// Step 6: Continue to next handler (the actual route handler)
|
||||
// Request is now authenticated and handlers can access user info
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuth is an OPTIONAL authentication middleware.
|
||||
// Routes using this middleware will work for both authenticated and anonymous users.
|
||||
//
|
||||
// When to use:
|
||||
// - Public endpoints that enhance experience for logged-in users
|
||||
// - Content that shows differently based on auth status
|
||||
// - APIs that return more data for authenticated users
|
||||
// - Features with both public and private modes
|
||||
//
|
||||
// Examples:
|
||||
// 1. Homepage: Shows personalized content if logged in, generic if not
|
||||
// 2. Blog post: Shows "Edit" button if author is logged in
|
||||
// 3. Search: Returns more results for authenticated users
|
||||
// 4. Comments: Shows "Reply" option if logged in
|
||||
//
|
||||
// Behavior:
|
||||
// - If valid token: Store claims in context, proceed (like Authenticate)
|
||||
// - If no token: Proceed anyway without claims
|
||||
// - If invalid token: Proceed anyway without claims (graceful degradation)
|
||||
//
|
||||
// Why not return error for invalid token?
|
||||
// - Allows graceful degradation (partial functionality)
|
||||
// - Doesn't block anonymous users
|
||||
// - Expired tokens don't break the page
|
||||
// - Better user experience
|
||||
//
|
||||
// How handlers detect authentication status:
|
||||
//
|
||||
// userID := c.Get("user_id")
|
||||
// if userID != nil {
|
||||
// // User is authenticated
|
||||
// authenticatedUserID := userID.(uuid.UUID)
|
||||
// // Show personalized content
|
||||
// } else {
|
||||
// // User is anonymous
|
||||
// // Show generic content
|
||||
// }
|
||||
//
|
||||
// Difference from Authenticate:
|
||||
// - Authenticate: BLOCKS unauthenticated requests (401 error)
|
||||
// - OptionalAuth: ALLOWS unauthenticated requests (no error)
|
||||
//
|
||||
// Token source:
|
||||
// - Only checks cookie (not Authorization header)
|
||||
// - Why? Browser-based clients naturally use cookies
|
||||
// - Mobile/API clients should use specific endpoints
|
||||
//
|
||||
// What gets stored (if authenticated):
|
||||
// - user_id: UUID of authenticated user
|
||||
// - tenant_id: UUID of user's organization
|
||||
// - email: User's email address
|
||||
// - role: User's role
|
||||
// - claims: Full claims object
|
||||
//
|
||||
// What gets stored (if not authenticated):
|
||||
// - Nothing - context values will be nil
|
||||
//
|
||||
// Parameters:
|
||||
// - next: The next handler in the chain (the actual route handler)
|
||||
//
|
||||
// Returns:
|
||||
// - HandlerFunc that wraps the next handler with optional authentication
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// // Single route with optional auth
|
||||
// e.GET("/", homeHandler, authMiddleware.OptionalAuth)
|
||||
//
|
||||
// // Route group with optional auth
|
||||
// public := e.Group("/public", authMiddleware.OptionalAuth)
|
||||
// public.GET("/posts", listPosts) // Shows different content based on auth
|
||||
// public.GET("/post/:id", viewPost) // Shows edit button if authenticated
|
||||
//
|
||||
// Handler example:
|
||||
//
|
||||
// func homeHandler(c echo.Context) error {
|
||||
// userID := c.Get("user_id")
|
||||
// if userID != nil {
|
||||
// // Authenticated user
|
||||
// return c.Render(200, "home-authenticated", data)
|
||||
// }
|
||||
// // Anonymous user
|
||||
// return c.Render(200, "home-public", data)
|
||||
// }
|
||||
func (m *AuthMiddleware) OptionalAuth(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// Step 1: Try to get token from cookie
|
||||
// We only check cookies for optional auth (not Authorization header)
|
||||
// This is intentional - optional auth is primarily for browser clients
|
||||
token, err := c.Cookie("access_token")
|
||||
if err != nil {
|
||||
// No cookie found - user is anonymous
|
||||
// This is OKAY for optional auth
|
||||
// Proceed to handler without setting context values
|
||||
// Handler will see nil values and know user is not authenticated
|
||||
return next(c)
|
||||
}
|
||||
// Step 2: Cookie found - validate the token
|
||||
// Even though auth is optional, we validate if token is present
|
||||
// This ensures we don't use invalid/expired tokens
|
||||
claims, err := m.authService.ValidateAccessToken(token.Value)
|
||||
|
||||
if err != nil {
|
||||
// Token is invalid or expired
|
||||
// For optional auth, we don't return error
|
||||
// Just proceed without setting context values
|
||||
// User will be treated as anonymous
|
||||
// This provides graceful degradation
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Step 3: Token is valid - store claims in context
|
||||
// Handler can now detect authenticated user via c.Get("user_id")
|
||||
// Same values as Authenticate middleware
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("tenant_id", claims.TenantID)
|
||||
c.Set("email", claims.Email)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("claims", claims)
|
||||
|
||||
// Step 4: Continue to handler
|
||||
// Handler can check if user_id is nil to determine auth status
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
// NewCORSMiddleware creates and configures Cross-Origin Resource Sharing (CORS) middleware
|
||||
// for the Echo web framework. CORS is a security feature that controls which origins
|
||||
// (domains) are allowed to make requests to your API from web browsers.
|
||||
//
|
||||
// Security Context:
|
||||
// Browsers enforce the Same-Origin Policy, which prevents JavaScript on one domain
|
||||
// from accessing resources on another domain. CORS relaxes this restriction by
|
||||
// allowing servers to explicitly specify which origins are trusted.
|
||||
//
|
||||
// Configuration Details:
|
||||
//
|
||||
// - AllowOrigins: Whitelist of trusted frontend origins that can access the API.
|
||||
// Currently configured for local development (React dev server on 5173, alternative on 3000).
|
||||
// Production deployment should update this to include actual frontend domain(s).
|
||||
//
|
||||
// - AllowMethods: HTTP methods permitted for cross-origin requests.
|
||||
// Includes standard REST operations plus OPTIONS for preflight requests.
|
||||
//
|
||||
// - AllowHeaders: Request headers that browsers are allowed to send.
|
||||
// Authorization header is critical for JWT bearer tokens.
|
||||
//
|
||||
// - AllowCredentials: Allows browsers to send credentials (cookies, authorization headers)
|
||||
// with cross-origin requests. Required for JWT authentication.
|
||||
// IMPORTANT: When true, AllowOrigins cannot use wildcards (*) for security.
|
||||
//
|
||||
// - MaxAge: Duration (in seconds) that browsers can cache preflight OPTIONS responses.
|
||||
// 3600 seconds (1 hour) reduces preflight requests for better performance.
|
||||
//
|
||||
// Preflight Requests:
|
||||
// For certain cross-origin requests (e.g., those with Authorization headers or
|
||||
// non-simple methods like PUT/DELETE), browsers automatically send an OPTIONS
|
||||
// request first to check if the actual request is allowed. This middleware
|
||||
// handles those preflight requests automatically.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// e := echo.New()
|
||||
// e.Use(NewCORSMiddleware())
|
||||
//
|
||||
// Production Considerations:
|
||||
// - Update AllowOrigins to include production frontend domain(s)
|
||||
// - Remove localhost origins in production builds
|
||||
// - Consider environment-based configuration for different deployment stages
|
||||
// - Never use "*" with AllowCredentials: true (security vulnerability)
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// Echo middleware function that handles CORS for all routes
|
||||
func NewCORSMiddleware() echo.MiddlewareFunc {
|
||||
return middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
// AllowOrigins specifies which frontend domains can make requests to this API.
|
||||
// These are the URLs where your React/Vue/Angular frontend is hosted.
|
||||
// Browser will reject requests from any origin not in this list.
|
||||
// TODO: Update this list for production deployment (e.g., "https://app.aurganize.com")
|
||||
AllowOrigins: []string{"http://localhost:5173", "http://localhost:3000"},
|
||||
|
||||
// AllowMethods defines which HTTP methods are permitted for cross-origin requests.
|
||||
// OPTIONS is required for handling CORS preflight requests.
|
||||
// GET, POST, PUT, DELETE, PATCH cover standard REST API operations.
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"},
|
||||
|
||||
// AllowHeaders specifies which request headers browsers can send in cross-origin requests.
|
||||
// - Origin: Browser automatically sends this, required for CORS
|
||||
// - Content-Type: Needed for sending JSON request bodies
|
||||
// - Accept: Specifies expected response format
|
||||
// - Authorization: Critical for JWT bearer token authentication
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
|
||||
|
||||
// AllowCredentials permits browsers to send credentials (cookies, HTTP auth, TLS certificates)
|
||||
// with cross-origin requests. Must be true for JWT authentication in Authorization header.
|
||||
// Security note: When true, AllowOrigins MUST NOT use wildcard "*"
|
||||
AllowCredentials: true,
|
||||
|
||||
// MaxAge specifies how long (in seconds) browsers can cache the preflight response.
|
||||
// During this time, browsers won't send additional OPTIONS requests for the same endpoint.
|
||||
// 3600 seconds = 1 hour, balancing performance with configuration change responsiveness.
|
||||
MaxAge: 3600,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,315 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// RateLimiter implements a sliding window rate limiting algorithm to prevent abuse.
|
||||
// Rate limiting is a critical security measure that protects your API from:
|
||||
// 1. Brute force attacks (login attempts, password guessing)
|
||||
// 2. Denial of Service (DoS) attacks (overwhelming the server)
|
||||
// 3. API abuse (scraping, excessive requests)
|
||||
// 4. Resource exhaustion (database connections, memory)
|
||||
//
|
||||
// How sliding window works:
|
||||
// - Tracks request timestamps for each IP address
|
||||
// - Keeps only requests within the time window
|
||||
// - Blocks requests when limit is exceeded
|
||||
// - Old requests automatically expire and don't count
|
||||
//
|
||||
// Example: limit=5, window=1 minute
|
||||
// - 10:00:00: Request 1 ✅ (1/5)
|
||||
// - 10:00:10: Request 2 ✅ (2/5)
|
||||
// - 10:00:20: Request 3 ✅ (3/5)
|
||||
// - 10:00:30: Request 4 ✅ (4/5)
|
||||
// - 10:00:40: Request 5 ✅ (5/5)
|
||||
// - 10:00:50: Request 6 ❌ (6/5 - BLOCKED!)
|
||||
// - 10:01:05: Request 7 ✅ (2/5 - Request 1 expired)
|
||||
//
|
||||
// Why sliding window vs fixed window?
|
||||
// - Fixed window: All counters reset at fixed intervals (e.g., every minute at :00)
|
||||
// Problem: Can allow 2x limit (5 at 10:00:59, 5 at 10:01:00 = 10 in 1 second)
|
||||
// - Sliding window: Counts requests in the last N seconds from now
|
||||
// Benefit: Smoother rate limiting, no burst at window boundaries
|
||||
//
|
||||
// Memory consideration:
|
||||
// - Stores timestamps for each IP address
|
||||
// - Memory grows with number of unique IPs
|
||||
// - Old timestamps are cleaned up automatically
|
||||
// - For high-traffic applications, consider Redis-based rate limiting
|
||||
//
|
||||
// Thread safety:
|
||||
// - Uses mutex (sync.Mutex) for concurrent access
|
||||
// - Multiple requests can arrive simultaneously
|
||||
// - Mutex ensures only one goroutine modifies the map at a time
|
||||
|
||||
type RateLimiter struct {
|
||||
// requests maps IP addresses to their recent request timestamps
|
||||
// Key: IP address (e.g., "192.168.1.1")
|
||||
// Value: Slice of timestamps when requests were made
|
||||
// Example: {"192.168.1.1": [10:00:00, 10:00:10, 10:00:20]}
|
||||
requests map[string][]time.Time
|
||||
|
||||
// mu (mutex) ensures thread-safe access to the requests map
|
||||
// Why needed: Multiple HTTP requests arrive concurrently (different goroutines)
|
||||
// Without mutex: Race conditions (data corruption, incorrect counts)
|
||||
// With mutex: Only one goroutine can read/write the map at a time
|
||||
mu sync.Mutex
|
||||
|
||||
// limit is the maximum number of requests allowed within the time window
|
||||
// Example: limit=5 means 5 requests per window
|
||||
// Common values:
|
||||
// - Login: 5-10 per minute (prevent brute force)
|
||||
// - API: 100-1000 per minute (prevent abuse)
|
||||
// - Registration: 3-5 per hour (prevent spam)
|
||||
limit int
|
||||
|
||||
// window is the time duration for counting requests
|
||||
// Example: window=1 minute means count requests in the last 60 seconds
|
||||
// Common values:
|
||||
// - 1 minute: Standard rate limiting
|
||||
// - 1 hour: Aggressive rate limiting (password reset)
|
||||
// - 1 second: Burst protection
|
||||
// Format: time.Second, time.Minute, time.Hour
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a new rate limiter with specified limit and time window.
|
||||
// This constructor initializes the rate limiter with empty request tracking.
|
||||
//
|
||||
// Parameters:
|
||||
//
|
||||
// - limit: Maximum number of requests allowed in the time window
|
||||
// Example: 5 means "allow 5 requests"
|
||||
// Too low: Blocks legitimate users
|
||||
// Too high: Doesn't prevent abuse
|
||||
// Recommendation: Start conservative, increase if needed
|
||||
//
|
||||
// - window: Time duration for the sliding window
|
||||
// Example: time.Minute means "5 requests per minute"
|
||||
// Common patterns:
|
||||
//
|
||||
// - Login: NewRateLimiter(5, time.Minute) = 5 attempts per minute
|
||||
//
|
||||
// - API: NewRateLimiter(100, time.Minute) = 100 calls per minute
|
||||
//
|
||||
// - Registration: NewRateLimiter(3, time.Hour) = 3 signups per hour
|
||||
//
|
||||
// Returns:
|
||||
// - Fully initialized RateLimiter ready to use as middleware
|
||||
//
|
||||
// Usage examples:
|
||||
//
|
||||
// // Protect login endpoint
|
||||
// loginLimiter := NewRateLimiter(5, time.Minute)
|
||||
// auth.POST("/login", handler, loginLimiter.Limit)
|
||||
//
|
||||
// // Protect API endpoints
|
||||
// apiLimiter := NewRateLimiter(100, time.Minute)
|
||||
// api.GET("/data", handler, apiLimiter.Limit)
|
||||
//
|
||||
// // Protect registration
|
||||
// registerLimiter := NewRateLimiter(3, time.Hour)
|
||||
// auth.POST("/register", handler, registerLimiter.Limit)
|
||||
//
|
||||
// Memory note:
|
||||
// - Starts with empty map, grows as IPs make requests
|
||||
// - Old timestamps cleaned automatically
|
||||
// - No manual cleanup needed
|
||||
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
requests: make(map[string][]time.Time),
|
||||
limit: limit,
|
||||
window: window,
|
||||
}
|
||||
}
|
||||
|
||||
// Limit is a middleware function that enforces rate limiting per IP address.
|
||||
// This wraps your route handler with rate limiting logic.
|
||||
//
|
||||
// How it works:
|
||||
// 1. Extract client's IP address
|
||||
// 2. Lock mutex (prevent concurrent access)
|
||||
// 3. Get current time and calculate window start
|
||||
// 4. Filter out expired requests (older than window)
|
||||
// 5. Check if limit exceeded
|
||||
// 6. If exceeded: Return 429 Too Many Requests
|
||||
// 7. If allowed: Add current request and proceed
|
||||
// 8. Unlock mutex (allow next request to be processed)
|
||||
//
|
||||
// Sliding window algorithm explained:
|
||||
// Window: [----------1 minute----------]
|
||||
// Now: ^
|
||||
// Window start: ^
|
||||
// Only count requests between window start and now
|
||||
//
|
||||
// Example timeline (limit=3, window=1 minute):
|
||||
// 10:00:00 - Request 1 ✅ Count: 1
|
||||
// 10:00:20 - Request 2 ✅ Count: 2
|
||||
// 10:00:40 - Request 3 ✅ Count: 3
|
||||
// 10:00:50 - Request 4 ❌ Count: 4 (BLOCKED - returns 429)
|
||||
// 10:01:05 - Request 5 ✅ Count: 3 (Request 1 expired)
|
||||
// 10:01:25 - Request 6 ✅ Count: 3 (Request 2 expired)
|
||||
//
|
||||
// IP address tracking:
|
||||
// - Uses c.RealIP() to get actual client IP
|
||||
// - Handles proxies (X-Forwarded-For header)
|
||||
// - Handles load balancers (X-Real-IP header)
|
||||
//
|
||||
// Why track by IP?
|
||||
// - Simple and effective for most use cases
|
||||
// - No user authentication required
|
||||
// - Works for public endpoints
|
||||
// - Alternative: Track by user ID (requires authentication)
|
||||
//
|
||||
// Thread safety:
|
||||
// - Mutex locks ensure safe concurrent access
|
||||
// - Multiple requests from different users are processed correctly
|
||||
// - No race conditions or data corruption
|
||||
//
|
||||
// Response codes:
|
||||
// - 200 OK: Request allowed (passes to next handler)
|
||||
// - 429 Too Many Requests: Rate limit exceeded (request blocked)
|
||||
//
|
||||
// Important notes:
|
||||
// - Mutex is locked during entire rate limit check
|
||||
// - Keep processing fast (simple operations only)
|
||||
// - Don't do expensive operations while locked
|
||||
// - Unlock happens automatically when function returns
|
||||
//
|
||||
// Parameters:
|
||||
// - next: The actual route handler to execute if rate limit allows
|
||||
//
|
||||
// Returns:
|
||||
// - HandlerFunc that wraps the next handler with rate limiting
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// rateLimiter := NewRateLimiter(5, time.Minute)
|
||||
//
|
||||
// // Single route
|
||||
// e.POST("/login", loginHandler, rateLimiter.Limit)
|
||||
//
|
||||
// // Route group
|
||||
// auth := e.Group("/auth")
|
||||
// auth.Use(rateLimiter.Limit) // Apply to all routes in group
|
||||
// auth.POST("/login", loginHandler)
|
||||
// auth.POST("/register", registerHandler)
|
||||
//
|
||||
// Production considerations:
|
||||
// - For high traffic, consider Redis-based rate limiting
|
||||
// - Monitor 429 responses (legitimate users vs attackers)
|
||||
// - Adjust limits based on actual usage patterns
|
||||
// - Consider different limits for different endpoints
|
||||
// - Add rate limit headers (X-RateLimit-Limit, X-RateLimit-Remaining)
|
||||
func (rl *RateLimiter) Limit(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// Step 1: Get client's IP address
|
||||
// RealIP() handles:
|
||||
// - X-Forwarded-For header (proxies)
|
||||
// - X-Real-IP header (load balancers)
|
||||
// - RemoteAddr (direct connections)
|
||||
// Example: "192.168.1.100" or "2001:db8::1" (IPv6)
|
||||
ip := c.RealIP()
|
||||
|
||||
// Step 2: Lock the mutex for thread-safe map access
|
||||
// CRITICAL: This prevents race conditions when multiple requests arrive simultaneously
|
||||
// What happens without lock:
|
||||
// - Goroutine 1 reads count: 4
|
||||
// - Goroutine 2 reads count: 4 (at the same time)
|
||||
// - Both think they're under limit (5)
|
||||
// - Both proceed (6 requests allowed instead of 5!)
|
||||
// With lock:
|
||||
// - Only one goroutine can read/write at a time
|
||||
// - Accurate counting guaranteed
|
||||
rl.mu.Lock()
|
||||
|
||||
// Note: Unlock will happen when function returns (defer not used here but safe because of return statements)
|
||||
|
||||
// Step 3: Get current time for window calculation
|
||||
// Used to determine which requests are still within the time window
|
||||
now := time.Now()
|
||||
|
||||
// Step 4: Calculate the start of the time window
|
||||
// Example: If window=1 minute and now=10:05:30
|
||||
// windowStart = 10:05:30 - 1 minute = 10:04:30
|
||||
// We only count requests between 10:04:30 and 10:05:30
|
||||
windowStart := now.Add(-rl.window)
|
||||
|
||||
// Step 5: Get existing requests for this IP
|
||||
// If IP never made a request, this will be nil/empty slice
|
||||
// Example: ["10:04:35", "10:05:10", "10:05:20"]
|
||||
request := rl.requests[ip]
|
||||
|
||||
// Step 6: Filter requests to keep only those within the window
|
||||
// Create a new slice to store valid (recent) requests
|
||||
validRequests := []time.Time{}
|
||||
|
||||
// Iterate through all previous requests from this IP
|
||||
for _, req := range request {
|
||||
// Check if request timestamp is after the window start
|
||||
// If yes: Request is recent (within window), keep it
|
||||
// If no: Request is old (outside window), discard it
|
||||
//
|
||||
// Example: windowStart=10:04:30, now=10:05:30
|
||||
// Request at 10:04:35 ✅ After window start (keep)
|
||||
// Request at 10:04:20 ❌ Before window start (discard)
|
||||
if req.After(windowStart) {
|
||||
validRequests = append(validRequests, req)
|
||||
}
|
||||
// Old requests are automatically garbage collected
|
||||
// This keeps memory usage bounded
|
||||
}
|
||||
|
||||
// Step 7: Check if rate limit is exceeded
|
||||
// Count how many valid requests exist
|
||||
// If count >= limit, block the request
|
||||
//
|
||||
// Example: limit=5
|
||||
// validRequests length=4 ✅ Allow (4 < 5)
|
||||
// validRequests length=5 ❌ Block (5 >= 5)
|
||||
// validRequests length=6 ❌ Block (6 >= 5)
|
||||
if len(validRequests) >= rl.limit {
|
||||
// IMPORTANT: Unlock mutex before returning error
|
||||
// Without this, mutex stays locked forever (deadlock!)
|
||||
rl.mu.Unlock()
|
||||
|
||||
// Return 429 Too Many Requests
|
||||
// This is the standard HTTP status code for rate limiting
|
||||
// Client should wait before retrying
|
||||
//
|
||||
// Best practice: Include Retry-After header (not implemented here)
|
||||
// Example: Retry-After: 60 (wait 60 seconds)
|
||||
return echo.NewHTTPError(429, "too many requests")
|
||||
}
|
||||
|
||||
// Step 8: Request is allowed - add current request to tracking
|
||||
// Append current timestamp to the valid requests
|
||||
// This request will count against future rate limit checks
|
||||
validRequests = append(validRequests, now)
|
||||
|
||||
// Step 9: Update the requests map with cleaned + new request
|
||||
// Replace old request list (which had expired requests) with new list
|
||||
// New list contains:
|
||||
// - Recent requests (within window)
|
||||
// - Current request (just made)
|
||||
// Old requests outside window are now garbage collected
|
||||
rl.requests[ip] = validRequests
|
||||
|
||||
// Step 10: Unlock the mutex
|
||||
// CRITICAL: Must unlock before calling next handler
|
||||
// Why: Next handler might take time (database query, etc.)
|
||||
// If we don't unlock, other requests will wait unnecessarily
|
||||
// Unlock here allows other IPs to be rate-limited concurrently
|
||||
rl.mu.Unlock()
|
||||
|
||||
// Step 11: Proceed to the actual route handler
|
||||
// Rate limit check passed, execute the requested operation
|
||||
// This could be login, API call, registration, etc.
|
||||
return next(c)
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Session represents an authenticated user session in the system.
|
||||
// A session is created when a user logs in and tracks their authentication state.
|
||||
//
|
||||
// What is a session?
|
||||
// - Created during login
|
||||
// - Associated with a refresh token
|
||||
// - Tracks device and location information
|
||||
// - Can be individually revoked
|
||||
// - Has an expiration date
|
||||
//
|
||||
// Why track sessions?
|
||||
// 1. Security: See all active logins
|
||||
// 2. Control: Revoke specific sessions ("logout from my phone")
|
||||
// 3. Audit: Track when/where users logged in
|
||||
// 4. Device management: Show users their active devices
|
||||
// 5. Token validation: Verify refresh tokens haven't been revoked
|
||||
//
|
||||
// Session lifecycle:
|
||||
// 1. Created: When user logs in
|
||||
// 2. Active: Used to refresh access tokens
|
||||
// 3. Last used updated: Every time refresh token is used
|
||||
// 4. Expired: When expires_at passes
|
||||
// 5. Revoked: When user logs out or admin revokes
|
||||
// 6. Deleted: Cleanup job removes old expired/revoked sessions
|
||||
//
|
||||
// Security model:
|
||||
// - Refresh token hash stored (not plaintext)
|
||||
// - Session can be revoked (logout)
|
||||
// - All sessions can be revoked (password change)
|
||||
// - Tracks device/location for anomaly detection
|
||||
type Session struct {
|
||||
// ID is the unique identifier for this session
|
||||
// Type: UUID v4 (128-bit random identifier)
|
||||
// Generated: Automatically by database on insert
|
||||
// Used for: Looking up sessions, revoking specific sessions
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
|
||||
// UserID identifies which user this session belongs to
|
||||
// Type: UUID v4 (foreign key to users table)
|
||||
// Used for: Finding all sessions for a user, revoking all user sessions
|
||||
// Relationship: Many sessions can belong to one user
|
||||
UserID uuid.UUID `json:"user_id" db:"user_id"`
|
||||
|
||||
// RefreshTokenHash is the hashed version of the refresh token
|
||||
// Type: String (base64-encoded SHA-256 hash)
|
||||
// Security: NEVER expose this in API responses (hence json tag comment)
|
||||
// Why hashed: If database breached, tokens can't be used
|
||||
// Hashing: SHA-256 (deterministic, allows lookup)
|
||||
// Storage: Should be excluded from JSON in production/staging
|
||||
RefreshTokenHash string `json:"refresh_token_hash" db:"refresh_token_hash"` // Never expose in JSON in (prod, staging)
|
||||
|
||||
// UserAgent contains the browser/application identifier
|
||||
// Type: Optional string (can be null)
|
||||
// Example: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/91.0.4472.124"
|
||||
// Used for: Displaying device information to user
|
||||
// Privacy: Contains OS and browser info (PII consideration)
|
||||
UserAgent *string `json:"user_agent" db:"user_agent"`
|
||||
|
||||
// IPAddress is the IP address from which the session was created
|
||||
// Type: Optional string (can be null, IPv4 or IPv6)
|
||||
// Example: "192.168.1.1" or "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
|
||||
// Used for: Location display, anomaly detection
|
||||
// Privacy: PII under GDPR, may need anonymization after time
|
||||
// Note: Could be proxy IP if behind load balancer
|
||||
IPAddress *string `json:"ip_address" db:"ip_address"`
|
||||
|
||||
// DeviceName is an optional user-friendly name for the device
|
||||
// Type: Optional string (can be null)
|
||||
// Example: "John's iPhone", "Work Laptop", "Home PC"
|
||||
// Used for: User convenience (easier to identify devices)
|
||||
// Set by: Client can optionally provide this
|
||||
DeviceName *string `json:"device_name" db:"device_name"`
|
||||
|
||||
// DeviceType categorizes the type of device
|
||||
// Type: String (enum-like values)
|
||||
// Possible values: "mobile", "desktop", "web", "unknown"
|
||||
// Detection: Based on user agent string parsing
|
||||
// Used for: Filtering sessions, displaying appropriate icons
|
||||
DeviceType string `json:"device_type" db:"device_type"`
|
||||
|
||||
// ExpiresAt is when this session expires
|
||||
// Type: Timestamp (typically 7 days from creation)
|
||||
// After expiry: Session cannot be used to get new access tokens
|
||||
// Cleanup: Expired sessions eventually deleted by cleanup job
|
||||
// Database check: Queries filter out expired sessions
|
||||
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
|
||||
|
||||
// IsRevoked indicates if session has been explicitly invalidated
|
||||
// Type: Boolean (default false)
|
||||
// Set to true when: User logs out, password changed, admin action
|
||||
// Effect: Refresh token can no longer be used
|
||||
// Database: Indexed for faster queries
|
||||
IsRevoked bool `json:"is_revoked" db:"is_revoked"`
|
||||
|
||||
// RevokedAt is when the session was revoked
|
||||
// Type: Optional timestamp (null if not revoked)
|
||||
// Set when: IsRevoked changed to true
|
||||
// Used for: Audit trail, analytics
|
||||
RevokedAt *time.Time `json:"revoked_at" db:"revoked_at"`
|
||||
|
||||
// RevokedReason explains why the session was revoked
|
||||
// Type: Optional string (null if not revoked)
|
||||
// Common values: "user_logout", "password_change", "admin_action", "security_breach"
|
||||
// Used for: Audit trail, understanding logout patterns, security investigations
|
||||
RevokedReason *string `json:"revoked_reason" db:"revoked_reason"`
|
||||
|
||||
// CreatedAt is when the session was created (login time)
|
||||
// Type: Timestamp (automatically set by database)
|
||||
// Used for: Displaying "logged in" time, sorting sessions
|
||||
// Database: Set by DEFAULT NOW() in schema
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
|
||||
// LastUsedAt is when the refresh token was last used
|
||||
// Type: Timestamp (updated on each token refresh)
|
||||
// Used for: Showing session activity, identifying stale sessions
|
||||
// Updated: Every ~15 minutes when access token is refreshed
|
||||
// Cleanup: Can remove sessions not used in X days
|
||||
LastUsedAt time.Time `json:"last_used_at" db:"last_used_at"`
|
||||
}
|
||||
|
||||
// CreateSessionInput contains the data needed to create a new session.
|
||||
// This is a DTO (Data Transfer Object) used to pass data to the repository.
|
||||
//
|
||||
// Why separate input struct?
|
||||
// - Separates what client provides from what database generates
|
||||
// - Clear interface for session creation
|
||||
// - Database generates ID and timestamps
|
||||
// - Type safety (can't accidentally set ID or timestamps)
|
||||
//
|
||||
// When used:
|
||||
// - During login (user authentication)
|
||||
// - When creating refresh token
|
||||
type CreateSessionInput struct {
|
||||
// UserID identifies which user this session belongs to
|
||||
// Required: Must be valid user ID
|
||||
UserID uuid.UUID
|
||||
|
||||
// RefreshToken is the plaintext token to be hashed
|
||||
// Security: Will be hashed before storage (SHA-256)
|
||||
// Never stored plaintext in database
|
||||
// Generated: Random 32-byte value, base64 encoded
|
||||
RefreshToken string
|
||||
|
||||
// UserAgent is optional browser/app information
|
||||
// Extracted from: HTTP User-Agent header
|
||||
// Can be nil: If header not provided
|
||||
UserAgent *string
|
||||
|
||||
// IPAddress is optional IP address of request
|
||||
// Extracted from: X-Forwarded-For or RemoteAddr
|
||||
// Can be nil: If not available
|
||||
IPAddress *string
|
||||
|
||||
// DeviceName is optional user-friendly device name
|
||||
// Provided by: Client (optional)
|
||||
// Can be nil: Most often not provided
|
||||
DeviceName *string
|
||||
|
||||
// DeviceType categorizes the device
|
||||
// Detected from: User agent string
|
||||
// Values: "mobile", "desktop", "web", "unknown"
|
||||
// Required: Always set (uses "unknown" if can't detect)
|
||||
DeviceType string
|
||||
|
||||
// ExpiresAt is when the session should expire
|
||||
// Calculated: Now + RefreshExpiry (typically 7 days)
|
||||
// Required: Must be set
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// User represents a user account in the system.
|
||||
// This is the core user entity containing authentication and profile information.
|
||||
//
|
||||
// What is a user?
|
||||
// - Account with unique email
|
||||
// - Associated with a tenant (multi-tenancy)
|
||||
// - Has roles for authorization
|
||||
// - Contains profile information
|
||||
// - Tracks authentication state
|
||||
//
|
||||
// User lifecycle:
|
||||
// 1. Created: During registration
|
||||
// 2. Pending: Email verification required (optional)
|
||||
// 3. Active: Can log in and use system
|
||||
// 4. Suspended: Temporarily disabled
|
||||
// 5. Deleted: Soft deleted (deleted_at set)
|
||||
//
|
||||
// Multi-tenancy:
|
||||
// - Each user belongs to one tenant (organization)
|
||||
// - Tenant ID used for data isolation
|
||||
// - Cross-tenant access not allowed
|
||||
// - Important for SaaS applications
|
||||
//
|
||||
// Security considerations:
|
||||
// - Password never exposed (json:"-" tag)
|
||||
// - Soft delete preserves data integrity
|
||||
// - Email is unique identifier
|
||||
// - Role-based access control
|
||||
type User struct {
|
||||
// ID is the unique identifier for this user
|
||||
// Type: UUID v4 (128-bit random identifier)
|
||||
// Generated: Automatically by database on insert
|
||||
// Primary key: Used for all lookups and relationships
|
||||
ID uuid.UUID `json:"id" bson:"id"`
|
||||
|
||||
// TenantID identifies which organization this user belongs to
|
||||
// Type: UUID v4 (foreign key to tenants table)
|
||||
// Multi-tenancy: Isolates data between organizations
|
||||
// Required: Every user must belong to a tenant
|
||||
// Used for: Filtering queries, enforcing data isolation
|
||||
TenantID uuid.UUID `json:"tenant_id" bson:"tenant_id"`
|
||||
|
||||
// Email is the user's email address (unique identifier for login)
|
||||
// Type: String (validated format, max 254 chars)
|
||||
// Unique: Within system (can't have duplicate accounts)
|
||||
// Normalized: Stored as lowercase for consistent matching
|
||||
// Used for: Login, communication, uniqueness
|
||||
// Privacy: PII, must be protected
|
||||
Email string `json:"email" db:"email"`
|
||||
|
||||
// PasswordHash is the bcrypt hash of the user's password
|
||||
// Type: Optional string (can be null for social login)
|
||||
// Format: "$2a$10$..." (bcrypt hash with salt)
|
||||
// Security: NEVER expose in API responses (json:"-" means omit)
|
||||
// Hashing: Bcrypt with cost factor 10
|
||||
// Why optional: Users with social login may not have password
|
||||
PasswordHash *string `json:"_" db:"password_hash"`
|
||||
|
||||
// FirstName is the user's first name
|
||||
// Type: Optional string (can be null)
|
||||
// Used for: Personalization, display
|
||||
// Privacy: PII, must be protected
|
||||
FirstName *string `json:"first_name" db:"first_name"`
|
||||
|
||||
// LastName is the user's last name
|
||||
// Type: Optional string (can be null)
|
||||
// Used for: Personalization, display
|
||||
// Privacy: PII, must be protected
|
||||
LastName *string `json:"last_name" db:"last_name"`
|
||||
|
||||
// FullName is the computed full name (first + last)
|
||||
// Type: String (computed by database trigger or application)
|
||||
// Generated: From first_name and last_name
|
||||
// Used for: Display purposes, searching
|
||||
// Database: Might be a generated column or manually updated
|
||||
FullName string `json:"full_name" db:"full_name"`
|
||||
|
||||
// AvatarURL is the URL to the user's profile picture
|
||||
// Type: Optional string (can be null)
|
||||
// Storage: URL points to file in object storage (MinIO/S3)
|
||||
// Default: System can provide default avatar if null
|
||||
// Privacy: Publicly accessible or requires auth
|
||||
AvatarURL *string `json:"avatar_url" db:"avatar_url"`
|
||||
|
||||
// Phone is the user's phone number
|
||||
// Type: Optional string (can be null)
|
||||
// Format: Should be E.164 format (+1234567890)
|
||||
// Used for: 2FA, notifications, contact
|
||||
// Privacy: PII, must be protected
|
||||
// Verification: Should require phone verification
|
||||
Phone *string `json:"phone" db:"phone"`
|
||||
|
||||
// Role defines the user's permissions level
|
||||
// Type: String (enum-like values)
|
||||
// Common values: "admin", "user", "manager", "viewer"
|
||||
// Used for: Authorization checks, feature access
|
||||
// Default: Usually "user" for new accounts
|
||||
// Important: Always check role before allowing operations
|
||||
Role string `json:"role" db:"role"`
|
||||
|
||||
// Status indicates the current state of the account
|
||||
// Type: String (enum-like values)
|
||||
// Possible values: "active", "pending", "suspended", "deleted"
|
||||
// Active: Can log in normally
|
||||
// Pending: Awaiting email verification
|
||||
// Suspended: Temporarily disabled (can't log in)
|
||||
// Deleted: Soft deleted (should have deleted_at set)
|
||||
Status string `json:"status" db:"status"`
|
||||
|
||||
// EmailVerified indicates if email has been verified
|
||||
// Type: Boolean (default false)
|
||||
// Purpose: Confirm user owns the email address
|
||||
// Workflow: User clicks link in verification email
|
||||
// Requirement: Some systems require verification before full access
|
||||
EmailVerified bool `json:"email_verified" db:"email_verified"`
|
||||
|
||||
// EmailVerifiedAt is when the email was verified
|
||||
// Type: Optional timestamp (null if not verified)
|
||||
// Set when: User clicks verification link
|
||||
// Used for: Audit trail, resend logic
|
||||
EmailVerifiedAt *time.Time `json:"email_verified_at" db:"email_verified_at"`
|
||||
|
||||
// IsOnboarded indicates if user completed onboarding
|
||||
// Type: Boolean (default false)
|
||||
// Purpose: Track if user saw welcome/tutorial
|
||||
// Used for: Showing onboarding flow
|
||||
// Set to true: After user completes onboarding steps
|
||||
IsOnboarded bool `json:"is_onboarded" db:"is_onboarded"`
|
||||
|
||||
// LastLoginAt is when the user last logged in
|
||||
// Type: Optional timestamp (null if never logged in)
|
||||
// Updated: After successful authentication
|
||||
// Used for: Security monitoring, activity tracking
|
||||
// Display: "Last login: 2 hours ago"
|
||||
LastLoginAt *time.Time `json:"last_login_at" db:"last_login_at"`
|
||||
|
||||
// LastLoginIP is the IP address of last login
|
||||
// Type: Optional string (null if never logged in)
|
||||
// Format: IPv4 or IPv6
|
||||
// Used for: Security monitoring, anomaly detection
|
||||
// Privacy: PII under GDPR, may need anonymization
|
||||
LastLoginIP *string `json:"last_login_ip" db:"last_login_ip"`
|
||||
|
||||
// CreatedAt is when the user account was created
|
||||
// Type: Timestamp (automatically set by database)
|
||||
// Used for: Account age, analytics, sorting
|
||||
// Database: Set by DEFAULT NOW() in schema
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
|
||||
// UpdatedAt is when the user record was last modified
|
||||
// Type: Timestamp (automatically updated)
|
||||
// Updated: Any time user record changes
|
||||
// Used for: Audit trail, change tracking
|
||||
// Database: Updated by trigger or application
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
|
||||
// DeletedAt is when the user was soft deleted
|
||||
// Type: Optional timestamp (null if not deleted)
|
||||
// Soft delete: Record preserved but marked as deleted
|
||||
// Used for: Data integrity, audit trail
|
||||
// Omitted from JSON if null (json:"deleted_at,omitempty")
|
||||
// Queries: Filter WHERE deleted_at IS NULL
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
|
||||
}
|
||||
|
||||
// CreateUserInput contains the data needed to create a new user account.
|
||||
// This is a DTO (Data Transfer Object) for the registration/user creation flow.
|
||||
//
|
||||
// Why separate input struct?
|
||||
// - Separates client input from database-generated fields
|
||||
// - Clear interface for what's required vs what's generated
|
||||
// - Type safety (can't set ID, timestamps, etc.)
|
||||
// - Validation happens on this struct
|
||||
//
|
||||
// When used:
|
||||
// - User registration
|
||||
// - Admin creating user
|
||||
// - Invitation flow
|
||||
type CreateUserInput struct {
|
||||
// TenantID is which organization the user belongs to
|
||||
// Required: Every user must belong to a tenant
|
||||
// Set by: System (based on signup domain, invitation, etc.)
|
||||
TenantID uuid.UUID
|
||||
|
||||
// Email is the user's email address
|
||||
// Required: Used for login and communication
|
||||
// Validation: Must be valid email format, must be unique
|
||||
// Normalized: Will be lowercased before storage
|
||||
Email string
|
||||
|
||||
// Password is the plaintext password
|
||||
// Required: Must meet strength requirements
|
||||
// Security: Will be hashed with bcrypt before storage
|
||||
// Validation: Checked for length, complexity, common patterns
|
||||
// NEVER logged or stored plaintext
|
||||
Password string
|
||||
|
||||
// FirstName is the user's first name
|
||||
// Optional: Can be null (but recommended)
|
||||
// Used for: Personalization, display
|
||||
FirstName *string
|
||||
|
||||
// LastName is the user's last name
|
||||
// Optional: Can be null (but recommended)
|
||||
// Used for: Personalization, display
|
||||
LastName *string
|
||||
|
||||
// Role is the user's permission level
|
||||
// Required: Must be set (often defaults to "user")
|
||||
// Values: "admin", "user", "manager", etc.
|
||||
// Set by: System (based on registration type) or admin
|
||||
Role string
|
||||
|
||||
// Status is the initial account status
|
||||
// Required: Usually "pending" or "active"
|
||||
// "pending": Requires email verification
|
||||
// "active": Can log in immediately
|
||||
// Set by: System based on email verification policy
|
||||
Status string
|
||||
}
|
||||
|
||||
// UserResponse is a sanitized user object for API responses.
|
||||
// This DTO removes sensitive fields before sending to client.
|
||||
//
|
||||
// Why separate response struct?
|
||||
// - Security: Never expose password_hash or internal IDs
|
||||
// - API contract: Clear definition of what clients receive
|
||||
// - Flexibility: Can add computed fields without changing User model
|
||||
// - Versioning: Can have different responses for API versions
|
||||
//
|
||||
// What's excluded from User:
|
||||
// - PasswordHash: NEVER expose password hashes
|
||||
// - DeletedAt: Internal field, not relevant to client
|
||||
// - Internal IDs: Some may be excluded depending on use case
|
||||
//
|
||||
// When used:
|
||||
// - Login response
|
||||
// - Profile endpoint
|
||||
// - User list endpoints
|
||||
// - Any API response containing user data
|
||||
type UserResponse struct {
|
||||
// ID is the user's unique identifier
|
||||
ID uuid.UUID `json:"id"`
|
||||
|
||||
// TenantID for multi-tenancy
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
|
||||
// Email for display and communication
|
||||
Email string `json:"email"`
|
||||
|
||||
// FirstName for personalization
|
||||
// Note: JSON tag shows "name" but field is FirstName (might be typo)
|
||||
FirstName *string `json:"name"`
|
||||
|
||||
// LastName for full name display
|
||||
LastName *string `json:"last_name"`
|
||||
|
||||
// FullName computed from first + last
|
||||
FullName string `json:"full_name"`
|
||||
|
||||
// AvatarURL for profile picture
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
|
||||
// Phone for contact
|
||||
Phone *string `json:"phone"`
|
||||
|
||||
// Role for client-side permission checks
|
||||
Role string `json:"role"`
|
||||
|
||||
// Status to show account state
|
||||
Status string `json:"status"`
|
||||
|
||||
// EmailVerified to prompt verification if needed
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
|
||||
// IsOnboarded to show onboarding if needed
|
||||
IsOnboarded bool `json:"is_onboarded"`
|
||||
|
||||
// LastLoginAt for security awareness
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
|
||||
// CreatedAt for account age
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Note: Excludes PasswordHash, DeletedAt, UpdatedAt, LastLoginIP
|
||||
}
|
||||
|
||||
// ToResponse converts a User model to a UserResponse DTO.
|
||||
// This method sanitizes the user object before sending to client.
|
||||
//
|
||||
// Why this method?
|
||||
// - Encapsulation: Conversion logic lives with the model
|
||||
// - Reusability: Can be called anywhere needed
|
||||
// - Type safety: Returns correct response type
|
||||
// - Maintainability: One place to update response structure
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// user := getUserFromDB()
|
||||
// response := user.ToResponse()
|
||||
// return c.JSON(http.StatusOK, response)
|
||||
//
|
||||
// What it does:
|
||||
// - Copies public fields from User to UserResponse
|
||||
// - Excludes sensitive fields (password_hash)
|
||||
// - Excludes internal fields (deleted_at, updated_at, last_login_ip)
|
||||
//
|
||||
// Returns:
|
||||
// - UserResponse with safe fields populated
|
||||
func (u *User) ToResponse() *UserResponse {
|
||||
return &UserResponse{
|
||||
ID: u.ID,
|
||||
TenantID: u.TenantID,
|
||||
Email: u.Email,
|
||||
FirstName: u.FirstName,
|
||||
LastName: u.LastName,
|
||||
FullName: u.FullName,
|
||||
AvatarURL: u.AvatarURL,
|
||||
Phone: u.Phone,
|
||||
Role: u.Role,
|
||||
Status: u.Status,
|
||||
EmailVerified: u.EmailVerified,
|
||||
IsOnboarded: u.IsOnboarded,
|
||||
LastLoginAt: u.LastLoginAt,
|
||||
CreatedAt: u.CreatedAt,
|
||||
// Intentionally excluded:
|
||||
// - PasswordHash (security)
|
||||
// - LastLoginIP (privacy)
|
||||
// - DeletedAt (internal)
|
||||
// - UpdatedAt (internal)
|
||||
// - EmailVerifiedAt (redundant with EmailVerified boolean)
|
||||
}
|
||||
}
|
||||
|
||||
// Note about database migration:
|
||||
// The comment at the end of the original file mentions:
|
||||
// "Current DB structure need to updated the migration script to reflect the current UserEntity"
|
||||
//
|
||||
// This suggests the database schema may be out of sync with this model.
|
||||
// The old structure mentioned was:
|
||||
// id | tenant_id | email | password_hash | name | avatar_url | role | is_active |
|
||||
// email_verified_at | last_login_at | created_at | updated_at | deleted_at
|
||||
//
|
||||
// Differences from current model:
|
||||
// 1. "name" field vs "first_name" + "last_name" + "full_name"
|
||||
// 2. "is_active" field vs "status" field (enum)
|
||||
// 3. Missing "phone" field
|
||||
// 4. Missing "email_verified" boolean
|
||||
// 5. Missing "is_onboarded" boolean
|
||||
// 6. Missing "last_login_ip" field
|
||||
//
|
||||
// Action required:
|
||||
// - Create database migration to update schema
|
||||
// - Or update model to match current database (then migrate later)
|
||||
// - Ensure model and database stay in sync
|
||||
|
|
@ -0,0 +1,586 @@
|
|||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/creativenoz/aurganize-v62/backend/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// SessionRepository handles all database operations related to user sessions.
|
||||
// A session represents an authenticated user's connection/login instance.
|
||||
//
|
||||
// What is a session?
|
||||
// - Created when a user logs in
|
||||
// - Stores refresh token information
|
||||
// - Tracks device/location information
|
||||
// - Can be revoked to log out a specific device
|
||||
// - Has an expiration date
|
||||
//
|
||||
// Why track sessions?
|
||||
// 1. Security: See all active login locations/devices
|
||||
// 2. Control: Revoke specific sessions (e.g., "logout from my phone")
|
||||
// 3. Audit: Track when/where users log in
|
||||
// 4. Token validation: Verify refresh tokens haven't been revoked
|
||||
//
|
||||
// Architecture pattern: Repository Pattern
|
||||
// - Abstracts database operations
|
||||
// - Provides clean interface for data access
|
||||
// - Makes testing easier (can mock repository)
|
||||
// - Keeps SQL queries separate from business logic
|
||||
type SessionRepository struct {
|
||||
db *sqlx.DB // sqlx provides enhanced database operations (named queries, struct scanning)
|
||||
}
|
||||
|
||||
// NewSessionRepository creates a new instance of SessionRepository.
|
||||
// This constructor follows dependency injection pattern:
|
||||
// - Database connection passed in rather than created internally
|
||||
// - Makes testing easier (can pass test database)
|
||||
// - Keeps repository flexible (works with any sqlx.DB connection)
|
||||
//
|
||||
// Parameter:
|
||||
// - db: The database connection pool to use for all operations
|
||||
//
|
||||
// Returns:
|
||||
// - Initialized SessionRepository ready to perform database operations
|
||||
func NewSessionRepository(db *sqlx.DB) *SessionRepository {
|
||||
return &SessionRepository{db: db}
|
||||
}
|
||||
|
||||
// Create creates a new session record in the database.
|
||||
// This is called when a user logs in to track the authentication session.
|
||||
//
|
||||
// What happens here:
|
||||
// 1. Hashes the refresh token (security - never store raw tokens)
|
||||
// 2. Inserts session record with user info, device info, expiration
|
||||
// 3. Returns the created session with generated ID and timestamps
|
||||
//
|
||||
// Why hash the token?
|
||||
// - If database is compromised, attackers can't use the tokens directly
|
||||
// - Hashing is one-way (can verify but can't recover original)
|
||||
// - Similar to password hashing but using SHA-256 instead of bcrypt
|
||||
//
|
||||
// Token hashing strategy explained:
|
||||
// We use SHA-256 instead of bcrypt because:
|
||||
// - bcrypt is for passwords (slow, salted, designed for brute-force resistance)
|
||||
// - bcrypt generates different hash each time for same input (random salt)
|
||||
// - SHA-256 is for tokens (fast, deterministic, allows exact lookup)
|
||||
// - SHA-256 always produces same hash for same input (what we need for token lookup)
|
||||
//
|
||||
// If we used bcrypt:
|
||||
// - Each login would generate different hash for same token
|
||||
// - We couldn't look up sessions by token (bcrypt needs to compare, not lookup)
|
||||
// - Token validation would require scanning all sessions (very slow)
|
||||
//
|
||||
// Flow:
|
||||
// 1. Hash the plaintext refresh token using SHA-256
|
||||
// 2. Insert session record with hashed token
|
||||
// 3. Database generates ID, timestamps
|
||||
// 4. Return complete session object
|
||||
//
|
||||
// Error handling:
|
||||
// - Returns error if database insert fails
|
||||
// - Caller should handle errors (usually return 500 to client)
|
||||
func (r *SessionRepository) Create(ctx context.Context, input *models.CreateSessionInput) (*models.Session, error) {
|
||||
// OLD CODE (commented out) - Why bcrypt doesn't work for tokens:
|
||||
// hash, err := bcrypt.GenerateFromPassword([]byte(input.RefreshToken), bcrypt.DefaultCost)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
// EXPLANATION OF WHY BCRYPT DOESN'T WORK:
|
||||
// bcrypt is designed for passwords (slow, with salt, for brute-force protection)
|
||||
// For tokens, you should use SHA-256 (fast, deterministic hash)
|
||||
//
|
||||
// Why this matters:
|
||||
// * bcrypt generates a different hash each time for the same input (because of random salt)
|
||||
// - Example: Hash("mytoken") could give "$2a$10$abcd..." first time and "$2a$10$xyz..." second time
|
||||
// * When you try to verify the token later, bcrypt.CompareHashAndPassword won't work with the plain token
|
||||
// - You'd need to store which hash belongs to which token (defeats the purpose)
|
||||
// * SHA-256 always produces the same hash for the same input (what you need for token lookup)
|
||||
// - Example: Hash("mytoken") always gives "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||
//
|
||||
// NEW CODE: Hash token using SHA-256 for deterministic lookup
|
||||
hash := hashToken(input.RefreshToken)
|
||||
|
||||
// Prepare session struct to receive database response
|
||||
session := &models.Session{}
|
||||
|
||||
// SQL query to insert new session
|
||||
// Uses RETURNING clause to get back the created record in one database round-trip
|
||||
// This is PostgreSQL-specific syntax (MySQL would need separate SELECT after INSERT)
|
||||
query := `
|
||||
INSERT INTO sessions (
|
||||
user_id, -- Which user this session belongs to
|
||||
refresh_token_hash,-- Hashed refresh token (never store plaintext tokens!)
|
||||
user_agent, -- Browser/app information (e.g., "Mozilla/5.0...")
|
||||
ip_address, -- IP address user logged in from
|
||||
device_name, -- Optional device name (e.g., "John's iPhone")
|
||||
device_type, -- Device category: "mobile", "desktop", "web"
|
||||
expires_at -- When this session expires (usually 7 days from now)
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
RETURNING id, user_id, refresh_token_hash, user_agent,
|
||||
ip_address, device_name, device_type, expires_at, is_revoked,
|
||||
revoked_at, revoked_reason, created_at, last_used_at
|
||||
`
|
||||
|
||||
// Execute query and scan result directly into session struct
|
||||
// GetContext:
|
||||
// - Executes query with context (supports cancellation/timeout)
|
||||
// - Expects exactly one row returned
|
||||
// - Maps columns to struct fields by matching db tags
|
||||
// - Returns error if query fails or row count != 1
|
||||
err := r.db.GetContext(
|
||||
ctx,
|
||||
session, // Destination struct
|
||||
query, // SQL query
|
||||
// Parameters matching $1, $2, $3, etc. in query
|
||||
input.UserID,
|
||||
string(hash), // Store hash, not raw token
|
||||
input.UserAgent,
|
||||
input.IPAddress,
|
||||
input.DeviceName,
|
||||
input.DeviceType,
|
||||
input.ExpiresAt,
|
||||
)
|
||||
return session, err
|
||||
}
|
||||
|
||||
// FindBySessionIDAndToken looks up a valid session by session ID and refresh token.
|
||||
// This is used to validate refresh tokens during token refresh requests.
|
||||
//
|
||||
// Why we need both session ID and token:
|
||||
// - Session ID comes from JWT claims (identifies which session)
|
||||
// - Token is the actual refresh token (proves possession)
|
||||
// - Both must match for validation to succeed
|
||||
//
|
||||
// Security checks performed:
|
||||
// 1. Token hash must match stored hash
|
||||
// 2. Session ID must match
|
||||
// 3. Session must not be revoked (is_revoked = FALSE)
|
||||
// 4. Session must not be expired (expires_at > NOW())
|
||||
//
|
||||
// Why hash the token for lookup?
|
||||
// - We never store plaintext tokens in database
|
||||
// - Hash the provided token using same algorithm (SHA-256)
|
||||
// - Look up by the hash
|
||||
// - If database is breached, attackers get hashes, not usable tokens
|
||||
//
|
||||
// Flow:
|
||||
// 1. Hash the provided token
|
||||
// 2. Query database for matching session ID and token hash
|
||||
// 3. Only return if session is valid (not revoked, not expired)
|
||||
// 4. Return nil if not found (not an error, just not found)
|
||||
//
|
||||
// Return values:
|
||||
// - (*Session, nil): Session found and valid
|
||||
// - (nil, nil): Session not found (not revoked/expired, or doesn't exist)
|
||||
// - (nil, error): Database error occurred
|
||||
func (r *SessionRepository) FindBySessionIDAndToken(ctx context.Context, sessionId uuid.UUID, token string) (*models.Session, error) {
|
||||
session := &models.Session{}
|
||||
|
||||
// SQL query with multiple conditions for security
|
||||
query := `
|
||||
SELECT id, user_id, refresh_token_hash, user_agent,
|
||||
ip_address, device_name, device_type, expires_at, is_revoked,
|
||||
revoked_at, revoked_reason, created_at, last_used_at
|
||||
FROM sessions
|
||||
WHERE refresh_token_hash = $1 -- Token must match
|
||||
AND id = $2 -- Session ID must match
|
||||
AND is_revoked = FALSE -- Session must not be revoked
|
||||
AND expires_at > NOW() -- Session must not be expired
|
||||
`
|
||||
|
||||
// Hash the provided token using same algorithm used during creation
|
||||
// This allows us to look up the session by the hash
|
||||
tokenHash := hashToken(token)
|
||||
|
||||
// Execute query
|
||||
err := r.db.GetContext(
|
||||
ctx,
|
||||
session,
|
||||
query,
|
||||
tokenHash, // $1 - Hashed token for lookup
|
||||
sessionId, // $2 - Session ID for matching
|
||||
)
|
||||
|
||||
// Handle "not found" case specially
|
||||
// sql.ErrNoRows means query executed successfully but returned no rows
|
||||
// This is not an error condition - it just means session doesn't exist or is invalid
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil // Return nil session and nil error
|
||||
}
|
||||
|
||||
return session, err
|
||||
}
|
||||
|
||||
// FindById retrieves a session by its ID if it's valid (not revoked, not expired).
|
||||
// This is useful for:
|
||||
// - Checking session status
|
||||
// - Updating session information
|
||||
// - Listing user's sessions
|
||||
//
|
||||
// Validation checks:
|
||||
// - Session must exist
|
||||
// - Session must not be revoked (is_revoked = FALSE)
|
||||
// - Session must not be expired (expires_at > NOW())
|
||||
//
|
||||
// Unlike FindBySessionIDAndToken, this doesn't verify the token itself,
|
||||
// just checks if the session exists and is valid.
|
||||
//
|
||||
// Return values:
|
||||
// - (*Session, nil): Session found and valid
|
||||
// - (nil, nil): Session not found or is invalid
|
||||
// - (nil, error): Database error occurred
|
||||
func (r *SessionRepository) FindById(ctx context.Context, id uuid.UUID) (*models.Session, error) {
|
||||
session := &models.Session{}
|
||||
|
||||
// Query for session by ID with validation checks
|
||||
query := `
|
||||
SELECT id, user_id, refresh_token_hash, user_agent,
|
||||
ip_address, device_name, device_type, expires_at, is_revoked,
|
||||
revoked_at, revoked_reason, created_at, last_used_at
|
||||
FROM sessions
|
||||
WHERE id = $1 -- Match session ID
|
||||
AND is_revoked = FALSE -- Must not be revoked
|
||||
AND expires_at > NOW() -- Must not be expired
|
||||
`
|
||||
|
||||
err := r.db.GetContext(
|
||||
ctx,
|
||||
session,
|
||||
query,
|
||||
id, // $1 - Session ID
|
||||
)
|
||||
|
||||
// Handle "not found" case
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil // Not found is not an error
|
||||
}
|
||||
return session, err
|
||||
}
|
||||
|
||||
// UpdateLastUsed updates the last_used_at timestamp for a session.
|
||||
// This is called whenever a session's refresh token is used to get a new access token.
|
||||
//
|
||||
// Why track last usage?
|
||||
// 1. Security: Identify sessions that haven't been used recently
|
||||
// 2. Cleanup: Can remove stale sessions
|
||||
// 3. User awareness: Show users which sessions are actively being used
|
||||
// 4. Anomaly detection: Unusual usage patterns might indicate compromise
|
||||
//
|
||||
// Called by:
|
||||
// - Token refresh endpoint (every time user gets new access token)
|
||||
// - Typically happens every 15 minutes (when access token expires)
|
||||
//
|
||||
// Updates:
|
||||
// - last_used_at: Set to current database time (NOW())
|
||||
//
|
||||
// Error handling:
|
||||
// - Returns error if update fails (database error)
|
||||
// - Caller usually ignores this error (not critical for token refresh)
|
||||
func (r *SessionRepository) UpdateLastUsed(ctx context.Context, id uuid.UUID) error {
|
||||
// SQL update query
|
||||
// Uses NOW() for database-consistent timestamp (not Go's time.Now())
|
||||
query := `
|
||||
UPDATE sessions
|
||||
SET last_used_at = NOW() -- Update to current database time
|
||||
WHERE id=$1 -- Only update this session
|
||||
`
|
||||
|
||||
// ExecContext executes query that doesn't return rows (UPDATE, DELETE, etc.)
|
||||
// Returns:
|
||||
// - sql.Result: Contains rows affected, last insert ID, etc.
|
||||
// - error: Database error if query fails
|
||||
_, err := r.db.ExecContext(
|
||||
ctx,
|
||||
query,
|
||||
id, // $1 - Session ID
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Revoke marks a session as revoked, preventing its refresh token from being used.
|
||||
// This is called during:
|
||||
// - User logout (revoke current session)
|
||||
// - Security actions (revoke compromised session)
|
||||
// - Administrative actions (force logout)
|
||||
//
|
||||
// What happens:
|
||||
// 1. Finds session by token hash
|
||||
// 2. Sets is_revoked = TRUE (marks as invalid)
|
||||
// 3. Sets revoked_at = NOW() (records when revoked)
|
||||
// 4. Sets revoked_reason (why it was revoked)
|
||||
//
|
||||
// Why track revocation reason?
|
||||
// - Audit trail: Know why sessions ended
|
||||
// - Analytics: Understand logout patterns
|
||||
// - Security: Identify security-related revocations
|
||||
// - User awareness: Can show user why session ended
|
||||
//
|
||||
// Common revocation reasons:
|
||||
// - "user_logout": User clicked logout button
|
||||
// - "password_change": Password was changed (invalidate all sessions)
|
||||
// - "security_breach": Suspected compromise
|
||||
// - "admin_action": Administrator revoked session
|
||||
// - "device_lost": User reported device lost/stolen
|
||||
//
|
||||
// Important: Once revoked, the session cannot be un-revoked.
|
||||
// User must log in again to create a new session.
|
||||
//
|
||||
// Error handling:
|
||||
// - Returns error if update fails
|
||||
// - No error if session doesn't exist (idempotent operation)
|
||||
func (r *SessionRepository) Revoke(ctx context.Context, token string, reason string) error {
|
||||
// Hash the token to find corresponding session
|
||||
// We store hashed tokens, so we must hash to look up
|
||||
tokenHash := hashToken(token)
|
||||
|
||||
// SQL update query to mark session as revoked
|
||||
query := `
|
||||
UPDATE sessions
|
||||
SET is_revoked = TRUE, -- Mark as revoked
|
||||
revoked_at = NOW(), -- Record revocation time
|
||||
revoked_reason = $2 -- Record why it was revoked
|
||||
WHERE refresh_token_hash=$1 -- Find session by token hash
|
||||
`
|
||||
|
||||
// Execute update
|
||||
// Note: UPDATE returns success even if no rows matched
|
||||
// This makes the operation idempotent (safe to call multiple times)
|
||||
_, err := r.db.ExecContext(
|
||||
ctx,
|
||||
query,
|
||||
tokenHash, // $1 - Token hash to find session
|
||||
reason, // $2 - Why session is being revoked
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeByUserId revokes all sessions for a specific user.
|
||||
// This is a security feature called "logout everywhere" or "logout all devices".
|
||||
//
|
||||
// When to use this:
|
||||
// 1. Password change: Invalidate all existing sessions (force re-login)
|
||||
// 2. Security breach: User reports account compromise
|
||||
// 3. Administrative action: Admin needs to force user logout
|
||||
// 4. Account deletion: Revoke all sessions before deleting user
|
||||
//
|
||||
// What it does:
|
||||
// - Finds all non-revoked sessions for the user
|
||||
// - Marks them all as revoked
|
||||
// - Records when and why they were revoked
|
||||
//
|
||||
// After calling this:
|
||||
// - All refresh tokens for this user become invalid
|
||||
// - User must log in again on all devices
|
||||
// - Current access tokens remain valid until they expire (typically 15 minutes)
|
||||
//
|
||||
// Note: This doesn't immediately invalidate access tokens because:
|
||||
// - Access tokens are stateless (not checked against database)
|
||||
// - They expire quickly anyway (15 minutes)
|
||||
// - Checking database for every API request would be slow
|
||||
// - For immediate invalidation, would need a token blacklist (expensive)
|
||||
//
|
||||
// Error handling:
|
||||
// - Returns error if update fails
|
||||
// - No error if user has no sessions (idempotent)
|
||||
func (r *SessionRepository) RevokeByUserId(ctx context.Context, userID uuid.UUID, reason string) error {
|
||||
// SQL query to revoke all user's sessions
|
||||
query := `
|
||||
UPDATE sessions
|
||||
SET is_revoked = TRUE, -- Mark as revoked
|
||||
revoked_at = NOW(), -- Record revocation time
|
||||
revoked_reason = $2 -- Record reason
|
||||
WHERE user_id = $1 -- All sessions for this user
|
||||
AND is_revoked = FALSE -- Only revoke non-revoked sessions (optimization)
|
||||
`
|
||||
|
||||
// Execute update
|
||||
// Could affect 0 to many rows depending on how many sessions user has
|
||||
_, err := r.db.ExecContext(
|
||||
ctx,
|
||||
query,
|
||||
userID, // $1 - User whose sessions to revoke
|
||||
reason, // $2 - Reason for revocation
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteExpired removes expired and old revoked sessions from database.
|
||||
// This is a cleanup/maintenance operation typically run as a scheduled job.
|
||||
//
|
||||
// What gets deleted:
|
||||
// 1. Sessions past their expiration date (expires_at < NOW())
|
||||
// 2. Revoked sessions older than 30 days (is_revoked AND revoked_at < 30 days ago)
|
||||
//
|
||||
// Why delete expired sessions?
|
||||
// - Database cleanup: Prevents unlimited growth
|
||||
// - Performance: Smaller tables = faster queries
|
||||
// - Privacy: No need to keep old session data forever
|
||||
// - Compliance: Data retention policies may require deletion
|
||||
//
|
||||
// Why keep revoked sessions for 30 days?
|
||||
// - Audit trail: Need recent history for security investigations
|
||||
// - User support: Can check recent logouts for support issues
|
||||
// - Analytics: Understand logout patterns
|
||||
// - After 30 days: Unlikely to need the data, safe to delete
|
||||
//
|
||||
// When to run this:
|
||||
// - Scheduled job: Daily or weekly (off-peak hours)
|
||||
// - Not during request handling: Too slow, not time-critical
|
||||
// - Could use database job scheduler or cron job
|
||||
//
|
||||
// Performance considerations:
|
||||
// - Could be slow if millions of sessions
|
||||
// - Consider adding indexes on expires_at and revoked_at
|
||||
// - Could batch delete (delete 1000 at a time) for very large tables
|
||||
// - Consider partitioning sessions table by date for easier cleanup
|
||||
//
|
||||
// Return value:
|
||||
// - Number of rows deleted (for logging/monitoring)
|
||||
// - Error if delete fails
|
||||
func (r *SessionRepository) DeleteExpired(ctx context.Context) (int64, error) {
|
||||
// SQL delete query with two conditions (connected by OR)
|
||||
// Deletes sessions that meet EITHER condition
|
||||
query := `
|
||||
DELETE FROM sessions
|
||||
WHERE expires_at < NOW() -- Condition 1: Session expired
|
||||
OR (
|
||||
is_revoked = TRUE -- Condition 2: Session revoked AND
|
||||
AND revoked_at < NOW() - INTERVAL '30 days' -- More than 30 days ago
|
||||
)
|
||||
`
|
||||
|
||||
// Execute delete operation
|
||||
// DELETE returns number of affected rows
|
||||
result, err := r.db.ExecContext(ctx, query)
|
||||
|
||||
if err != nil {
|
||||
return 0, err // Return 0 and error if delete fails
|
||||
}
|
||||
|
||||
// Extract number of rows deleted
|
||||
// This is useful for logging: "Deleted 1,234 expired sessions"
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// ListByUserID retrieves all sessions for a specific user.
|
||||
// This is used to show users their active sessions (like Gmail's "devices & activity").
|
||||
//
|
||||
// What it returns:
|
||||
// - ALL sessions for user (both active and revoked)
|
||||
// - Includes expired sessions (caller can filter if needed)
|
||||
// - Sorted by database order (consider adding ORDER BY created_at DESC)
|
||||
//
|
||||
// Use cases:
|
||||
// 1. Security page: Show user where they're logged in
|
||||
// 2. Session management: Let user revoke specific sessions
|
||||
// 3. Audit: Show login history with locations/devices
|
||||
// 4. Support: Help user understand their login activity
|
||||
//
|
||||
// What each session shows:
|
||||
// - When created (created_at)
|
||||
// - Last used (last_used_at)
|
||||
// - Device type (mobile/desktop/web)
|
||||
// - Location (IP address)
|
||||
// - Browser (user agent)
|
||||
// - Status (is_revoked, expires_at)
|
||||
//
|
||||
// Privacy note:
|
||||
// - Never expose refresh_token_hash to client
|
||||
// - IP addresses may be considered personal data (GDPR)
|
||||
// - Consider anonymizing/hashing old IP addresses
|
||||
//
|
||||
// Performance consideration:
|
||||
// - Most users have few sessions (1-5)
|
||||
// - Not a concern unless user has 100+ sessions
|
||||
// - Consider adding pagination for enterprise users
|
||||
//
|
||||
// Return values:
|
||||
// - Slice of sessions (empty slice if user has no sessions)
|
||||
// - Error if query fails
|
||||
func (r *SessionRepository) ListByUserID(ctx context.Context, userId uuid.UUID) ([]*models.Session, error) {
|
||||
// Slice to hold returned sessions
|
||||
var sessions []*models.Session
|
||||
|
||||
// SQL query to get all user sessions
|
||||
// No filtering by is_revoked or expires_at - returns everything
|
||||
// Consider adding: ORDER BY created_at DESC for newest first
|
||||
query := `
|
||||
SELECT id, user_id, refresh_token_hash, user_agent,
|
||||
ip_address, device_name, device_type, expires_at, is_revoked,
|
||||
revoked_at, revoked_reason, created_at, last_used_at
|
||||
FROM sessions
|
||||
WHERE user_id=$1 -- All sessions for this user
|
||||
`
|
||||
|
||||
// SelectContext is like GetContext but for multiple rows
|
||||
// - Executes query and scans all rows into slice
|
||||
// - Maps columns to struct fields by db tags
|
||||
// - Returns empty slice if no rows (not an error)
|
||||
err := r.db.SelectContext(
|
||||
ctx,
|
||||
&sessions, // Destination slice (must be pointer to slice)
|
||||
query,
|
||||
userId, // $1 - User ID
|
||||
)
|
||||
|
||||
return sessions, err
|
||||
}
|
||||
|
||||
// hashToken creates a SHA-256 hash of a token and returns it as a base64-encoded string.
|
||||
// This is used for secure token storage in the database.
|
||||
//
|
||||
// Why hash tokens?
|
||||
// 1. Security: If database is breached, attackers get hashes not usable tokens
|
||||
// 2. Defense in depth: Multiple layers of security
|
||||
// 3. Compliance: Some regulations require token hashing
|
||||
// 4. Best practice: Never store sensitive tokens in plaintext
|
||||
//
|
||||
// Why SHA-256 instead of bcrypt?
|
||||
// - SHA-256 is deterministic: Same input always gives same output
|
||||
// Example: hashToken("mytoken") always gives same hash
|
||||
// Allows database lookup by hash
|
||||
// - bcrypt is random: Same input gives different output each time (due to salt)
|
||||
// Example: bcrypt("mytoken") gives different hash every time
|
||||
// Can't look up by hash, must compare with every stored hash
|
||||
// - SHA-256 is fast: Good for tokens that are looked up frequently
|
||||
// - bcrypt is slow: Good for passwords to resist brute force
|
||||
//
|
||||
// Why base64 encode?
|
||||
// - SHA-256 produces binary data (32 bytes)
|
||||
// - Binary data is hard to store in text fields
|
||||
// - base64 converts binary to text (safe for VARCHAR/TEXT columns)
|
||||
// - URLEncoding variant avoids special characters (+, /, =)
|
||||
//
|
||||
// Process:
|
||||
// 1. Convert token string to bytes
|
||||
// 2. Hash using SHA-256 (produces 32-byte hash)
|
||||
// 3. Encode to base64 (produces ~44-character string)
|
||||
// 4. Store in database as string
|
||||
//
|
||||
// Security note:
|
||||
// - SHA-256 is one-way: Can't reverse hash to get original token
|
||||
// - Can only verify by hashing again and comparing
|
||||
// - This means if database is compromised, tokens can't be extracted
|
||||
//
|
||||
// Return:
|
||||
// - Base64-encoded SHA-256 hash as string
|
||||
func hashToken(token string) string {
|
||||
// Create SHA-256 hash of token bytes
|
||||
// sha256.Sum256 returns [32]byte array
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
|
||||
// Encode hash bytes to base64 string
|
||||
// URLEncoding uses URL-safe characters (no +, /, =)
|
||||
// hash[:] converts [32]byte array to []byte slice
|
||||
return base64.URLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
|
@ -0,0 +1,492 @@
|
|||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/creativenoz/aurganize-v62/backend/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// UserRepository handles all database operations related to users.
|
||||
// This is the data access layer for user management.
|
||||
//
|
||||
// What this repository does:
|
||||
// - Creates new users (registration)
|
||||
// - Finds users by email or ID (login, profile lookup)
|
||||
// - Updates user information (password, last login)
|
||||
// - Verifies passwords (authentication)
|
||||
// - Checks email uniqueness (prevent duplicates)
|
||||
//
|
||||
// Architecture pattern: Repository Pattern
|
||||
// Benefits:
|
||||
// - Separates database logic from business logic
|
||||
// - Makes testing easier (can mock repository)
|
||||
// - Provides clean interface for data access
|
||||
// - Centralizes SQL queries
|
||||
// - Makes it easy to change database later
|
||||
//
|
||||
// Security considerations:
|
||||
// - Passwords are NEVER stored in plaintext
|
||||
// - Always use bcrypt for password hashing
|
||||
// - Soft deletes (deleted_at) preserve data integrity
|
||||
// - Email normalization prevents duplicate accounts
|
||||
type UserRepository struct {
|
||||
db *sqlx.DB // sqlx provides enhanced database operations (named params, struct scanning)
|
||||
}
|
||||
|
||||
// NewUserRepository creates a new instance of UserRepository.
|
||||
// This follows dependency injection pattern:
|
||||
// - Database connection passed in (not created internally)
|
||||
// - Makes testing easier (can inject test database)
|
||||
// - Keeps repository decoupled from connection setup
|
||||
// - Follows SOLID principles (Dependency Inversion)
|
||||
//
|
||||
// Parameter:
|
||||
// - db: The database connection pool for all operations
|
||||
//
|
||||
// Returns:
|
||||
// - Initialized UserRepository ready for use
|
||||
func NewUserRepository(db *sqlx.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
// Create creates a new user in the database.
|
||||
// This is called during user registration.
|
||||
//
|
||||
// What happens:
|
||||
// 1. Hash password using bcrypt (NEVER store plaintext!)
|
||||
// 2. Insert user record with hashed password
|
||||
// 3. Database generates ID, timestamps, and computed fields (full_name)
|
||||
// 4. Return complete user object
|
||||
//
|
||||
// Why bcrypt for passwords?
|
||||
// - Specifically designed for password hashing
|
||||
// - Slow by design (resists brute-force attacks)
|
||||
// - Includes salt automatically (prevents rainbow table attacks)
|
||||
// - Adaptive (can increase cost factor as computers get faster)
|
||||
// - Industry standard for password storage
|
||||
//
|
||||
// bcrypt workflow:
|
||||
// 1. Generates random salt
|
||||
// 2. Combines password + salt
|
||||
// 3. Hashes multiple times (cost factor determines iterations)
|
||||
// 4. Result: "$2a$10$salt+hash" format (self-contained, includes cost and salt)
|
||||
//
|
||||
// Why DefaultCost?
|
||||
// - Balance between security and performance
|
||||
// - DefaultCost = 10 (2^10 = 1024 iterations)
|
||||
// - Takes ~100ms to hash (acceptable for login, but slows brute-force)
|
||||
// - Can increase for higher security (cost 12 = 4x slower, cost 14 = 16x slower)
|
||||
//
|
||||
// Database features used:
|
||||
// - RETURNING clause: Get back created record without separate SELECT
|
||||
// - Auto-generated fields: id (UUID), timestamps, full_name (computed)
|
||||
//
|
||||
// Error handling:
|
||||
// - Returns error if password hashing fails (very rare)
|
||||
// - Returns error if insert fails (constraint violations, etc.)
|
||||
//
|
||||
// Flow:
|
||||
// 1. Hash password with bcrypt
|
||||
// 2. Execute INSERT with RETURNING
|
||||
// 3. Scan returned row into user struct
|
||||
// 4. Return populated user object
|
||||
func (r *UserRepository) Create(ctx context.Context, input *models.CreateUserInput) (*models.User, error) {
|
||||
// Step 1: Hash the password using bcrypt
|
||||
// bcrypt.GenerateFromPassword:
|
||||
// - Takes password as []byte
|
||||
// - Takes cost factor (DefaultCost = 10)
|
||||
// - Returns hash as []byte (e.g., "$2a$10$...")
|
||||
// - Returns error if hashing fails (very rare, maybe out of memory)
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
// Wrap error with context for better debugging
|
||||
return nil, fmt.Errorf("failed to hash password : %w", err)
|
||||
}
|
||||
|
||||
// Prepare user struct to receive database response
|
||||
user := &models.User{}
|
||||
|
||||
// SQL INSERT query with RETURNING clause
|
||||
// PostgreSQL returns the inserted row, avoiding a separate SELECT
|
||||
// This is atomic and more efficient
|
||||
query := `
|
||||
INSERT INTO users (
|
||||
tenant_id, -- Multi-tenancy: which organization user belongs to
|
||||
email, -- User's email (unique identifier for login)
|
||||
password_hash, -- Bcrypt hash of password (NEVER plaintext!)
|
||||
first_name, -- User's first name
|
||||
last_name, -- User's last name
|
||||
role, -- User's role (admin, user, manager, etc.)
|
||||
status -- Account status (active, pending, suspended, deleted)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id,tenant_id, email, password_hash, first_name, last_name,
|
||||
full_name, avatar_url, phone, role, status, email_verified,
|
||||
email_verified_at, is_onboarded, last_login_at, last_login_ip,
|
||||
created_at, updated_at, deleted_at
|
||||
`
|
||||
|
||||
// Execute query and scan result into user struct
|
||||
// GetContext:
|
||||
// - Supports context (cancellation, timeout)
|
||||
// - Expects exactly one row
|
||||
// - Maps columns to struct fields by db tags
|
||||
err = r.db.GetContext(
|
||||
ctx,
|
||||
user, // Destination struct
|
||||
query,
|
||||
// Parameters matching $1-$7 in query
|
||||
input.TenantID,
|
||||
input.Email,
|
||||
string(hashedPassword), // Convert []byte to string for database
|
||||
input.FirstName,
|
||||
input.LastName,
|
||||
input.Role,
|
||||
input.Status,
|
||||
)
|
||||
return user, err
|
||||
}
|
||||
|
||||
// FindByEmail finds a user by their email address.
|
||||
// This is used during login and email existence checks.
|
||||
//
|
||||
// Why search by email?
|
||||
// - Email is the unique identifier for login
|
||||
// - Users remember emails better than IDs
|
||||
// - Standard practice for web applications
|
||||
//
|
||||
// Security considerations:
|
||||
// - Email comparison is case-sensitive in database
|
||||
// - Service layer should normalize email (lowercase, trim)
|
||||
// - Prevents duplicate accounts with different casing
|
||||
//
|
||||
// Soft delete handling:
|
||||
// - Only returns users where deleted_at IS NULL
|
||||
// - Deleted users are hidden but data preserved
|
||||
// - Allows for account recovery
|
||||
// - Maintains referential integrity
|
||||
//
|
||||
// Return values:
|
||||
// - (*User, nil): User found
|
||||
// - (nil, nil): User not found (not an error, just doesn't exist)
|
||||
// - (nil, error): Database error occurred
|
||||
//
|
||||
// Why return nil instead of error when not found?
|
||||
// - "Not found" is a valid state, not an error
|
||||
// - Allows caller to distinguish between "doesn't exist" and "database error"
|
||||
// - Follows repository pattern best practices
|
||||
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
|
||||
// SQL SELECT query
|
||||
// Note: Should add LIMIT 1 for optimization (early exit)
|
||||
query := `
|
||||
SELECT id,tenant_id, email, password_hash, first_name, last_name,
|
||||
full_name, avatar_url, phone, role, status, email_verified,
|
||||
email_verified_at, is_onboarded, last_login_at, last_login_ip,
|
||||
created_at, updated_at, deleted_at
|
||||
FROM users
|
||||
WHERE email = $1 -- Exact email match
|
||||
AND deleted_at IS NULL -- Only non-deleted users
|
||||
`
|
||||
|
||||
// Execute query
|
||||
err := r.db.GetContext(
|
||||
ctx,
|
||||
user,
|
||||
query,
|
||||
email, // $1 - Email to search for
|
||||
)
|
||||
|
||||
// Handle "not found" case specially
|
||||
// sql.ErrNoRows means query executed but returned no rows
|
||||
// This is expected when user doesn't exist
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil // User not found (not an error)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err // Real database error
|
||||
}
|
||||
|
||||
return user, err
|
||||
}
|
||||
|
||||
// FindByID finds a user by their unique ID.
|
||||
// This is used for:
|
||||
// - Loading user after authentication
|
||||
// - Fetching user profile
|
||||
// - Validating user existence
|
||||
// - Retrieving user for operations
|
||||
//
|
||||
// Why search by ID vs email?
|
||||
// - ID lookup is faster (primary key index)
|
||||
// - ID never changes (email might change)
|
||||
// - Used internally after user is identified
|
||||
//
|
||||
// When to use ID vs email:
|
||||
// - Use email: Login, registration checks
|
||||
// - Use ID: After authentication, internal operations
|
||||
//
|
||||
// Soft delete handling:
|
||||
// - Only returns non-deleted users (deleted_at IS NULL)
|
||||
// - Prevents access to deleted accounts
|
||||
// - Maintains data for audit trail
|
||||
//
|
||||
// Return values:
|
||||
// - (*User, nil): User found
|
||||
// - (nil, nil): User not found or deleted
|
||||
// - (nil, error): Database error occurred
|
||||
func (r *UserRepository) FindByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
|
||||
// SQL SELECT query by ID
|
||||
query := `
|
||||
SELECT id,tenant_id, email, password_hash, first_name, last_name,
|
||||
full_name, avatar_url, phone, role, status, email_verified,
|
||||
email_verified_at, is_onboarded, last_login_at, last_login_ip,
|
||||
created_at, updated_at, deleted_at
|
||||
FROM users
|
||||
WHERE id = $1 -- Exact ID match (UUID)
|
||||
AND deleted_at IS NULL -- Only non-deleted users
|
||||
`
|
||||
|
||||
// Execute query
|
||||
err := r.db.GetContext(
|
||||
ctx,
|
||||
user,
|
||||
query,
|
||||
id, // $1 - User ID (UUID)
|
||||
)
|
||||
|
||||
// Handle "not found" case
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil // User not found (not an error)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err // Database error
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// EmailExists checks if an email is already registered in the system.
|
||||
// This is used during registration to prevent duplicate accounts.
|
||||
//
|
||||
// Why check email existence?
|
||||
// 1. Prevent duplicate accounts (UX issue)
|
||||
// 2. Provide clear error messages ("Email already registered")
|
||||
// 3. Enforce uniqueness at application level (in addition to database constraint)
|
||||
// 4. Allow custom error handling (e.g., suggest login instead)
|
||||
//
|
||||
// Implementation using EXISTS:
|
||||
// - EXISTS is efficient (stops at first match)
|
||||
// - Returns boolean directly
|
||||
// - Doesn't load full user data (faster than COUNT or SELECT)
|
||||
// - Uses index on email column
|
||||
//
|
||||
// Soft delete consideration:
|
||||
// - Only checks non-deleted users (deleted_at IS NULL)
|
||||
// - Allows email reuse after deletion (debatable design choice)
|
||||
// - Alternative: Never allow email reuse (more strict)
|
||||
//
|
||||
// Return values:
|
||||
// - (true, nil): Email exists (already registered)
|
||||
// - (false, nil): Email available (can register)
|
||||
// - (false, error): Database error occurred
|
||||
func (r *UserRepository) EmailExists(ctx context.Context, email string) (bool, error) {
|
||||
var email_already_exists bool
|
||||
|
||||
// SQL EXISTS query
|
||||
// EXISTS(...) returns true/false based on whether subquery returns rows
|
||||
// More efficient than COUNT(*) or SELECT * for existence checks
|
||||
query := `
|
||||
SELECT EXISTS (
|
||||
SELECT FROM users -- SELECT doesn't need columns for EXISTS
|
||||
WHERE email = $1 -- Check for email match
|
||||
AND deleted_at IS NULL -- Only check non-deleted users
|
||||
)
|
||||
`
|
||||
|
||||
// Execute query and scan boolean result
|
||||
err := r.db.GetContext(
|
||||
ctx,
|
||||
&email_already_exists, // Boolean result
|
||||
query,
|
||||
email, // $1 - Email to check
|
||||
)
|
||||
|
||||
return email_already_exists, err
|
||||
}
|
||||
|
||||
// UpdateLastLogin updates the user's last login timestamp and IP address.
|
||||
// This is called after successful login for audit and security purposes.
|
||||
//
|
||||
// Why track last login?
|
||||
// 1. Security: Detect unusual login patterns (new location, time)
|
||||
// 2. User awareness: Show "Last login: 2 hours ago from New York"
|
||||
// 3. Audit trail: Compliance requirements (who accessed when)
|
||||
// 4. Account activity: Identify inactive accounts
|
||||
// 5. Support: Help users verify their own activity
|
||||
//
|
||||
// What gets updated:
|
||||
// - last_login_at: Current timestamp (when login occurred)
|
||||
// - last_login_ip: IP address of login (for location/security analysis)
|
||||
// - updated_at: Record last modification time
|
||||
//
|
||||
// IP address considerations:
|
||||
// - Can be IPv4 or IPv6
|
||||
// - Might be proxy/load balancer IP (need X-Forwarded-For)
|
||||
// - Privacy concern: May need to anonymize after time period (GDPR)
|
||||
// - Useful for: Geographic analysis, fraud detection
|
||||
//
|
||||
// Performance note:
|
||||
// - This is a quick UPDATE (indexed by id)
|
||||
// - Usually fast enough to include in login flow
|
||||
// - Alternative: Update asynchronously if performance critical
|
||||
//
|
||||
// Error handling:
|
||||
// - Returns error if update fails
|
||||
// - Caller might ignore (login succeeds even if this fails)
|
||||
// - Non-critical operation (login more important than tracking)
|
||||
func (r *UserRepository) UpdateLastLogin(ctx context.Context, id uuid.UUID, ip *string) error {
|
||||
// SQL UPDATE query
|
||||
// Uses NOW() for consistent database timestamp
|
||||
query := `
|
||||
UPDATE users
|
||||
SET last_login_at = NOW(), -- Current database time
|
||||
last_login_ip = $2, -- IP address from request
|
||||
updated_at = NOW() -- Track this modification
|
||||
WHERE id = $1 -- Only update this user
|
||||
`
|
||||
|
||||
// Execute update
|
||||
// ExecContext for queries that don't return rows (UPDATE, DELETE)
|
||||
_, err := r.db.ExecContext(
|
||||
ctx,
|
||||
query,
|
||||
id, // $1 - User ID
|
||||
ip, // $2 - IP address (pointer allows NULL)
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdatePassword updates a user's password.
|
||||
// This is used for:
|
||||
// - Password change (user-initiated)
|
||||
// - Password reset (forgot password flow)
|
||||
// - Force password change (admin action)
|
||||
//
|
||||
// Security process:
|
||||
// 1. Hash new password with bcrypt
|
||||
// 2. Update password_hash in database
|
||||
// 3. Update updated_at timestamp
|
||||
//
|
||||
// What happens after password change:
|
||||
// - Caller should revoke all sessions (force re-login on all devices)
|
||||
// - User receives email notification (security alert)
|
||||
// - Audit log entry created
|
||||
//
|
||||
// Why hash before updating:
|
||||
// - NEVER store plaintext passwords in database
|
||||
// - bcrypt provides strong one-way hashing
|
||||
// - Even database admins can't see actual passwords
|
||||
// - Protects users even if database is breached
|
||||
//
|
||||
// Important considerations:
|
||||
// 1. Validate new password strength before calling this
|
||||
// 2. Verify user's identity (current password or reset token)
|
||||
// 3. Rate limit password changes (prevent abuse)
|
||||
// 4. Send notification to user's email
|
||||
// 5. Consider revoking all sessions
|
||||
//
|
||||
// Error handling:
|
||||
// - Returns error if hashing fails (rare, memory issues)
|
||||
// - Returns error if update fails (user not found, database error)
|
||||
func (r *UserRepository) UpdatePassword(ctx context.Context, id uuid.UUID, password string) error {
|
||||
// Step 1: Hash the new password
|
||||
// Always use bcrypt for password hashing (never plaintext!)
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
// Wrap error with context
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// SQL UPDATE query
|
||||
query := `
|
||||
UPDATE users
|
||||
SET password_hash = $2, -- Update to new hashed password
|
||||
updated_at = NOW() -- Track modification time
|
||||
WHERE id = $1 -- Only update this user
|
||||
`
|
||||
|
||||
// Execute update
|
||||
_, err = r.db.ExecContext(
|
||||
ctx,
|
||||
query,
|
||||
id, // $1 - User ID
|
||||
string(hashedPassword), // $2 - New password hash
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// VerifyPassword checks if a provided password matches the user's stored password hash.
|
||||
// This is the core of password-based authentication.
|
||||
//
|
||||
// How it works:
|
||||
// 1. Extract password_hash from user (from database)
|
||||
// 2. Use bcrypt.CompareHashAndPassword to verify
|
||||
// 3. Return true if match, false if not
|
||||
//
|
||||
// bcrypt verification process:
|
||||
// 1. Hash format: "$2a$10$salthashedpassword"
|
||||
// 2. bcrypt extracts salt from stored hash
|
||||
// 3. Hashes provided password with same salt
|
||||
// 4. Compares result with stored hash
|
||||
// 5. Returns nil error if match, error if mismatch
|
||||
//
|
||||
// Why bcrypt is good for this:
|
||||
// - Timing-safe comparison (prevents timing attacks)
|
||||
// - Salt is stored in hash (no separate storage needed)
|
||||
// - Slow by design (prevents brute force)
|
||||
// - Industry standard
|
||||
//
|
||||
// Security considerations:
|
||||
// - Never log or display passwords
|
||||
// - Don't reveal if email or password was wrong (prevents enumeration)
|
||||
// - Rate limit login attempts (prevent brute force)
|
||||
// - Consider account lockout after failed attempts
|
||||
//
|
||||
// Nil check importance:
|
||||
// - If password_hash is nil, dereferencing causes panic
|
||||
// - This might happen if:
|
||||
// - User row is corrupted
|
||||
// - Migration error
|
||||
// - Direct database manipulation
|
||||
//
|
||||
// - Better to return false than crash
|
||||
//
|
||||
// Return values:
|
||||
// - true: Password matches (authentication successful)
|
||||
// - false: Password doesn't match OR hash is nil (authentication failed)
|
||||
func (r *UserRepository) VerifyPassword(user *models.User, providedPassword string) bool {
|
||||
// Safety check: Prevent panic if password_hash is nil
|
||||
// This shouldn't happen in normal operation, but better safe than crashed
|
||||
if user.PasswordHash == nil {
|
||||
return false // No hash = can't verify = authentication fails
|
||||
}
|
||||
|
||||
// Use bcrypt to compare provided password with stored hash
|
||||
// CompareHashAndPassword:
|
||||
// - Takes stored hash as []byte
|
||||
// - Takes provided password as []byte
|
||||
// - Returns nil if match, error if mismatch
|
||||
// - Handles salt extraction and timing-safe comparison
|
||||
err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(providedPassword))
|
||||
|
||||
// Return true if err is nil (passwords match)
|
||||
// Return false if err is not nil (passwords don't match)
|
||||
return err == nil
|
||||
}
|
||||
|
|
@ -0,0 +1,338 @@
|
|||
package routes
|
||||
|
||||
import (
|
||||
"github.com/creativenoz/aurganize-v62/backend/internal/handlers"
|
||||
"github.com/creativenoz/aurganize-v62/backend/internal/middleware"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// SetUpRoutes configures all HTTP routes for the application.
|
||||
// This is the central routing configuration where all API endpoints are defined.
|
||||
//
|
||||
// What are routes?
|
||||
// Routes map HTTP methods and URL paths to handler functions.
|
||||
// Example: POST /api/v1/auth/login → authHandler.Login
|
||||
//
|
||||
// Route organization:
|
||||
// This application uses route groups to organize endpoints by:
|
||||
// 1. API versioning (/api/v1)
|
||||
// 2. Feature area (/auth, /users, /projects, etc.)
|
||||
// 3. Authentication requirement (public vs protected)
|
||||
//
|
||||
// Why use route groups?
|
||||
// - Organization: Related routes grouped together
|
||||
// - Shared middleware: Apply middleware to entire group
|
||||
// - URL prefixing: Avoid repeating base paths
|
||||
// - Versioning: Easy to add /api/v2 later
|
||||
//
|
||||
// Architecture pattern: Dependency Injection
|
||||
// - Handlers and middleware are passed in (not created here)
|
||||
// - Makes testing easier (can inject mocks)
|
||||
// - Makes dependencies explicit
|
||||
// - Follows SOLID principles
|
||||
//
|
||||
// Current route structure:
|
||||
// /api/v1
|
||||
// /auth (public - no authentication required)
|
||||
// POST /login - User authentication
|
||||
// POST /refresh - Token rotation
|
||||
// POST /logout - User logout
|
||||
// /{protected routes} (require authentication)
|
||||
// GET /health - Health check with user info
|
||||
//
|
||||
// Future expansion:
|
||||
// Add more route groups for different features:
|
||||
// - /users (user management)
|
||||
// - /projects (project operations)
|
||||
// - /tasks (task management)
|
||||
// - /admin (admin-only endpoints)
|
||||
//
|
||||
// Parameters:
|
||||
// - e: Echo instance (the web framework)
|
||||
// - authHandler: Handler for authentication endpoints
|
||||
// - authMiddleware: Middleware for protecting routes
|
||||
//
|
||||
// Usage:
|
||||
// e := echo.New()
|
||||
// authHandler := handlers.NewAuthHandler(...)
|
||||
// authMiddleware := middleware.NewAuthMiddleware(...)
|
||||
// routes.SetUpRoutes(e, authHandler, authMiddleware)
|
||||
// e.Start(":8080")
|
||||
|
||||
func SetUpRoutes(
|
||||
e *echo.Echo,
|
||||
authHandler *handlers.AuthHandler,
|
||||
authMiddleware *middleware.AuthMiddleware,
|
||||
) {
|
||||
// Create API version 1 group
|
||||
// All routes will be prefixed with /api/v1
|
||||
// This enables API versioning for backward compatibility
|
||||
//
|
||||
// Why version your API?
|
||||
// - Breaking changes: Can release v2 while maintaining v1
|
||||
// - Client compatibility: Old clients continue working
|
||||
// - Gradual migration: Clients upgrade at their own pace
|
||||
// - Clear communication: Version tells clients what to expect
|
||||
//
|
||||
// Example URLs:
|
||||
// - /api/v1/auth/login
|
||||
// - /api/v1/health
|
||||
// Future: /api/v2/auth/login (with different behavior)
|
||||
api := e.Group("/api/v1")
|
||||
|
||||
// ============================================================================
|
||||
// AUTHENTICATION ROUTES (Public - No Authentication Required)
|
||||
// ============================================================================
|
||||
//
|
||||
// These routes handle user authentication and token management.
|
||||
// They are PUBLIC because users need to authenticate BEFORE having tokens.
|
||||
//
|
||||
// Security note: While these routes don't require authentication,
|
||||
// they should still be protected by:
|
||||
// - Rate limiting (prevent brute force attacks)
|
||||
// - HTTPS only (protect credentials in transit)
|
||||
// - CORS restrictions (only allowed origins)
|
||||
//
|
||||
// Base path: /api/v1/auth
|
||||
auth := api.Group("/auth")
|
||||
|
||||
// POST /api/v1/auth/login
|
||||
// Authenticates user credentials and issues tokens
|
||||
//
|
||||
// What it does:
|
||||
// 1. Validates email and password
|
||||
// 2. Checks if user is active
|
||||
// 3. Generates access token (15 min lifetime)
|
||||
// 4. Generates refresh token (7 day lifetime)
|
||||
// 5. Creates session in database
|
||||
// 6. Sets HttpOnly cookies
|
||||
// 7. Returns user data and tokens
|
||||
//
|
||||
// Request:
|
||||
// POST /api/v1/auth/login
|
||||
// Content-Type: application/json
|
||||
// Body: {
|
||||
// "email": "user@example.com",
|
||||
// "password": "SecurePassword123!"
|
||||
// }
|
||||
//
|
||||
// Response (Success - 200):
|
||||
// Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Lax
|
||||
// Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Lax
|
||||
// Body: {
|
||||
// "user": { "id": "...", "email": "...", "role": "..." },
|
||||
// "access_token": "eyJhbGci...",
|
||||
// "refresh_token": "eyJhbGci...",
|
||||
// "expires_in": 900
|
||||
// }
|
||||
//
|
||||
// Response (Error - 401):
|
||||
// Body: { "message": "invalid credentials" }
|
||||
//
|
||||
// Security features:
|
||||
// - Password never returned in response
|
||||
// - Generic error messages (prevents email enumeration)
|
||||
// - HttpOnly cookies (XSS protection)
|
||||
// - Session tracking (device, IP, user agent)
|
||||
auth.POST("/login", authHandler.Login)
|
||||
|
||||
// POST /api/v1/auth/refresh
|
||||
// Rotates refresh token and issues new access token
|
||||
//
|
||||
// What it does:
|
||||
// 1. Validates old refresh token (from cookie or body)
|
||||
// 2. Generates new access token
|
||||
// 3. Generates new refresh token (rotation)
|
||||
// 4. Revokes old refresh token (invalidates it)
|
||||
// 5. Creates new session
|
||||
// 6. Sets new cookies
|
||||
// 7. Returns new tokens
|
||||
//
|
||||
// Why token rotation?
|
||||
// - Limits stolen token exposure window
|
||||
// - Enables theft detection (reused old token = possible attack)
|
||||
// - Each token is single-use after rotation
|
||||
// - Industry security best practice
|
||||
//
|
||||
// Request (Option 1 - Cookie):
|
||||
// POST /api/v1/auth/refresh
|
||||
// Cookie: refresh_token=eyJhbGci...
|
||||
//
|
||||
// Request (Option 2 - Body):
|
||||
// POST /api/v1/auth/refresh
|
||||
// Content-Type: application/json
|
||||
// Body: { "refresh_token": "eyJhbGci..." }
|
||||
//
|
||||
// Response (Success - 200):
|
||||
// Set-Cookie: access_token=...; (new token)
|
||||
// Set-Cookie: refresh_token=...; (NEW token, different from request!)
|
||||
// Body: {
|
||||
// "access_token": "eyJhbGci...",
|
||||
// "refresh_token": "eyJhbGci...", // Client MUST store this new token
|
||||
// "expires_in": 900
|
||||
// }
|
||||
//
|
||||
// Response (Error - 401):
|
||||
// - "missing refresh token" (no token provided)
|
||||
// - "refresh token expired" (needs re-login)
|
||||
// - "refresh token revoked" (session invalidated)
|
||||
// - "invalid refresh token" (signature invalid or malformed)
|
||||
//
|
||||
// IMPORTANT: Client must store the new refresh token and discard the old one!
|
||||
// The old refresh token is immediately invalidated after successful rotation.
|
||||
auth.POST("/refresh", authHandler.RefreshTokenWithRotation)
|
||||
|
||||
// POST /api/v1/auth/logout
|
||||
// Revokes refresh token and clears authentication cookies
|
||||
//
|
||||
// What it does:
|
||||
// 1. Extracts refresh token from cookie
|
||||
// 2. Revokes session in database (marks as revoked)
|
||||
// 3. Clears access_token cookie
|
||||
// 4. Clears refresh_token cookie
|
||||
// 5. Returns success
|
||||
//
|
||||
// Why logout is important:
|
||||
// - Security: Prevents refresh token from being used again
|
||||
// - Privacy: Removes tokens from browser
|
||||
// - Session management: Marks session as ended
|
||||
// - User control: User can explicitly end session
|
||||
//
|
||||
// Request:
|
||||
// POST /api/v1/auth/logout
|
||||
// Cookie: refresh_token=eyJhbGci...
|
||||
//
|
||||
// Response (Success - 200):
|
||||
// Set-Cookie: access_token=; MaxAge=-1 (deleted)
|
||||
// Set-Cookie: refresh_token=; MaxAge=-1 (deleted)
|
||||
// Body: (no content)
|
||||
//
|
||||
// Note: Always returns success even if token is invalid or missing
|
||||
// This prevents information leakage about token validity
|
||||
//
|
||||
// What happens to current access token?
|
||||
// - Access token still works until it expires (~15 min)
|
||||
// - This is acceptable because:
|
||||
// 1. Access tokens are short-lived
|
||||
// 2. Checking database on every request would be slow
|
||||
// 3. User is effectively logged out (can't get new access tokens)
|
||||
auth.POST("/logout", authHandler.Logout)
|
||||
|
||||
// ============================================================================
|
||||
// PROTECTED ROUTES (Authentication Required)
|
||||
// ============================================================================
|
||||
//
|
||||
// These routes require valid authentication via access token.
|
||||
// Requests without valid tokens receive 401 Unauthorized response.
|
||||
//
|
||||
// Authentication methods supported:
|
||||
// 1. Cookie: access_token=... (automatic for browsers)
|
||||
// 2. Header: Authorization: Bearer <token> (for mobile/API clients)
|
||||
//
|
||||
// How authentication works:
|
||||
// 1. authMiddleware.Authenticate extracts token
|
||||
// 2. Validates token signature and expiration
|
||||
// 3. Stores user claims in context (user_id, email, role, etc.)
|
||||
// 4. Proceeds to handler if valid
|
||||
// 5. Returns 401 if invalid/expired
|
||||
//
|
||||
// What handlers can access:
|
||||
// userID := c.Get("user_id").(uuid.UUID)
|
||||
// email := c.Get("email").(string)
|
||||
// role := c.Get("role").(string)
|
||||
// tenantID := c.Get("tenant_id").(uuid.UUID)
|
||||
// claims := c.Get("claims").(*auth.AccessTokenClaims)
|
||||
//
|
||||
// Base path: /api/v1
|
||||
// All routes in this group require authentication
|
||||
protected := api.Group("")
|
||||
|
||||
// Apply authentication middleware to all routes in this group
|
||||
// This means every route added to 'protected' will:
|
||||
// 1. Check for access token
|
||||
// 2. Validate token
|
||||
// 3. Block request if invalid
|
||||
// 4. Store user info in context if valid
|
||||
protected.Use(authMiddleware.Authenticate)
|
||||
|
||||
// GET /api/v1/health
|
||||
// Health check endpoint that returns server status and user info
|
||||
//
|
||||
// What it does:
|
||||
// 1. Checks that server is running
|
||||
// 2. Verifies authentication middleware works
|
||||
// 3. Returns authenticated user's email
|
||||
//
|
||||
// Why a protected health check?
|
||||
// - Verifies entire auth pipeline works
|
||||
// - Tests token validation
|
||||
// - Confirms middleware is properly applied
|
||||
// - Useful for monitoring authenticated endpoints
|
||||
//
|
||||
// Request:
|
||||
// GET /api/v1/health
|
||||
// Cookie: access_token=eyJhbGci...
|
||||
// OR
|
||||
// Authorization: Bearer eyJhbGci...
|
||||
//
|
||||
// Response (Success - 200):
|
||||
// Body: {
|
||||
// "status": "ok",
|
||||
// "user": "user@example.com" // Email of authenticated user
|
||||
// }
|
||||
//
|
||||
// Response (Error - 401):
|
||||
// Body: { "message": "missing authentication token" }
|
||||
// OR: { "message": "token has expired" }
|
||||
// OR: { "message": "invalid token" }
|
||||
//
|
||||
// Note: This is a simple inline handler for demonstration
|
||||
// Production endpoints should use dedicated handler functions
|
||||
//
|
||||
// Type assertion: c.Get("email").(string)
|
||||
// - c.Get() returns interface{} (any type)
|
||||
// - .(string) asserts that value is a string
|
||||
// - Safe because middleware guarantees email is set as string
|
||||
// - Will panic if middleware didn't set email (which shouldn't happen)
|
||||
protected.GET("/health", func(c echo.Context) error {
|
||||
return c.JSON(200, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"user": c.Get("email").(string),
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// FUTURE ROUTE GROUPS
|
||||
// ============================================================================
|
||||
//
|
||||
// As the application grows, add more route groups here:
|
||||
//
|
||||
// // User management routes (protected)
|
||||
// users := protected.Group("/users")
|
||||
// users.GET("", userHandler.List) // GET /api/v1/users
|
||||
// users.GET("/:id", userHandler.GetByID) // GET /api/v1/users/:id
|
||||
// users.PUT("/:id", userHandler.Update) // PUT /api/v1/users/:id
|
||||
// users.DELETE("/:id", userHandler.Delete) // DELETE /api/v1/users/:id
|
||||
//
|
||||
// // Project routes (protected)
|
||||
// projects := protected.Group("/projects")
|
||||
// projects.GET("", projectHandler.List) // GET /api/v1/projects
|
||||
// projects.POST("", projectHandler.Create) // POST /api/v1/projects
|
||||
// projects.GET("/:id", projectHandler.GetByID) // GET /api/v1/projects/:id
|
||||
//
|
||||
// // Admin routes (protected + role check)
|
||||
// admin := protected.Group("/admin")
|
||||
// admin.Use(middleware.RequireRole("admin")) // Extra middleware for role check
|
||||
// admin.GET("/users", adminHandler.ListAllUsers)
|
||||
// admin.POST("/users/:id/suspend", adminHandler.SuspendUser)
|
||||
//
|
||||
// // Public routes (optional auth - shows different content if logged in)
|
||||
// public := api.Group("")
|
||||
// public.Use(authMiddleware.OptionalAuth)
|
||||
// public.GET("/posts", postHandler.List) // Shows public + user's private posts if authenticated
|
||||
//
|
||||
// // Webhook routes (API key auth instead of JWT)
|
||||
// webhooks := api.Group("/webhooks")
|
||||
// webhooks.Use(webhookMiddleware.ValidateSignature)
|
||||
// webhooks.POST("/github", webhookHandler.HandleGitHub)
|
||||
}
|
||||
|
|
@ -0,0 +1,899 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/creativenoz/aurganize-v62/backend/internal/config"
|
||||
"github.com/creativenoz/aurganize-v62/backend/internal/models"
|
||||
"github.com/creativenoz/aurganize-v62/backend/internal/repositories"
|
||||
"github.com/creativenoz/aurganize-v62/backend/pkg/auth"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Predefined errors for authentication operations.
|
||||
// These are defined as package-level variables so they can be:
|
||||
// 1. Compared using errors.Is() for error handling
|
||||
// 2. Wrapped with additional context using errors.Wrap()
|
||||
// 3. Tested reliably (same error instance)
|
||||
// 4. Documented centrally
|
||||
//
|
||||
// Why use errors.New() vs custom error types?
|
||||
// - Simple errors don't need additional data
|
||||
// - errors.Is() works for comparison
|
||||
// - Can still wrap with context: fmt.Errorf("context: %w", ErrInvalidToken)
|
||||
// - Good balance between simplicity and functionality
|
||||
var (
|
||||
// ErrInvalidToken indicates the provided token is malformed or has invalid signature
|
||||
// Common causes:
|
||||
// - Token tampered with
|
||||
// - Wrong signing secret used
|
||||
// - Token format doesn't match JWT standard
|
||||
// - Token claims are malformed
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
|
||||
// ErrExpiredToken indicates the token's expiration time has passed
|
||||
// This is normal - tokens should expire for security
|
||||
// Client should use refresh token to get new access token
|
||||
ErrExpiredToken = errors.New("token has been expired")
|
||||
|
||||
// ErrRevokedToken indicates the token has been explicitly invalidated
|
||||
// Happens when:
|
||||
// - User logs out
|
||||
// - Admin revokes session
|
||||
// - Password changed (all sessions revoked)
|
||||
// - Security breach detected
|
||||
ErrRevokedToken = errors.New("token has been revoked")
|
||||
|
||||
// ErrInvalidTokenType indicates token type doesn't match expected
|
||||
// We have two token types: "access" and "refresh"
|
||||
// This prevents using a refresh token as an access token (security issue)
|
||||
ErrInvalidTokenType = errors.New("invalid token type")
|
||||
)
|
||||
|
||||
// AuthService handles all authentication and authorization logic.
|
||||
// This service is the business logic layer that sits between handlers and repositories.
|
||||
//
|
||||
// Responsibilities:
|
||||
// 1. Token generation (access and refresh tokens)
|
||||
// 2. Token validation (signature, expiration, revocation)
|
||||
// 3. Session management (create, validate, revoke)
|
||||
// 4. Token revocation (logout, logout all devices)
|
||||
//
|
||||
// Architecture: Service Layer Pattern
|
||||
// - Handlers call services (not repositories directly)
|
||||
// - Services contain business logic
|
||||
// - Services call repositories for data access
|
||||
// - Services can call multiple repositories (transaction coordination)
|
||||
//
|
||||
// Why separate service from handler?
|
||||
// - Reusability: Multiple handlers can use same service
|
||||
// - Testability: Can test business logic without HTTP
|
||||
// - Separation of concerns: HTTP logic vs business logic
|
||||
// - Transaction management: Service coordinates multiple repo calls
|
||||
type AuthService struct {
|
||||
config *config.Config // JWT secrets, expiration times, issuer info
|
||||
sessionRepo *repositories.SessionRepository // Database operations for sessions
|
||||
userRepo *repositories.UserRepository // Database operations for users (not used much here)
|
||||
}
|
||||
|
||||
// NewAuthService creates a new AuthService with injected dependencies.
|
||||
// This constructor follows dependency injection pattern for:
|
||||
// - Testability (can inject mocks)
|
||||
// - Flexibility (can change implementations)
|
||||
// - Clear dependencies (explicit in signature)
|
||||
//
|
||||
// Parameters:
|
||||
// - config: Application configuration (JWT settings)
|
||||
// - sessionRepo: For session database operations
|
||||
// - userRepo: For user database operations
|
||||
//
|
||||
// Returns:
|
||||
// - Fully initialized AuthService
|
||||
func NewAuthService(config *config.Config, sessionRepo *repositories.SessionRepository, userRepo *repositories.UserRepository) *AuthService {
|
||||
return &AuthService{
|
||||
config: config,
|
||||
sessionRepo: sessionRepo,
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAccessToken creates a new JWT access token for a user.
|
||||
// Access tokens are short-lived (typically 15 minutes) and used for API authentication.
|
||||
//
|
||||
// What's an access token?
|
||||
// - Short-lived JWT (15 minutes typical)
|
||||
// - Contains user identity and permissions
|
||||
// - Sent with every API request
|
||||
// - Stateless (doesn't require database lookup)
|
||||
// - Used for request authentication/authorization
|
||||
//
|
||||
// Token structure:
|
||||
// - Header: Algorithm (HS256), type (JWT)
|
||||
// - Payload: Claims (user info, expiration, issuer)
|
||||
// - Signature: HMAC SHA-256 of header+payload with secret
|
||||
//
|
||||
// Claims included:
|
||||
// - UserID: Identifies the user
|
||||
// - TenantID: For multi-tenancy (which organization)
|
||||
// - Email: User's email address
|
||||
// - Role: User's role (for authorization checks)
|
||||
// - TokenType: "access" (prevents token confusion)
|
||||
// - Standard claims: exp, iat, nbf, iss, sub
|
||||
//
|
||||
// Standard JWT claims explained:
|
||||
// - exp (expires at): When token becomes invalid
|
||||
// - iat (issued at): When token was created
|
||||
// - nbf (not before): Token not valid before this time (usually same as iat)
|
||||
// - iss (issuer): Who issued the token (our application)
|
||||
// - sub (subject): User ID (standard way to identify token subject)
|
||||
//
|
||||
// Why short-lived?
|
||||
// - If token is stolen, it's only valid for 15 minutes
|
||||
// - Limits damage from token theft
|
||||
// - Forces regular token refresh (can check if user still has access)
|
||||
// - Balance between security and UX
|
||||
//
|
||||
// Security considerations:
|
||||
// - Signed with secret key (only server can create valid tokens)
|
||||
// - Include token type to prevent refresh token being used as access token
|
||||
// - Don't include sensitive data (token visible in requests)
|
||||
// - Set reasonable expiration (not too long)
|
||||
//
|
||||
// Return values:
|
||||
// - (string, nil): Successfully generated token
|
||||
// - ("", error): Token generation failed (configuration error)
|
||||
func (a *AuthService) GenerateAccessToken(user *models.User) (string, error) {
|
||||
// Get current time for timestamps
|
||||
now := time.Now()
|
||||
// Calculate expiration time (now + configured expiry duration)
|
||||
expiresAt := now.Add(a.config.JWT.AccessExpiry)
|
||||
|
||||
// Create claims structure with user information
|
||||
claims := auth.AccessTokenClaims{
|
||||
// Custom claims (our application-specific data)
|
||||
UserID: user.ID, // User's unique identifier
|
||||
TenantID: user.TenantID, // Organization/tenant identifier (multi-tenancy)
|
||||
Email: user.Email, // User's email address
|
||||
Role: user.Role, // User's role (admin, user, etc.)
|
||||
TokenType: "access", // Identifies this as an access token
|
||||
|
||||
// Standard JWT registered claims
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt), // When token expires
|
||||
IssuedAt: jwt.NewNumericDate(now), // When token was created
|
||||
NotBefore: jwt.NewNumericDate(now), // Token valid from this time
|
||||
Issuer: "aurganize-v62-api", // Who issued this token
|
||||
Subject: user.ID.String(), // Subject of token (user ID)
|
||||
},
|
||||
}
|
||||
|
||||
// Create JWT token with claims
|
||||
// NewWithClaims:
|
||||
// - First param: Signing method (HS256 = HMAC SHA-256)
|
||||
// - Second param: Claims to include in token
|
||||
// Returns unsigned token object
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
// Sign token with secret key to create final JWT string
|
||||
// SignedString:
|
||||
// - Takes secret key as []byte
|
||||
// - Creates signature using HMAC SHA-256
|
||||
// - Returns complete JWT string: "header.payload.signature"
|
||||
// - Only holder of secret can create valid signatures
|
||||
return token.SignedString([]byte(a.config.JWT.AccessSecret))
|
||||
}
|
||||
|
||||
// GenerateRefreshToken creates a new refresh token and session record.
|
||||
// Refresh tokens are long-lived (typically 7 days) and used to obtain new access tokens.
|
||||
//
|
||||
// What's a refresh token?
|
||||
// - Long-lived (7 days typical)
|
||||
// - Used only to get new access tokens
|
||||
// - Stored in database (can be revoked)
|
||||
// - Contains session ID for tracking
|
||||
// - More secure than never-expiring access tokens
|
||||
//
|
||||
// Refresh token vs Access token:
|
||||
// - Access: Short-lived (15min), stateless, for API requests
|
||||
// - Refresh: Long-lived (7 days), stateful (in database), for token renewal only
|
||||
//
|
||||
// Two-part token system:
|
||||
// 1. Random token ID (stored in database)
|
||||
// 2. JWT containing user ID, session ID, and token ID
|
||||
//
|
||||
// Why this two-part system?
|
||||
// - JWT alone: Can't revoke (stateless)
|
||||
// - Database alone: Requires lookup on every request (slow)
|
||||
// - Hybrid: JWT for claims, database for revocation checking
|
||||
//
|
||||
// Token generation process:
|
||||
// 1. Generate random 32-byte token ID (cryptographically secure)
|
||||
// 2. Create session record in database with hashed token ID
|
||||
// 3. Create JWT containing user ID, session ID, and token ID
|
||||
// 4. Sign JWT with refresh secret
|
||||
//
|
||||
// Session tracking:
|
||||
// - Records device information (user agent, IP, device type)
|
||||
// - Allows "view active sessions" feature
|
||||
// - Enables "logout from device X" functionality
|
||||
// - Audit trail for security
|
||||
//
|
||||
// Security features:
|
||||
// - Random token ID (not predictable)
|
||||
// - Stored in database (can revoke)
|
||||
// - Hashed in database (can't use even if database breached)
|
||||
// - Device tracking (detect unusual activity)
|
||||
// - Expiration date (eventually expires)
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context for database operations
|
||||
// - user: User to create token for
|
||||
// - userAgent: Browser/app information (optional)
|
||||
// - ipAddress: IP address of request (optional)
|
||||
//
|
||||
// Returns:
|
||||
// - (signedToken, session, nil): Success
|
||||
// - ("", nil, error): Failed to generate random token
|
||||
// - ("", nil, error): Failed to create session in database
|
||||
// - ("", nil, error): Failed to sign JWT
|
||||
func (a *AuthService) GenerateRefreshToken(ctx context.Context, user *models.User, userAgent *string, ipAddress *string) (string, *models.Session, error) {
|
||||
// Step 1: Generate cryptographically secure random token ID
|
||||
// Create 32-byte buffer for random data
|
||||
tokenBytes := make([]byte, 32)
|
||||
|
||||
// Fill buffer with cryptographically secure random bytes
|
||||
// crypto/rand.Read uses OS-provided randomness (very secure)
|
||||
// This is NOT like math/rand (which is predictable)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return "", nil, err // Failed to generate random data (very rare)
|
||||
}
|
||||
|
||||
// Encode random bytes to base64 string
|
||||
// Base64 makes binary data safe for text storage
|
||||
// URLEncoding variant avoids special characters (+, /, =)
|
||||
// Result: ~44-character string
|
||||
refreshToken := base64.URLEncoding.EncodeToString(tokenBytes)
|
||||
|
||||
// Step 2: Calculate token expiration time
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(a.config.JWT.RefreshExpiry) // Usually 7 days
|
||||
|
||||
// Step 3: Create session record in database
|
||||
// This stores:
|
||||
// - Hashed token (not plaintext for security)
|
||||
// - User ID (who owns this session)
|
||||
// - Device information (user agent, IP, device type)
|
||||
// - Expiration time
|
||||
session, err := a.sessionRepo.Create(ctx, &models.CreateSessionInput{
|
||||
UserID: user.ID,
|
||||
RefreshToken: refreshToken, // Will be hashed by repository
|
||||
UserAgent: userAgent, // Browser/app info
|
||||
IPAddress: ipAddress, // Where login came from
|
||||
DeviceType: detectDeviceType(userAgent), // mobile, desktop, or web
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", nil, err // Database error
|
||||
}
|
||||
|
||||
// Step 4: Create JWT claims with session information
|
||||
claims := auth.RefreshTokenClaims{
|
||||
// Custom claims
|
||||
UserID: user.ID, // Which user owns this token
|
||||
SessionID: session.ID, // Which session this token belongs to
|
||||
TokenID: refreshToken, // The random token ID (for database lookup)
|
||||
TokenType: "refresh", // Identifies this as refresh token
|
||||
|
||||
// Standard JWT claims
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt), // When token expires
|
||||
IssuedAt: jwt.NewNumericDate(now), // When created
|
||||
NotBefore: jwt.NewNumericDate(now), // Valid from now
|
||||
Issuer: "aurganize-v62-api", // Who issued it
|
||||
Subject: user.ID.String(), // Subject (user ID)
|
||||
},
|
||||
}
|
||||
|
||||
// Step 5: Create and sign JWT
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signedToken, err := token.SignedString([]byte(a.config.JWT.RefreshSecret))
|
||||
if err != nil {
|
||||
return "", nil, err // Failed to sign token
|
||||
}
|
||||
|
||||
// Return signed JWT and session object
|
||||
return signedToken, session, err
|
||||
}
|
||||
|
||||
// ValidateAccessToken verifies an access token's signature and claims.
|
||||
// This is called on every authenticated API request.
|
||||
//
|
||||
// What gets validated:
|
||||
// 1. JWT signature (proves token wasn't tampered with)
|
||||
// 2. Token not expired (exp claim)
|
||||
// 3. Token valid now (nbf claim)
|
||||
// 4. Token is "access" type (not refresh)
|
||||
// 5. Token issued by us (iss claim)
|
||||
//
|
||||
// Validation process:
|
||||
// 1. Parse JWT structure (header.payload.signature)
|
||||
// 2. Verify signature using access secret
|
||||
// 3. Check signing algorithm is HMAC (not "none" or RSA)
|
||||
// 4. Validate expiration time
|
||||
// 5. Validate issued-at time
|
||||
// 6. Validate token type
|
||||
//
|
||||
// Why validate on every request?
|
||||
// - Stateless authentication (no session lookups)
|
||||
// - Fast (just cryptographic validation)
|
||||
// - Secure (can't forge without secret)
|
||||
// - Scalable (no database query needed)
|
||||
//
|
||||
// Security checks:
|
||||
// - Algorithm verification (prevents "none" algorithm attack)
|
||||
// - Signature verification (prevents tampering)
|
||||
// - Expiration check (prevents replay of old tokens)
|
||||
// - Token type check (prevents using refresh as access)
|
||||
//
|
||||
// Why NOT check database?
|
||||
// - Would be slow (database query on every request)
|
||||
// - Would not scale well
|
||||
// - Access tokens are short-lived anyway (15 min)
|
||||
// - Revocation handled at refresh token level
|
||||
//
|
||||
// When validation fails:
|
||||
// - 401 Unauthorized response to client
|
||||
// - Client should try refresh token
|
||||
// - If refresh fails, redirect to login
|
||||
//
|
||||
// Parameters:
|
||||
// - tokenString: JWT string from Authorization header or cookie
|
||||
//
|
||||
// Return values:
|
||||
// - (*claims, nil): Token valid, returns claims for authorization
|
||||
// - (nil, ErrExpiredToken): Token expired (client should refresh)
|
||||
// - (nil, ErrInvalidToken): Token invalid (malformed, wrong signature, wrong type)
|
||||
func (a *AuthService) ValidateAccessToken(tokenString string) (*auth.AccessTokenClaims, error) {
|
||||
// Parse and validate JWT token
|
||||
// ParseWithClaims:
|
||||
// - Parses JWT string
|
||||
// - Validates signature using provided key function
|
||||
// - Checks expiration and issued-at times
|
||||
// - Populates claims struct
|
||||
token, err := jwt.ParseWithClaims(
|
||||
tokenString, // JWT string to parse
|
||||
&auth.AccessTokenClaims{}, // Struct to populate with claims
|
||||
// Key function: Called to get signing key for validation
|
||||
func(token *jwt.Token) (interface{}, error) {
|
||||
// Security check: Verify algorithm is HMAC
|
||||
// Prevents "none" algorithm attack where attacker removes signature
|
||||
// Prevents algorithm confusion attacks (using public key as symmetric key)
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
// Return secret key for signature verification
|
||||
return []byte(a.config.JWT.AccessSecret), nil
|
||||
},
|
||||
// Parser options for additional validation
|
||||
jwt.WithExpirationRequired(), // Ensure exp claim is present and valid
|
||||
jwt.WithIssuedAt(), // Validate iat claim
|
||||
jwt.WithTimeFunc(time.Now), // Use current time for validation
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
// Check if error is specifically about expiration
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrExpiredToken // Return specific expiration error
|
||||
}
|
||||
// Other errors: invalid signature, malformed JWT, etc.
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// Extract and validate claims
|
||||
// Type assertion: Convert interface{} to *AccessTokenClaims
|
||||
claims, ok := token.Claims.(*auth.AccessTokenClaims)
|
||||
if !ok || !token.Valid {
|
||||
// Claims wrong type or token invalid
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// Verify token type (prevent refresh token being used as access token)
|
||||
if claims.TokenType != "access" {
|
||||
return nil, ErrInvalidTokenType
|
||||
}
|
||||
|
||||
// Token is valid, return claims for use in authorization
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// ValidateRefreshToken verifies a refresh token and returns associated session.
|
||||
// This is called when client wants to get a new access token.
|
||||
//
|
||||
// Unlike access tokens, refresh tokens are validated against database:
|
||||
// 1. Verify JWT signature
|
||||
// 2. Check expiration
|
||||
// 3. Look up session in database
|
||||
// 4. Verify session not revoked
|
||||
// 5. Verify session not expired
|
||||
// 6. Update session last-used timestamp
|
||||
//
|
||||
// Why check database for refresh tokens?
|
||||
// - Enables revocation (logout, password change)
|
||||
// - Tracks device/location information
|
||||
// - Allows "logout all devices" functionality
|
||||
// - More secure than purely stateless
|
||||
// - Acceptable performance (refresh happens every 15 min, not every request)
|
||||
//
|
||||
// Validation flow:
|
||||
// 1. Parse JWT and verify signature
|
||||
// 2. Extract session ID and token ID from claims
|
||||
// 3. Query database for matching session
|
||||
// 4. Verify session exists and is valid
|
||||
// 5. Update last-used timestamp
|
||||
// 6. Return claims and session
|
||||
//
|
||||
// Security checks performed:
|
||||
// 1. JWT signature verification
|
||||
// 2. Token expiration check
|
||||
// 3. Token type verification ("refresh")
|
||||
// 4. Session existence check
|
||||
// 5. Session revocation check
|
||||
// 6. Session expiration check
|
||||
//
|
||||
// Why so many checks?
|
||||
// - Defense in depth (multiple security layers)
|
||||
// - Catches different types of attacks
|
||||
// - Provides clear error messages
|
||||
// - Enables fine-grained control
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context for database operations
|
||||
// - tokenString: JWT string from cookie
|
||||
//
|
||||
// Return values:
|
||||
// - (claims, session, nil): Valid token, returns data for use
|
||||
// - (nil, nil, ErrExpiredToken): Token expired
|
||||
// - (nil, nil, ErrInvalidToken): Token invalid or session not found
|
||||
// - (nil, nil, ErrRevokedToken): Session has been revoked
|
||||
func (a *AuthService) ValidateRefreshToken(ctx context.Context, tokenString string) (*auth.RefreshTokenClaims, *models.Session, error) {
|
||||
// Step 1: Parse and validate JWT
|
||||
token, err := jwt.ParseWithClaims(
|
||||
tokenString,
|
||||
&auth.RefreshTokenClaims{}, // Refresh token claims struct
|
||||
func(token *jwt.Token) (interface{}, error) {
|
||||
// Verify algorithm is HMAC (security check)
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
// Return REFRESH secret (different from access secret!)
|
||||
return []byte(a.config.JWT.RefreshSecret), nil
|
||||
},
|
||||
jwt.WithExpirationRequired(), // Check expiration
|
||||
jwt.WithIssuedAt(), // Check issued-at
|
||||
jwt.WithTimeFunc(time.Now), // Use current time
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, nil, ErrExpiredToken
|
||||
}
|
||||
return nil, nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// Step 2: Extract and validate claims
|
||||
claims, ok := token.Claims.(*auth.RefreshTokenClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// Step 3: Verify token type
|
||||
if claims.TokenType != "refresh" {
|
||||
return nil, nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// Step 4: Look up session in database
|
||||
// This checks:
|
||||
// - Session exists
|
||||
// - Token hash matches
|
||||
// - Session not revoked
|
||||
// - Session not expired
|
||||
session, err := a.sessionRepo.FindBySessionIDAndToken(ctx, claims.SessionID, claims.TokenID)
|
||||
if err != nil {
|
||||
return nil, nil, err // Database error
|
||||
}
|
||||
|
||||
// Step 5: Verify session was found
|
||||
if session == nil {
|
||||
// Session doesn't exist or is invalid
|
||||
// Could mean: wrong token, session revoked, session expired
|
||||
return nil, nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// Step 6: Verify session not revoked (redundant but explicit)
|
||||
if session.IsRevoked {
|
||||
// Session was explicitly revoked (logout, password change, etc.)
|
||||
return nil, nil, ErrRevokedToken
|
||||
}
|
||||
|
||||
// Step 7: Verify session not expired (redundant but explicit)
|
||||
if session.ExpiresAt.Before(time.Now()) {
|
||||
// Session expired (different from token expiration)
|
||||
return nil, nil, ErrRevokedToken
|
||||
}
|
||||
|
||||
// Step 8: Update session last-used timestamp
|
||||
// Tracks when session was last active
|
||||
// Useful for security monitoring and cleanup
|
||||
// We ignore error (not critical for validation)
|
||||
_ = a.sessionRepo.UpdateLastUsed(ctx, session.ID)
|
||||
|
||||
// All checks passed, return claims and session
|
||||
return claims, session, nil
|
||||
}
|
||||
|
||||
// RotateRefreshToken validates an old refresh token and issues a new one.
|
||||
// This implements refresh token rotation for enhanced security:
|
||||
// 1. Validates the old refresh token (JWT + session)
|
||||
// 2. Generates a new random token and creates new session
|
||||
// 3. Revokes the old session
|
||||
// 4. Returns new access token + new refresh token
|
||||
//
|
||||
// Security benefits:
|
||||
// - Limits window of exposure if token is stolen
|
||||
// - Enables detection of token theft (if old token is used after rotation)
|
||||
// - Reduces attack surface by regularly cycling credentials
|
||||
//
|
||||
// Token theft detection:
|
||||
// If an attacker uses a stolen old token after the legitimate user has already
|
||||
// rotated it, the system can detect this suspicious activity and revoke all
|
||||
// sessions for that user as a security precaution.
|
||||
func (a *AuthService) RotateRefreshToken(ctx context.Context, oldTokenString string, userAgent *string, ipAddress *string) (string, string, *models.Session, error) {
|
||||
// Step 1: Validate the old refresh token
|
||||
claims, _, err := a.ValidateRefreshToken(ctx, oldTokenString)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
// Step 2: Get user details
|
||||
user, err := a.userRepo.FindByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return "", "", nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// Step 3: Generate new access token
|
||||
newAccessToken, err := a.GenerateAccessToken(user)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
// Step 4: Generate new refresh token (creates new session)
|
||||
newRefreshToken, newSession, err := a.GenerateRefreshToken(ctx, user, userAgent, ipAddress)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
// Step 5: Revoke the old session (invalidates old refresh token)
|
||||
// Use background context to ensure revocation completes even if request is cancelled
|
||||
go func() {
|
||||
_ = a.sessionRepo.Revoke(context.Background(), claims.TokenID, "token_rotation")
|
||||
}()
|
||||
|
||||
return newAccessToken, newRefreshToken, newSession, nil
|
||||
|
||||
}
|
||||
|
||||
// ValidateRefreshTokenWithRotationCheck validates a refresh token and detects potential theft.
|
||||
// If a revoked token is used (possible replay attack after rotation), it revokes all user sessions.
|
||||
//
|
||||
// Attack scenario:
|
||||
// 1. Legitimate user rotates token (old token revoked, new token issued)
|
||||
// 2. Attacker tries to use the old stolen token
|
||||
// 3. System detects revoked token usage → revokes ALL user sessions
|
||||
// 4. Both attacker and legitimate user must re-authenticate
|
||||
// 5. User is alerted to suspicious activity
|
||||
//
|
||||
// This is an optional enhancement for high-security requirements.
|
||||
func (a *AuthService) ValidateRefreshTokenWithRotationCheck(ctx context.Context, tokenString string) (*auth.RefreshTokenClaims, *models.Session, error) {
|
||||
// Parse JWT to get claims
|
||||
token, err := jwt.ParseWithClaims(
|
||||
tokenString,
|
||||
&auth.RefreshTokenClaims{},
|
||||
func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return []byte(a.config.JWT.RefreshSecret), nil
|
||||
},
|
||||
jwt.WithExpirationRequired(),
|
||||
jwt.WithIssuedAt(),
|
||||
jwt.WithTimeFunc(time.Now),
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, nil, ErrExpiredToken
|
||||
}
|
||||
return nil, nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*auth.RefreshTokenClaims)
|
||||
if !ok {
|
||||
return nil, nil, ErrInvalidToken
|
||||
}
|
||||
if claims.TokenType != "refresh" {
|
||||
return nil, nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// Look up session
|
||||
session, err := a.sessionRepo.FindBySessionIDAndToken(ctx, claims.SessionID, claims.TokenID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if session == nil {
|
||||
return nil, nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// THEFT DETECTION: If session is revoked but token is still valid (not expired),
|
||||
// this indicates someone is trying to reuse a rotated token.
|
||||
// This could be a legitimate user with an old token, or an attacker with a stolen token.
|
||||
if session.IsRevoked {
|
||||
// Check if token was revoked due to rotation
|
||||
if session.RevokedReason != nil && *session.RevokedReason == "token_rotation" {
|
||||
// SECURITY EVENT: Possible token theft detected
|
||||
// Revoke ALL sessions for this user as a precaution
|
||||
go func() {
|
||||
_ = a.sessionRepo.RevokeByUserId(context.Background(), session.UserID, "potential_token_theft")
|
||||
// TODO: Send security alert email/notification to user
|
||||
// TODO: Log security event for monitoring
|
||||
}()
|
||||
}
|
||||
return nil, nil, ErrExpiredToken
|
||||
}
|
||||
|
||||
if session.ExpiresAt.Before(time.Now()) {
|
||||
return nil, nil, ErrExpiredToken
|
||||
}
|
||||
_ = a.sessionRepo.UpdateLastUsed(ctx, session.ID)
|
||||
return claims, session, nil
|
||||
}
|
||||
|
||||
// RevokeRefreshToken marks a refresh token as revoked (logout).
|
||||
// This is called when user logs out from current device.
|
||||
//
|
||||
// What happens:
|
||||
// 1. Parse JWT to extract token ID
|
||||
// 2. Find session by token hash
|
||||
// 3. Mark session as revoked in database
|
||||
// 4. Record revocation reason and timestamp
|
||||
//
|
||||
// Why parse JWT if we're revoking?
|
||||
// - Need to extract token ID from claims
|
||||
// - Token might be expired (that's okay for revocation)
|
||||
// - We still verify signature (ensure it's our token)
|
||||
//
|
||||
// After revocation:
|
||||
// - Token can't be used to get new access tokens
|
||||
// - Current access tokens still work (until they expire in ~15 min)
|
||||
// - User effectively logged out from this device
|
||||
//
|
||||
// Why current access tokens still work:
|
||||
// - Access tokens are stateless (not checked against database)
|
||||
// - They expire quickly anyway (15 minutes)
|
||||
// - Checking database on every request would be too slow
|
||||
// - This is an acceptable security tradeoff
|
||||
//
|
||||
// Revocation reason:
|
||||
// - "user_logout": User clicked logout button
|
||||
// - Stored for audit trail
|
||||
// - Can be used for analytics
|
||||
// - Helps in security investigations
|
||||
//
|
||||
// Error handling:
|
||||
// - Returns error if parsing fails
|
||||
// - Returns error if database update fails
|
||||
// - Idempotent: OK to revoke already-revoked token
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context for database operations
|
||||
// - tokenJWT: JWT string to revoke
|
||||
//
|
||||
// Return values:
|
||||
// - nil: Successfully revoked (or already revoked)
|
||||
// - error: Failed to parse token or update database
|
||||
func (a *AuthService) RevokeRefreshToken(ctx context.Context, tokenJWT string) error {
|
||||
// Step 1: Parse JWT to extract claims
|
||||
// We need the token ID to find the session
|
||||
// We still use ParseWithClaims even though we're revoking
|
||||
// because we need to verify it's actually our token
|
||||
token, err := jwt.ParseWithClaims(
|
||||
tokenJWT,
|
||||
&auth.RefreshTokenClaims{},
|
||||
func(token *jwt.Token) (interface{}, error) {
|
||||
// Verify algorithm
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return []byte(a.config.JWT.RefreshSecret), nil
|
||||
},
|
||||
// Note: We still require expiration and issued-at validation
|
||||
// even for revocation, to ensure token structure is valid
|
||||
jwt.WithExpirationRequired(),
|
||||
jwt.WithIssuedAt(),
|
||||
jwt.WithTimeFunc(time.Now),
|
||||
)
|
||||
if err != nil {
|
||||
// Could be expired token (that's okay for revocation)
|
||||
// But we still return error to indicate parsing failure
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2: Extract claims
|
||||
claims, ok := token.Claims.(*auth.RefreshTokenClaims)
|
||||
if !ok {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
// Step 3: Revoke the session in database
|
||||
// Uses token ID to find session
|
||||
// Marks as revoked with reason "user_logout"
|
||||
// Operation is idempotent (safe to call multiple times)
|
||||
return a.sessionRepo.Revoke(ctx, claims.TokenID, "user_logout")
|
||||
}
|
||||
|
||||
// RevokeAllUserToken revokes all refresh tokens for a user (logout all devices).
|
||||
// This is a security feature for:
|
||||
// - Password change (force re-login everywhere)
|
||||
// - Account compromise (revoke all access)
|
||||
// - User request (logout from all devices)
|
||||
// - Administrative action (force logout)
|
||||
//
|
||||
// What it does:
|
||||
// - Finds all non-revoked sessions for user
|
||||
// - Marks them all as revoked
|
||||
// - Records revocation reason "revoke_all"
|
||||
// - Updates revocation timestamp
|
||||
//
|
||||
// After calling this:
|
||||
// - All refresh tokens for user become invalid
|
||||
// - User must log in again on all devices
|
||||
// - Current access tokens still work (until they expire in ~15 min)
|
||||
//
|
||||
// Use cases:
|
||||
// 1. Password change: User changes password, log out all devices
|
||||
// 2. Security breach: User reports compromise, revoke all access
|
||||
// 3. Lost device: User lost phone, remotely log out all
|
||||
// 4. Suspicious activity: Admin detects breach, force logout
|
||||
// 5. Account termination: Ensure all access revoked
|
||||
//
|
||||
// Why this is important:
|
||||
// - User control: Can remotely log out stolen device
|
||||
// - Security: Limits damage from compromise
|
||||
// - Password change: Ensures old sessions can't continue
|
||||
// - Compliance: May be required for certain operations
|
||||
//
|
||||
// What happens to user:
|
||||
// - All devices logged out
|
||||
// - Must log in again with new credentials
|
||||
// - Sees all sessions revoked in session list
|
||||
// - Receives email notification (recommended)
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context for database operations
|
||||
// - userId: User whose tokens to revoke
|
||||
//
|
||||
// Return values:
|
||||
// - nil: All tokens successfully revoked
|
||||
// - error: Database error occurred
|
||||
func (a *AuthService) RevokeAllUserToken(ctx context.Context, userId uuid.UUID) error {
|
||||
// Revoke all sessions for user
|
||||
// Reason "revoke_all" indicates this was bulk revocation
|
||||
// Repository handles finding and updating all sessions
|
||||
return a.sessionRepo.RevokeByUserId(ctx, userId, "revoke_all")
|
||||
}
|
||||
|
||||
// detectDeviceType attempts to determine device type from user agent string.
|
||||
// This is used for:
|
||||
// - Session display (show user what device they're logged in on)
|
||||
// - Security monitoring (detect unusual devices)
|
||||
// - Analytics (understand user devices)
|
||||
// - Targeted features (mobile vs desktop experience)
|
||||
//
|
||||
// Detection logic:
|
||||
// 1. Check for mobile indicators: "Mobile", "Android", "iPhone"
|
||||
// 2. Check for desktop app indicator: "Electron"
|
||||
// 3. Default to "web" for browsers
|
||||
// 4. Return "unknown" if no user agent
|
||||
//
|
||||
// Device types:
|
||||
// - "mobile": Smartphones and tablets (Android, iOS)
|
||||
// - "desktop": Desktop applications (Electron apps)
|
||||
// - "web": Web browsers on desktop/laptop
|
||||
// - "unknown": No user agent or unrecognized
|
||||
//
|
||||
// Limitations:
|
||||
// - User agent can be spoofed (not 100% reliable)
|
||||
// - Simple detection (not comprehensive device detection)
|
||||
// - Can't distinguish tablet from phone
|
||||
// - Can't detect specific browser or OS version
|
||||
//
|
||||
// For better detection, consider:
|
||||
// - User agent parsing library (more comprehensive)
|
||||
// - Client-side detection (more accurate)
|
||||
// - Device fingerprinting (more reliable but privacy concerns)
|
||||
//
|
||||
// Why this is good enough:
|
||||
// - Just for display/convenience
|
||||
// - Not used for security decisions
|
||||
// - Simple and fast
|
||||
// - No external dependencies
|
||||
//
|
||||
// Parameters:
|
||||
// - userAgent: User-Agent header from HTTP request
|
||||
//
|
||||
// Returns:
|
||||
// - "mobile": Mobile device detected
|
||||
// - "desktop": Desktop application detected
|
||||
// - "web": Web browser (default for desktop browsers)
|
||||
// - "unknown": No user agent or unrecognized
|
||||
func detectDeviceType(userAgent *string) string {
|
||||
// Handle nil user agent (no header provided)
|
||||
if userAgent == nil {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Get user agent string
|
||||
ua := *userAgent
|
||||
|
||||
// Check for mobile indicators
|
||||
// Contains checks for substring (case-insensitive via contains helper)
|
||||
// Mobile keywords: "Mobile", "Android", "Iphone" (covers iOS and Android)
|
||||
if contains(ua, "Mobile") || contains(ua, "Android") || contains(ua, "Iphone") {
|
||||
return "mobile"
|
||||
}
|
||||
|
||||
// Check for desktop application
|
||||
// "Electron" indicates Electron-based desktop app
|
||||
if contains(ua, "Electron") {
|
||||
return "desktop"
|
||||
}
|
||||
|
||||
// Default to web browser
|
||||
// Catches Chrome, Firefox, Safari, Edge, etc. on desktop
|
||||
return "web"
|
||||
}
|
||||
|
||||
// contains checks if a string contains a substring (case-insensitive).
|
||||
// This is a helper function for user agent parsing.
|
||||
//
|
||||
// Why case-insensitive?
|
||||
// - User agents can vary in casing
|
||||
// - "iPhone" vs "iphone" vs "IPHONE"
|
||||
// - "Android" vs "android"
|
||||
// - More robust matching
|
||||
//
|
||||
// Implementation:
|
||||
// - Convert both strings to lowercase
|
||||
// - Use strings.Contains for substring check
|
||||
//
|
||||
// Parameters:
|
||||
// - s: String to search in
|
||||
// - substring: Substring to search for
|
||||
//
|
||||
// Returns:
|
||||
// - true: Substring found (case-insensitive)
|
||||
// - false: Substring not found
|
||||
func contains(s string, substring string) bool {
|
||||
// Convert both to lowercase and check if substring exists
|
||||
return strings.Contains(strings.ToLower(s), strings.ToLower(substring))
|
||||
}
|
||||
|
|
@ -0,0 +1,657 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/creativenoz/aurganize-v62/backend/internal/models"
|
||||
"github.com/creativenoz/aurganize-v62/backend/internal/repositories"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Predefined errors for user operations.
|
||||
// These are package-level error variables that can be:
|
||||
// 1. Compared using errors.Is() for error handling
|
||||
// 2. Wrapped with context using fmt.Errorf()
|
||||
// 3. Tested reliably (same instance)
|
||||
// 4. Documented centrally
|
||||
//
|
||||
// Why define errors at package level?
|
||||
// - Consistency: Same error for same situation across codebase
|
||||
// - Testability: Can check for specific error types
|
||||
// - Documentation: Clear list of possible errors
|
||||
// - i18n ready: Can map errors to localized messages
|
||||
var (
|
||||
// ErrUserNotFound indicates the requested user doesn't exist
|
||||
// Used when: Looking up user by ID/email and not found
|
||||
// HTTP code: 404 Not Found
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
|
||||
// ErrInvalidCredentials indicates email or password is wrong
|
||||
// Used when: Login fails due to bad credentials
|
||||
// HTTP code: 401 Unauthorized
|
||||
// Security: Generic message prevents email enumeration
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
|
||||
// ErrEmailAlreadyExists indicates email is already registered
|
||||
// Used when: Registration with existing email
|
||||
// HTTP code: 400 Bad Request or 409 Conflict
|
||||
ErrEmailAlreadyExists = errors.New("email already exists")
|
||||
// ErrWeakPassword indicates password doesn't meet security requirements
|
||||
// Used when: Registration or password change with weak password
|
||||
// HTTP code: 400 Bad Request
|
||||
ErrWeakPassword = errors.New("password is too weak")
|
||||
|
||||
// ErrInvalidEmail indicates email format is invalid
|
||||
// Used when: Registration with malformed email
|
||||
// HTTP code: 400 Bad Request
|
||||
ErrInvalidEmail = errors.New("invalid email format")
|
||||
)
|
||||
|
||||
// UserService handles all user-related business logic.
|
||||
// This service sits between HTTP handlers and data repositories.
|
||||
//
|
||||
// Responsibilities:
|
||||
// 1. User registration (with validation)
|
||||
// 2. User authentication (credential verification)
|
||||
// 3. User lookup (by ID or email)
|
||||
// 4. User updates (password, last login)
|
||||
// 5. Input validation (email format, password strength)
|
||||
//
|
||||
// Architecture: Service Layer Pattern
|
||||
// Benefits:
|
||||
// - Business logic separated from HTTP concerns
|
||||
// - Reusable across multiple handlers
|
||||
// - Testable without HTTP infrastructure
|
||||
// - Can coordinate multiple repositories
|
||||
// - Can add cross-cutting concerns (logging, metrics)
|
||||
//
|
||||
// Why this layer exists:
|
||||
// - Handlers should be thin (just HTTP translation)
|
||||
// - Repositories should be simple (just database operations)
|
||||
// - Business logic needs a home (validation, coordination)
|
||||
//
|
||||
// Security considerations:
|
||||
// - Email normalization (prevent duplicate accounts)
|
||||
// - Password strength validation (prevent weak passwords)
|
||||
// - Generic error messages (prevent information leakage)
|
||||
// - Input sanitization (prevent injection attacks)
|
||||
type UserService struct {
|
||||
userRepo *repositories.UserRepository // Database operations for users
|
||||
}
|
||||
|
||||
// NewUserService creates a new UserService with injected dependencies.
|
||||
// This follows dependency injection pattern:
|
||||
// - Repository passed in (not created internally)
|
||||
// - Makes testing easier (can inject mock repository)
|
||||
// - Keeps service decoupled from repository implementation
|
||||
//
|
||||
// Parameters:
|
||||
// - userRepo: Repository for user database operations
|
||||
//
|
||||
// Returns:
|
||||
// - Fully initialized UserService
|
||||
func NewUserService(userRepo *repositories.UserRepository) *UserService {
|
||||
return &UserService{userRepo: userRepo}
|
||||
}
|
||||
|
||||
// Register creates a new user account with validation.
|
||||
// This is the complete user registration flow.
|
||||
//
|
||||
// What happens:
|
||||
// 1. Validate input (email format, password strength, uniqueness)
|
||||
// 2. Normalize email (lowercase, trim whitespace)
|
||||
// 3. Create user in database (repository handles password hashing)
|
||||
// 4. Return created user object
|
||||
//
|
||||
// Validation performed:
|
||||
// - Email format validation (structure, length)
|
||||
// - Email uniqueness check (not already registered)
|
||||
// - Password strength validation (length, complexity, not common)
|
||||
//
|
||||
// Why validate in service layer?
|
||||
// - Business rules belong here
|
||||
// - Reusable validation (same rules everywhere)
|
||||
// - Clear error messages for different failure cases
|
||||
// - Can be tested independently
|
||||
//
|
||||
// Email normalization importance:
|
||||
// - Prevents duplicate accounts: user@example.com vs USER@Example.com
|
||||
// - Consistent storage format
|
||||
// - Easier searching and matching
|
||||
// - Standard practice for email handling
|
||||
//
|
||||
// Security considerations:
|
||||
// - Password never logged or exposed
|
||||
// - Email uniqueness check prevents enumeration (generic error)
|
||||
// - Strong password requirements enforced
|
||||
// - Input sanitization (trim, lowercase)
|
||||
//
|
||||
// After registration:
|
||||
// - User account created but may need email verification
|
||||
// - Caller should send verification email
|
||||
// - User might not be able to log in until verified (depends on status)
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context for database operations
|
||||
// - userInput: Registration data (email, password, names, etc.)
|
||||
//
|
||||
// Return values:
|
||||
// - (*User, nil): Successfully created user
|
||||
// - (nil, ErrInvalidEmail): Email format invalid
|
||||
// - (nil, ErrEmailAlreadyExists): Email already registered
|
||||
// - (nil, ErrWeakPassword): Password too weak
|
||||
// - (nil, error): Database error or other failure
|
||||
func (u *UserService) Register(ctx context.Context, userInput *models.CreateUserInput) (*models.User, error) {
|
||||
// Step 1: Validate registration input
|
||||
// This checks:
|
||||
// - Email format is valid
|
||||
// - Email not already registered
|
||||
// - Password meets strength requirements
|
||||
if err := u.ValidateRegistrationInput(ctx, userInput); err != nil {
|
||||
return nil, err // Return specific validation error
|
||||
}
|
||||
|
||||
// Step 2: Normalize email for consistent storage
|
||||
// - TrimSpace: Remove leading/trailing whitespace
|
||||
// - ToLower: Convert to lowercase for case-insensitive matching
|
||||
// Why: "User@Example.COM " becomes "user@example.com"
|
||||
userInput.Email = strings.ToLower(strings.TrimSpace(userInput.Email))
|
||||
|
||||
// Step 3: Create user in database
|
||||
// Repository handles:
|
||||
// - Password hashing (bcrypt)
|
||||
// - Database insertion
|
||||
// - Generating user ID and timestamps
|
||||
user, err := u.userRepo.Create(ctx, userInput)
|
||||
if err != nil {
|
||||
// Wrap error with context for better debugging
|
||||
return nil, fmt.Errorf("failed to create user : %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Return created user
|
||||
// User object includes generated ID, timestamps, etc.
|
||||
return user, err
|
||||
}
|
||||
|
||||
// AuthenticateUserByEmail verifies user credentials (email + password).
|
||||
// This is the core of the login process.
|
||||
//
|
||||
// What happens:
|
||||
// 1. Normalize email (lowercase, trim)
|
||||
// 2. Look up user by email
|
||||
// 3. Verify password against stored hash
|
||||
// 4. Return user if valid
|
||||
//
|
||||
// Security considerations:
|
||||
// - Generic error message (prevents email enumeration)
|
||||
// - Email normalization (consistent with registration)
|
||||
// - Password never logged or exposed
|
||||
// - Constant-time password comparison (via bcrypt)
|
||||
//
|
||||
// Why generic error?
|
||||
// - "Invalid credentials" for both wrong email AND wrong password
|
||||
// - Prevents attackers from discovering valid emails
|
||||
// - Standard security practice
|
||||
// - Trade-off: Slightly worse UX for better security
|
||||
//
|
||||
// Email enumeration attack explained:
|
||||
// - Attacker tries many emails
|
||||
// - Different errors for "email not found" vs "wrong password"
|
||||
// - Attacker can build list of valid emails
|
||||
// - Then focus on password cracking for valid emails
|
||||
// - Solution: Same error for both cases
|
||||
//
|
||||
// Password verification:
|
||||
// - Uses bcrypt.CompareHashAndPassword
|
||||
// - Constant-time comparison (prevents timing attacks)
|
||||
// - Automatically handles salt extraction
|
||||
// - Returns error if no match
|
||||
//
|
||||
// After successful authentication:
|
||||
// - Caller should check user.Status (active, suspended, etc.)
|
||||
// - Caller should generate auth tokens
|
||||
// - Caller should update last login timestamp
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context for database operations
|
||||
// - email: User's email address
|
||||
// - password: User's plaintext password
|
||||
//
|
||||
// Return values:
|
||||
// - (*User, nil): Authentication successful
|
||||
// - (nil, ErrInvalidCredentials): Wrong email or password
|
||||
// - (nil, error): Database error (wrapped)
|
||||
func (u *UserService) AuthenticateUserByEmail(ctx context.Context, email string, password string) (*models.User, error) {
|
||||
// Step 1: Normalize email
|
||||
// Must match normalization done during registration
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
|
||||
// Step 2: Look up user by email
|
||||
user, err := u.userRepo.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
// Wrap error with context
|
||||
// This is a database error, not "user not found"
|
||||
return nil, fmt.Errorf("repository error : %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Check if user exists
|
||||
if user == nil {
|
||||
// Email not found in database
|
||||
// Return generic error (don't reveal email doesn't exist)
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Step 4: Verify password
|
||||
// Repository method uses bcrypt to compare
|
||||
// Returns false if password doesn't match
|
||||
if !u.userRepo.VerifyPassword(user, password) {
|
||||
// Password incorrect
|
||||
// Return generic error (don't reveal password was wrong)
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Step 5: Authentication successful
|
||||
// Return user object for token generation
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetByID retrieves a user by their unique ID.
|
||||
// This is used for:
|
||||
// - Loading user after authentication
|
||||
// - Fetching user profile
|
||||
// - Validating user existence
|
||||
//
|
||||
// When to use this vs GetByEmail:
|
||||
// - Use GetByID: When you already have user ID (from token, etc.)
|
||||
// - Use GetByEmail: During login or user lookup
|
||||
//
|
||||
// Why this is simple:
|
||||
// - Just wraps repository call
|
||||
// - Adds consistent error wrapping
|
||||
// - Provides clear error when user not found
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context for database operations
|
||||
// - id: User's unique identifier (UUID)
|
||||
//
|
||||
// Return values:
|
||||
// - (*User, nil): User found
|
||||
// - (nil, ErrUserNotFound): User doesn't exist (or is deleted)
|
||||
// - (nil, error): Database error (wrapped)
|
||||
func (u *UserService) GetByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
|
||||
// Look up user by ID
|
||||
user, err := u.userRepo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
// Database error
|
||||
return nil, fmt.Errorf("repository error : %w", err)
|
||||
}
|
||||
|
||||
// Check if user was found
|
||||
if user == nil {
|
||||
// User doesn't exist (or is soft-deleted)
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetByEmail retrieves a user by their email address.
|
||||
// This is used for:
|
||||
// - User lookup in admin interfaces
|
||||
// - Checking if email is registered
|
||||
// - Loading user before certain operations
|
||||
//
|
||||
// When to use this vs GetByID:
|
||||
// - Use GetByEmail: When you have email (user lookup, admin search)
|
||||
// - Use GetByID: When you have ID (token validation, internal operations)
|
||||
//
|
||||
// Email should be normalized before calling (lowercase, trim).
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context for database operations
|
||||
// - email: User's email address
|
||||
//
|
||||
// Return values:
|
||||
// - (*User, nil): User found
|
||||
// - (nil, ErrUserNotFound): User doesn't exist
|
||||
// - (nil, error): Database error (wrapped)
|
||||
func (u *UserService) GetByEmail(ctx context.Context, email string) (*models.User, error) {
|
||||
// Look up user by email
|
||||
user, err := u.userRepo.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
// Database error
|
||||
return nil, fmt.Errorf("repository error : %w", err)
|
||||
}
|
||||
|
||||
// Check if user was found
|
||||
if user == nil {
|
||||
// User doesn't exist
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// UpdateLastLogin updates user's last login timestamp and IP address.
|
||||
// This is called after successful login for audit purposes.
|
||||
//
|
||||
// Why track this:
|
||||
// - Security monitoring
|
||||
// - User awareness ("last login")
|
||||
// - Account activity tracking
|
||||
// - Compliance requirements
|
||||
//
|
||||
// When to call:
|
||||
// - After successful login
|
||||
// - After token refresh (debatable)
|
||||
// - Before generating tokens
|
||||
//
|
||||
// This is a simple pass-through to repository.
|
||||
// Service layer included for consistency and future business logic.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context for database operations
|
||||
// - id: User's ID
|
||||
// - ipAddress: IP address of request
|
||||
//
|
||||
// Returns:
|
||||
// - error: Database error if update fails
|
||||
func (u *UserService) UpdateLastLogin(ctx context.Context, id uuid.UUID, ipAddress *string) error {
|
||||
// Delegate to repository
|
||||
return u.userRepo.UpdateLastLogin(ctx, id, ipAddress)
|
||||
}
|
||||
|
||||
// UpdatePassword changes a user's password.
|
||||
// This is used for:
|
||||
// - User-initiated password change
|
||||
// - Password reset flow
|
||||
// - Admin force password change
|
||||
//
|
||||
// Important: Before calling this:
|
||||
// 1. Validate user's identity (current password or reset token)
|
||||
// 2. Validate new password strength
|
||||
// 3. Verify user has permission (self or admin)
|
||||
//
|
||||
// After calling this:
|
||||
// 1. Revoke all user's sessions (force re-login)
|
||||
// 2. Send email notification to user
|
||||
// 3. Log event for audit trail
|
||||
// 4. Update password history (if tracking)
|
||||
//
|
||||
// This is a simple pass-through to repository.
|
||||
// Repository handles password hashing.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context for database operations
|
||||
// - id: User's ID
|
||||
// - newPassword: New plaintext password (will be hashed)
|
||||
//
|
||||
// Returns:
|
||||
// - error: Hashing or database error
|
||||
func (u *UserService) UpdatePassword(ctx context.Context, id uuid.UUID, newPassword string) error {
|
||||
// Delegate to repository
|
||||
// Repository handles bcrypt hashing
|
||||
return u.userRepo.UpdatePassword(ctx, id, newPassword)
|
||||
}
|
||||
|
||||
// ValidateRegistrationInput validates all user registration input.
|
||||
// This is called before creating a new user account.
|
||||
//
|
||||
// Validations performed:
|
||||
// 1. Email format validation (structure, length)
|
||||
// 2. Email uniqueness check (not already registered)
|
||||
// 3. Password strength validation (length, complexity)
|
||||
//
|
||||
// Why validate in service layer?
|
||||
// - Business rules belong here (not in handler or repository)
|
||||
// - Reusable validation (called from multiple places)
|
||||
// - Testable independently
|
||||
// - Clear separation of concerns
|
||||
//
|
||||
// Validation order matters:
|
||||
// 1. Format validation first (cheap, no database query)
|
||||
// 2. Uniqueness check second (database query, more expensive)
|
||||
// 3. Password validation last (computational cost)
|
||||
//
|
||||
// Why email uniqueness here vs database constraint?
|
||||
// - Both! Database constraint is backup
|
||||
// - Service check provides better error message
|
||||
// - Service check prevents unnecessary password hashing
|
||||
// - Database constraint prevents race conditions
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context for database operations
|
||||
// - input: Registration input to validate
|
||||
//
|
||||
// Return values:
|
||||
// - nil: All validation passed
|
||||
// - ErrInvalidEmail: Email format invalid
|
||||
// - ErrEmailAlreadyExists: Email already registered
|
||||
// - ErrWeakPassword: Password doesn't meet requirements
|
||||
// - error: Database error during uniqueness check
|
||||
func (u *UserService) ValidateRegistrationInput(ctx context.Context, input *models.CreateUserInput) error {
|
||||
// Step 1: Validate email format
|
||||
// Checks structure, length, basic format
|
||||
// This is cheap (no database query)
|
||||
if !isValidEmail(input.Email) {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
|
||||
// Step 2: Check email uniqueness
|
||||
// Queries database to see if email already exists
|
||||
// Lowercase email for case-insensitive check
|
||||
exists, err := u.userRepo.EmailExists(ctx, strings.ToLower(input.Email))
|
||||
if err != nil {
|
||||
// Database error during uniqueness check
|
||||
// Wrap with context for debugging
|
||||
return fmt.Errorf("failed to check email uniqueness : %w email id [%s]", err, input.Email)
|
||||
}
|
||||
if exists {
|
||||
// Email already registered
|
||||
return ErrEmailAlreadyExists
|
||||
}
|
||||
|
||||
// Step 3: Validate password strength
|
||||
// Checks length, complexity, common passwords
|
||||
// Requires password, email (to prevent email in password), and first name (to prevent name in password)
|
||||
if !isStrongPassword(input.Password, input.Email, *input.FirstName) {
|
||||
return ErrWeakPassword
|
||||
}
|
||||
|
||||
// All validation passed
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidEmail checks if an email address has valid format.
|
||||
// This is a basic validation, not RFC 5322 compliant.
|
||||
//
|
||||
// Checks performed:
|
||||
// 1. Length: 3-254 characters (RFC 5321 limit)
|
||||
// 2. Contains @: Must have exactly one @
|
||||
// 3. @ position: Not at start or end
|
||||
// 4. Local part: 1-64 characters (before @)
|
||||
// 5. Domain part: Contains at least one dot
|
||||
//
|
||||
// What this DOESN'T check:
|
||||
// - Special characters in local part
|
||||
// - International domain names
|
||||
// - Multiple @ symbols in quoted local part
|
||||
// - Full RFC 5322 compliance
|
||||
//
|
||||
// Why simple validation?
|
||||
// - Good enough for most cases
|
||||
// - Fast (no regex or complex parsing)
|
||||
// - Prevents obvious mistakes
|
||||
// - Final validation is sending verification email
|
||||
//
|
||||
// For production, consider:
|
||||
// - Using email validation library
|
||||
// - DNS MX record check (is domain valid?)
|
||||
// - Disposable email detection
|
||||
// - Email verification required
|
||||
//
|
||||
// Examples:
|
||||
// - Valid: "user@example.com", "john.doe@company.co.uk"
|
||||
// - Invalid: "user", "@example.com", "user@", "user@@example.com"
|
||||
//
|
||||
// Parameters:
|
||||
// - emailInput: Email string to validate
|
||||
//
|
||||
// Returns:
|
||||
// - true: Email format appears valid
|
||||
// - false: Email format is invalid
|
||||
func isValidEmail(emailInput string) bool {
|
||||
// Trim whitespace for validation
|
||||
email := strings.TrimSpace(emailInput)
|
||||
|
||||
// Check length constraints
|
||||
// Min: "a@b.c" = 5 chars (but we use 3 to be permissive)
|
||||
// Max: 254 chars per RFC 5321
|
||||
if len(email) < 3 || len(email) > 254 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Find position of last @ symbol
|
||||
// LastIndex returns -1 if not found
|
||||
atIndex := strings.LastIndex(email, "@")
|
||||
|
||||
// Validate @ position
|
||||
// Must exist and not be at start (position 0) or end
|
||||
if atIndex < 1 || atIndex > len(email)-1 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Split email into local and domain parts
|
||||
localPart := email[:atIndex] // Before @
|
||||
domainPart := email[atIndex+1:] // After @
|
||||
|
||||
// Validate local part length
|
||||
// RFC 5321: Maximum 64 characters before @
|
||||
if len(localPart) < 1 || len(localPart) > 64 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate domain part has at least one dot
|
||||
// Required for valid domain (e.g., "example.com")
|
||||
// Note: This doesn't validate TLD or DNS
|
||||
if !strings.Contains(domainPart, ".") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Basic validation passed
|
||||
return true
|
||||
}
|
||||
|
||||
// isStrongPassword validates password meets security requirements.
|
||||
// This enforces password policy to prevent weak passwords.
|
||||
//
|
||||
// Requirements:
|
||||
// 1. Minimum 8 characters (longer is better)
|
||||
// 2. At least one lowercase letter (a-z)
|
||||
// 3. At least one uppercase letter (A-Z)
|
||||
// 4. At least one number (0-9)
|
||||
// 5. At least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?/~)
|
||||
// 6. Must NOT contain user's email
|
||||
// 7. Must NOT contain user's first name
|
||||
//
|
||||
// Why these requirements?
|
||||
// - Length: Harder to brute force
|
||||
// - Lowercase: Increases character space
|
||||
// - Uppercase: Increases character space
|
||||
// - Number: Increases character space
|
||||
// - Special: Increases character space (most important)
|
||||
// - No email: Prevents easy guessing
|
||||
// - No name: Prevents easy guessing
|
||||
//
|
||||
// Character space importance:
|
||||
// - Lowercase only: 26^8 = 208 billion combinations
|
||||
// - + Uppercase: 52^8 = 53 trillion combinations
|
||||
// - + Numbers: 62^8 = 218 trillion combinations
|
||||
// - + Special chars: 90^8 = 4.3 quadrillion combinations
|
||||
//
|
||||
// What this DOESN'T check:
|
||||
// - Dictionary words (would need dictionary)
|
||||
// - Common passwords (would need list like "password123")
|
||||
// - Keyboard patterns (would need pattern matching)
|
||||
// - Previously breached passwords (would need Have I Been Pwned API)
|
||||
//
|
||||
// For production, consider:
|
||||
// - Password strength library (zxcvbn)
|
||||
// - Have I Been Pwned API integration
|
||||
// - Common password blacklist
|
||||
// - Personal information checking (birthdate, etc.)
|
||||
//
|
||||
// Parameters:
|
||||
// - passwordToCheck: Password to validate
|
||||
// - email: User's email (to prevent email in password)
|
||||
// - firstName: User's first name (to prevent name in password)
|
||||
//
|
||||
// Returns:
|
||||
// - true: Password meets all requirements
|
||||
// - false: Password fails one or more requirements
|
||||
func isStrongPassword(passwordToCheck string, email string, firstName string) bool {
|
||||
// Check minimum length
|
||||
// 8 characters minimum (NIST recommends at least 8)
|
||||
// Consider increasing to 12 or 16 for better security
|
||||
if len(passwordToCheck) < 8 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Initialize flags for each requirement
|
||||
hasLowerCase := false
|
||||
hasUpperCase := false
|
||||
hasSpecialCharacter := false
|
||||
hasNumber := false
|
||||
|
||||
// Check each character in password
|
||||
// We iterate once through the string checking all requirements
|
||||
for _, char := range passwordToCheck {
|
||||
switch {
|
||||
// Check for uppercase letter (A-Z)
|
||||
case char >= 'A' && char <= 'Z':
|
||||
hasUpperCase = true
|
||||
// Check for lowercase letter (a-z)
|
||||
case char >= 'a' && char <= 'z':
|
||||
hasLowerCase = true
|
||||
// Check for digit (0-9)
|
||||
case char >= '0' && char <= '9':
|
||||
hasNumber = true
|
||||
// Check for special characters
|
||||
// Ranges cover common special characters on keyboard:
|
||||
// !, ", #, $, %, &, ', (, ), *, +, ,, -, ., /
|
||||
// :, ;, <, =, >, ?, @
|
||||
// [, \, ], ^, _, `
|
||||
// {, |, }, ~
|
||||
case (char >= '!' && char <= '/') || // !"#$%&'()*+,-./
|
||||
(char >= ':' && char <= '@') || // :;<=>?@
|
||||
(char >= '[' && char <= '`') || // [\]^_`
|
||||
(char >= '{' && char <= '~'): // {|}~
|
||||
hasSpecialCharacter = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all character type requirements are met
|
||||
if !hasLowerCase || !hasUpperCase || !hasSpecialCharacter || !hasNumber {
|
||||
return false // Missing at least one required character type
|
||||
}
|
||||
|
||||
// Check if password contains user's email
|
||||
// Prevents passwords like "myemail@example.com123"
|
||||
// Case-insensitive check
|
||||
if strings.Contains(passwordToCheck, email) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if password contains user's first name
|
||||
// Prevents passwords like "JohnSmith123!"
|
||||
// Case-insensitive check
|
||||
if strings.Contains(passwordToCheck, firstName) {
|
||||
return false
|
||||
}
|
||||
|
||||
// All requirements passed
|
||||
return true
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AccessTokenClaims represents the claims stored in a short-lived access token.
|
||||
// Access tokens are used for authenticating API requests and contain user identity
|
||||
// and authorization information. They are stateless and validated purely through
|
||||
// JWT signature verification without requiring database lookups.
|
||||
//
|
||||
// Typical lifetime: 15 minutes to 1 hour
|
||||
// Use case: Included in Authorization header for every API request
|
||||
type AccessTokenClaims struct {
|
||||
UserID uuid.UUID `json:"user_id"` // Unique identifier of the authenticated user
|
||||
TenantID uuid.UUID `json:"tenant_id"` // Tenant/organization ID for multi-tenant isolation
|
||||
Email string `json:"email"` // User's email address for identification
|
||||
Role string `json:"role"` // User's role (e.g., "admin", "user", "contractor") for authorization
|
||||
TokenType string `json:"token_type"` // Always "access" - used to prevent token type confusion attacks
|
||||
jwt.RegisteredClaims // Standard JWT claims (exp, iat, nbf, iss, sub)
|
||||
}
|
||||
|
||||
// RefreshTokenClaims represents the claims stored in a long-lived refresh token.
|
||||
// Refresh tokens are used to obtain new access tokens without requiring the user
|
||||
// to re-authenticate. They are stateful and validated against database sessions,
|
||||
// allowing for instant revocation when needed (logout, security events, etc.).
|
||||
//
|
||||
// Typical lifetime: 7-30 days
|
||||
// Use case: Stored securely on client, exchanged for new access tokens when they expire
|
||||
//
|
||||
// Security model: Hybrid approach combining JWT signature validation with database
|
||||
// session validation for both performance and revocability.
|
||||
type RefreshTokenClaims struct {
|
||||
UserID uuid.UUID `json:"user_id"` // Unique identifier of the authenticated user
|
||||
SessionID uuid.UUID `json:"session_id"` // Database session ID for revocation and tracking
|
||||
TokenID string `json:"token_id"` // Cryptographically random token (32 bytes) embedded in JWT and hashed in database
|
||||
TokenType string `json:"token_type"` // Always "refresh" - used to prevent token type confusion attacks
|
||||
jwt.RegisteredClaims // Standard JWT claims (exp, iat, nbf, iss, sub)
|
||||
}
|
||||
|
||||
// Valid validates the AccessTokenClaims by checking the token type.
|
||||
// This method is called automatically by the JWT library during token parsing
|
||||
// to perform custom validation logic beyond the standard claims validation.
|
||||
//
|
||||
// Note: In jwt-go v5, validation of standard claims (exp, iat, nbf) is handled
|
||||
// through parser options like jwt.WithExpirationRequired() rather than the Valid()
|
||||
// method. This method only validates custom business logic (token type).
|
||||
//
|
||||
// Returns:
|
||||
// - nil if the token type is "access"
|
||||
// - jwt.ErrInvalidType if the token type is not "access" (prevents using refresh tokens as access tokens)
|
||||
func (c AccessTokenClaims) Valid() error {
|
||||
if c.TokenType != "access" {
|
||||
return jwt.ErrInvalidType
|
||||
}
|
||||
|
||||
// Standard claims validation (exp, iat, nbf, etc.) is handled by parser options:
|
||||
//
|
||||
// Example usage:
|
||||
// token, err := jwt.ParseWithClaims(
|
||||
// tokenString,
|
||||
// &AccessTokenClaims{},
|
||||
// keyFunc,
|
||||
// jwt.WithExpirationRequired(), // Validates exp claim
|
||||
// jwt.WithIssuedAt(), // Validates iat claim
|
||||
// jwt.WithTimeFunc(time.Now), // Provides time source for validation
|
||||
// )
|
||||
//
|
||||
// This approach is more explicit and testable than the v4 nested validation.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Valid validates the RefreshTokenClaims by checking the token type.
|
||||
// This method is called automatically by the JWT library during token parsing
|
||||
// to perform custom validation logic beyond the standard claims validation.
|
||||
//
|
||||
// Note: Refresh tokens undergo additional validation beyond this method:
|
||||
// 1. JWT signature verification (ensures token was issued by our server)
|
||||
// 2. Standard claims validation via parser options (exp, iat, nbf)
|
||||
// 3. This method's token type check (prevents using access tokens as refresh tokens)
|
||||
// 4. Database session lookup using SessionID (ensures session still exists)
|
||||
// 5. Token hash verification (ensures TokenID matches hashed value in database)
|
||||
// 6. Session status checks (not revoked, not expired in database)
|
||||
//
|
||||
// This multi-layer validation provides defense in depth security.
|
||||
//
|
||||
// Returns:
|
||||
// - nil if the token type is "refresh"
|
||||
// - jwt.ErrInvalidType if the token type is not "refresh" (prevents using access tokens as refresh tokens)
|
||||
func (c RefreshTokenClaims) Valid() error {
|
||||
if c.TokenType != "refresh" {
|
||||
return jwt.ErrInvalidType
|
||||
}
|
||||
|
||||
// Standard claims validation (exp, iat, nbf, etc.) is handled by parser options.
|
||||
// See AccessTokenClaims.Valid() comment for detailed explanation.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
-- Drop indexes
|
||||
DROP INDEX IF EXISTS idx_sessions_expired_cleanup;
|
||||
DROP INDEX IF EXISTS idx_sessions_is_revoked;
|
||||
DROP INDEX IF EXISTS idx_sessions_expires_at;
|
||||
DROP INDEX IF EXISTS idx_sessions_refresh_token;
|
||||
DROP INDEX IF EXISTS idx_sessions_user_id;
|
||||
|
||||
-- Drop table
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
-- ==========================================
|
||||
-- SESSIONS TABLE
|
||||
-- Purpose: Store refresh tokens for JWT authentication
|
||||
-- ==========================================
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
-- Primary key
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
-- User reference
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Token details
|
||||
refresh_token VARCHAR(500) NOT NULL UNIQUE,
|
||||
refresh_token_hash VARCHAR(255) NOT NULL, -- bcrypt hash of token
|
||||
|
||||
-- Device/Client information
|
||||
user_agent TEXT,
|
||||
ip_address INET,
|
||||
device_name VARCHAR(255),
|
||||
device_type VARCHAR(50), -- 'web', 'mobile', 'desktop'
|
||||
|
||||
-- Expiry
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
|
||||
-- Status
|
||||
is_revoked BOOLEAN DEFAULT FALSE,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_reason VARCHAR(255),
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||
last_used_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT chk_device_type CHECK (device_type IN ('web', 'mobile', 'desktop', 'unknown'))
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_sessions_user_id ON sessions(user_id) WHERE NOT is_revoked;
|
||||
CREATE INDEX idx_sessions_refresh_token ON sessions(refresh_token) WHERE NOT is_revoked;
|
||||
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX idx_sessions_is_revoked ON sessions(is_revoked);
|
||||
|
||||
-- Index for cleanup queries (expired sessions)
|
||||
-- Note: Cannot use NOW() in partial index - it's not IMMUTABLE
|
||||
-- The application will filter expires_at < NOW() at query time
|
||||
CREATE INDEX idx_sessions_expired_cleanup ON sessions(expires_at, is_revoked)
|
||||
WHERE NOT is_revoked;
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE sessions IS 'JWT refresh token storage with device tracking';
|
||||
COMMENT ON COLUMN sessions.refresh_token IS 'Plain refresh token (indexed for lookup)';
|
||||
COMMENT ON COLUMN sessions.refresh_token_hash IS 'Bcrypt hash of refresh token (for verification)';
|
||||
COMMENT ON COLUMN sessions.is_revoked IS 'Manually revoked sessions (logout)';
|
||||
|
|
@ -130,19 +130,19 @@ services:
|
|||
- "8080:8080"
|
||||
volumes:
|
||||
# Mount source code for hot reload
|
||||
- ./backend:/app
|
||||
- ./../../backend:/app
|
||||
# Exclude node_modules and vendor
|
||||
- /app/vendor
|
||||
- /app/bin
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
nats:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
# depends_on:
|
||||
# postgres:
|
||||
# condition: service_healthy
|
||||
# redis:
|
||||
# condition: service_healthy
|
||||
# nats:
|
||||
# condition: service_healthy
|
||||
# minio:
|
||||
# condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"]
|
||||
interval: 10s
|
||||
|
|
|
|||
Loading…
Reference in New Issue