From 060cf8c78b679d2939e0caa931cb1ab22b38463f Mon Sep 17 00:00:00 2001 From: rizzOn Date: Mon, 8 Dec 2025 02:10:21 +0530 Subject: [PATCH] feat(auth): Implement comprehensive JWT authentication system with token rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/.air.toml | 88 +- backend/.env.example | 26 +- backend/DockerFile.dev | 13 +- backend/go.mod | 40 +- backend/go.sum | 84 +- backend/internal/config/config.go | 345 +++++-- backend/internal/handlers/auth_handler.go | 697 ++++++++++++++ backend/internal/middleware/auth.go | 330 +++++++ backend/internal/middleware/cors.go | 86 ++ backend/internal/middleware/rate_limiter.go | 315 ++++++ backend/internal/models/sessions.go | 177 ++++ backend/internal/models/users.go | 363 +++++++ .../repositories/session_repository.go | 586 ++++++++++++ .../internal/repositories/user_repository.go | 492 ++++++++++ backend/internal/routes/routes.go | 338 +++++++ backend/internal/services/auth_services.go | 899 ++++++++++++++++++ backend/internal/services/user_service.go | 657 +++++++++++++ backend/pkg/auth/claims.go | 101 ++ database/migrations/000002_sessions.down.sql | 15 + database/migrations/000002_sessions.up.sql | 54 ++ infrastructure/docker/docker-compose.yml | 20 +- 21 files changed, 5482 insertions(+), 244 deletions(-) create mode 100644 backend/internal/handlers/auth_handler.go create mode 100644 backend/internal/middleware/auth.go create mode 100644 backend/internal/middleware/cors.go create mode 100644 backend/internal/middleware/rate_limiter.go create mode 100644 backend/internal/models/sessions.go create mode 100644 backend/internal/models/users.go create mode 100644 backend/internal/repositories/session_repository.go create mode 100644 backend/internal/repositories/user_repository.go create mode 100644 backend/internal/routes/routes.go create mode 100644 backend/internal/services/auth_services.go create mode 100644 backend/internal/services/user_service.go create mode 100644 backend/pkg/auth/claims.go create mode 100644 database/migrations/000002_sessions.down.sql create mode 100644 database/migrations/000002_sessions.up.sql diff --git a/backend/.air.toml b/backend/.air.toml index 0785915..08662fb 100644 --- a/backend/.air.toml +++ b/backend/.air.toml @@ -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 \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index 32270b2..ab53910 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/backend/DockerFile.dev b/backend/DockerFile.dev index d0dcde2..bd6e171 100644 --- a/backend/DockerFile.dev +++ b/backend/DockerFile.dev @@ -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 \ No newline at end of file +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" ] \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index a2881b4..6f23f3f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 ) diff --git a/backend/go.sum b/backend/go.sum index cda58ac..66b41b9 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 036a102..3839ac8 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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) } diff --git a/backend/internal/handlers/auth_handler.go b/backend/internal/handlers/auth_handler.go new file mode 100644 index 0000000..a571caf --- /dev/null +++ b/backend/internal/handlers/auth_handler.go @@ -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 +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..19f176f --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -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 " (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 " + 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) + } +} diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go new file mode 100644 index 0000000..7c5dff7 --- /dev/null +++ b/backend/internal/middleware/cors.go @@ -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, + }) +} diff --git a/backend/internal/middleware/rate_limiter.go b/backend/internal/middleware/rate_limiter.go new file mode 100644 index 0000000..6c569bf --- /dev/null +++ b/backend/internal/middleware/rate_limiter.go @@ -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) + + } +} diff --git a/backend/internal/models/sessions.go b/backend/internal/models/sessions.go new file mode 100644 index 0000000..269f8c2 --- /dev/null +++ b/backend/internal/models/sessions.go @@ -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 +} diff --git a/backend/internal/models/users.go b/backend/internal/models/users.go new file mode 100644 index 0000000..dd4a3a9 --- /dev/null +++ b/backend/internal/models/users.go @@ -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 diff --git a/backend/internal/repositories/session_repository.go b/backend/internal/repositories/session_repository.go new file mode 100644 index 0000000..4d933e3 --- /dev/null +++ b/backend/internal/repositories/session_repository.go @@ -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[:]) +} diff --git a/backend/internal/repositories/user_repository.go b/backend/internal/repositories/user_repository.go new file mode 100644 index 0000000..28c4d4c --- /dev/null +++ b/backend/internal/repositories/user_repository.go @@ -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 +} diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go new file mode 100644 index 0000000..c787239 --- /dev/null +++ b/backend/internal/routes/routes.go @@ -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 (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) +} diff --git a/backend/internal/services/auth_services.go b/backend/internal/services/auth_services.go new file mode 100644 index 0000000..69986cd --- /dev/null +++ b/backend/internal/services/auth_services.go @@ -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)) +} diff --git a/backend/internal/services/user_service.go b/backend/internal/services/user_service.go new file mode 100644 index 0000000..e069836 --- /dev/null +++ b/backend/internal/services/user_service.go @@ -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 +} diff --git a/backend/pkg/auth/claims.go b/backend/pkg/auth/claims.go new file mode 100644 index 0000000..1af04c8 --- /dev/null +++ b/backend/pkg/auth/claims.go @@ -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 +} diff --git a/database/migrations/000002_sessions.down.sql b/database/migrations/000002_sessions.down.sql new file mode 100644 index 0000000..ca6d02f --- /dev/null +++ b/database/migrations/000002_sessions.down.sql @@ -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; + + + + + + diff --git a/database/migrations/000002_sessions.up.sql b/database/migrations/000002_sessions.up.sql new file mode 100644 index 0000000..af85738 --- /dev/null +++ b/database/migrations/000002_sessions.up.sql @@ -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)'; \ No newline at end of file diff --git a/infrastructure/docker/docker-compose.yml b/infrastructure/docker/docker-compose.yml index 9720c76..1ac6ecd 100644 --- a/infrastructure/docker/docker-compose.yml +++ b/infrastructure/docker/docker-compose.yml @@ -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