diff --git a/backend/.air.toml b/backend/.air.toml index 0785915..688667c 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_ext = ["go", "tpl", "tmpl", "html"] + # Include file extensions to watch + include_ext = ["go", "env", "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..40ed579 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,9 +11,9 @@ SERVER_WRITE_TIMEOUT=10s # ============================================================================== DB_HOST=localhost DB_PORT=5432 -DB_USER=aurganize -DB_PASSWORD=aurganize_dev_pass_change_in_production -DB_NAME=aurganize_v62 +DB_USER=aurganize_backend_api +DB_PASSWORD=dev_backend_pass_v6.2 +DB_NAME=aurganize_dev DB_SSLMODE=disable # Connection Pool @@ -26,12 +26,20 @@ DB_CONN_MAX_LIFETIME=5m # ============================================================================== # IMPORTANT: Change these secrets in production! # Generate with: openssl rand -base64 32 -JWT_ACCESS_SECRET=your-super-secret-access-key-min-32-chars-change-in-production -JWT_REFRESH_SECRET=your-super-secret-refresh-key-min-32-chars-must-be-different +JWT_ACCESS_SECRET=Qv2vA663YrdO5mX5gufIqLD5uyqkeaYpbiJP/2XC8I0= +JWT_REFRESH_SECRET=ZpOhrMoUAn5MtRpuEPHM9n+Ddv8Y/96WTwleWCej3r8= JWT_ACCESS_EXPIRY=15m JWT_REFRESH_EXPIRY=168h JWT_ISSUER=aurganize-v62 + +# ============================================================================== +# COOKIE SETTING +# ============================================================================== +# Populated with suggested default values since im not sure about them +COOKIE_DOMAIN=aurganize.in +COOKIE_SAMESITE=lax + # ============================================================================== # REDIS (Caching & Sessions) # ============================================================================== @@ -52,4 +60,6 @@ MINIO_ENDPOINT=localhost: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 + + 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/cmd/api/main.go b/backend/cmd/api/main.go index 2c853a7..0468fd7 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -10,9 +10,18 @@ import ( "time" "github.com/creativenoz/aurganize-v62/backend/internal/config" + "github.com/creativenoz/aurganize-v62/backend/internal/handlers" + "github.com/creativenoz/aurganize-v62/backend/internal/middleware" + "github.com/creativenoz/aurganize-v62/backend/internal/repositories" + "github.com/creativenoz/aurganize-v62/backend/internal/routes" + "github.com/creativenoz/aurganize-v62/backend/internal/services" + "github.com/creativenoz/aurganize-v62/backend/jobs" "github.com/creativenoz/aurganize-v62/backend/pkg/logger" + "github.com/go-playground/validator/v10" + "github.com/jmoiron/sqlx" "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" + + echomiddleware "github.com/labstack/echo/v4/middleware" "github.com/rs/zerolog/log" ) @@ -36,13 +45,95 @@ func main() { Str("environment", cfg.Server.Environment). Msg("Starting Aurganize v6.2 API server") + // ========================================================================= + // Database Connection + // ========================================================================= + log.Info(). + Str("host", cfg.DatabaseDSN()) + db, err := sqlx.Connect("postgres", cfg.DatabaseDSN()) + if err != nil { + log.Fatal(). + Err(err). + Str("dsn", cfg.DatabaseDSN()). + Msg("failed to connect to database") + } + + defer db.Close() + + db.SetMaxOpenConns(cfg.Database.MaxOpenConns) + db.SetMaxIdleConns(cfg.Database.MaxIdleConns) + db.SetConnMaxLifetime(cfg.Database.ConnMaxLifetime) + + if err := db.Ping(); err != nil { + log.Fatal(). + Err(err). + Str("dsn", cfg.DatabaseDSN()). + Msg("failed to ping database") + } + log.Info(). + Str("host", cfg.Database.Host). + Str("database", cfg.Database.DBName). + Msg("database connected successfully") + + // ========================================================================= + // Initialize Repositories + // ========================================================================= + userRepo := repositories.NewUserRepository(db) + sessionRepo := repositories.NewSessionRepository(db) + tenantRepo := repositories.NewTenantRepository(db) + log.Info(). + Msg("repositories initialized") + + // ========================================================================= + // Initialize Handlers + // ========================================================================= + + authService := services.NewAuthService(cfg, sessionRepo, userRepo) + userService := services.NewUserService(userRepo) + tenantService := services.NewTenantService(cfg, tenantRepo, userRepo, db) + + log.Info(). + Msg("services initialized") + + // ========================================================================= + // Initialize Handlers + // ========================================================================= + + authHandler := handlers.NewAuthHandler(cfg, authService, userService) + userHandler := handlers.NewUserRegisterHandler(cfg, authService, userService, tenantService) + tenantHandler := handlers.NewTenantHanlder(tenantService) + + log.Info(). + Msg("handlers initialized") + + // ========================================================================= + // Initialize Middleware + // ========================================================================= + authMiddleware := middleware.NewAuthMiddleware(authService) + globalrateLimitterMiddleware := middleware.NewRateLimiter(5, time.Minute) + log.Info(). + Msg("middleware initialized") + + // ========================================================================= + // Background Jobs + // ========================================================================= + sessionCleanUpJob := jobs.NewSessionCleanUpJob(sessionRepo) + + go func() { + sessionCleanUpJob.Start(context.Background()) + }() + + log.Info(). + Dur("interval", 12*time.Hour). + Msg("session clean up job started") + // ========================================================================= // Create Echo Instance // ========================================================================= e := echo.New() e.HideBanner = true e.HidePort = true - + e.Validator = &customValidator{validator: validator.New()} e.HTTPErrorHandler = customHTTPErrorHandler // we are using a custom error handler e.Server.ReadTimeout = cfg.Server.ReadTimeout @@ -54,19 +145,19 @@ func main() { // ========================================================================= // Setting safe recover middleware - e.Use(middleware.Recover()) + e.Use(echomiddleware.Recover()) // Middleware catches panic // Returns 500 Internal Server Error // Server keeps running // ------------------------------------------------------------------------- // Setting request ID middleware - e.Use(middleware.RequestID()) + e.Use(echomiddleware.RequestID()) // Trace request through entire system // Link frontend error to backend logs // This adds a header : X-Request-ID: abc-123-def-456 // ------------------------------------------------------------------------ // Setting Logger format - e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ + e.Use(echomiddleware.LoggerWithConfig(echomiddleware.LoggerConfig{ Format: `{"time":"${time_rfc3339}","method":"${method}","uri":"${uri}",` + `"status":${status},"latency_ms":${latency_ms},"error":"${error}"}` + "\n", Output: log.Logger, @@ -82,36 +173,11 @@ func main() { // } // ----------------------------------------------------------------------- // Setting CORS (Cross-Origin Resource Sharing) middleware - e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ - AllowOrigins: []string{ - "http://localhost:5173", // (Development) Svelte dev server : this is the port suggested to be used with front-end - "http://localhost:3000", // (Developement) Alternative dev port : this is an alternative port kept aside - "https://app.aurganize.com", // (Production) Production frontend : we can use this subdomain itself for front-end - }, - AllowMethods: []string{ - http.MethodGet, - http.MethodPost, - http.MethodPut, - http.MethodDelete, - http.MethodPatch, - http.MethodOptions, - }, - AllowHeaders: []string{ - "Origin", - "Content-Type", - "Accept", - "Authorization", - "X-Request-ID", - }, - - AllowCredentials: true, // Not sure about why are using this option - - MaxAge: 3600, // 1 hour in seconds - })) + e.Use(middleware.NewCORSMiddleware()) // Prevents malicious sites from calling your API // ---------------------------------------------------------------------- // Setting Security Headers middleware - e.Use(middleware.SecureWithConfig(middleware.SecureConfig{ + e.Use(echomiddleware.SecureWithConfig(echomiddleware.SecureConfig{ XSSProtection: "1; mode=block", ContentTypeNosniff: "nosniff", XFrameOptions: "SAMEORIGIN", @@ -144,7 +210,7 @@ func main() { // - Additional layer of XSS protection // ------------------------------------------------------------------- // Setting Gzip compression middleware - e.Use(middleware.Gzip()) + e.Use(echomiddleware.Gzip()) // TODO : Rate Limiting middleware (planning to use redis for custom rate limiter) @@ -165,6 +231,15 @@ func main() { }) }) + routes.SetUpRoutes( + e, + authHandler, + userHandler, + tenantHandler, + authMiddleware, + globalrateLimitterMiddleware, + ) + log.Info().Msg("Routes configured") // ========================================================================= // Start Server in a new thread @@ -231,6 +306,18 @@ func main() { } +type customValidator struct { + validator *validator.Validate +} + +func (cv *customValidator) Validate(i interface{}) error { + if err := cv.validator.Struct(i); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + + } + return nil +} + // HealthCheck Handler function // This endpoint is to be used by: // - Load balancers to determine if instance is healthy diff --git a/backend/go.mod b/backend/go.mod index a2881b4..a0f88fc 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,48 +2,39 @@ 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/lib/pq v1.10.9 + 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/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-playground/validator/v10 v10.28.0 + 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/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/crypto v0.45.0 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/text v0.31.0 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..151592d 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,50 +1,46 @@ -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/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 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 +48,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..477c451 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..4c70c55 --- /dev/null +++ b/backend/internal/handlers/auth_handler.go @@ -0,0 +1,952 @@ +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" + "github.com/rs/zerolog/log" +) + +// 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 { + log.Info(). + Str("handler", "auth"). + Str("cookie_domain", cfg.Cookie.CookieDomain). + Bool("cookie_secure", cfg.Cookie.CookieSecure). + Dur("access_expiry", cfg.JWT.AccessExpiry). + Dur("refresh_expiry", cfg.JWT.RefreshExpiry). + Msg("auth handler initialized") + 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 { + log.Info(). + Str("handler", "auth"). + Str("action", "login_attempt"). + Str("ip", c.RealIP()). + Str("user_agent", c.Request().UserAgent()). + Msg("login attempt started") + + // 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 { + log.Warn(). + Str("handler", "auth"). + Str("action", "login_bind_failed"). + Str("ip", c.RealIP()). + Err(err). + Msg("failed to bind login request") + // 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 { + log.Warn(). + Str("handler", "auth"). + Str("action", "login_validation_failed"). + Str("email", req.Email). + Str("validation_error", err.Error()). + Str("ip", c.RealIP()). + Msg("login request validation failed") + // 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() + log.Info(). + Str("handler", "auth"). + Str("action", "authenticate_attempt"). + Str("email", req.Email). + Str("ip", c.RealIP()). + Msg("attempting to authenticate user") + + // 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 { + log.Warn(). + Str("handler", "auth"). + Str("action", "authentication_failed"). + Str("email", req.Email). + Str("ip", c.RealIP()). + Err(err). + Msg("user authentication failed") + // 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" { + log.Warn(). + Str("handler", "auth"). + Str("action", "inactive_account_login"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("status", user.Status). + Str("ip", c.RealIP()). + Msg("login attempt on inactive account") + // 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") + } + log.Info(). + Str("handler", "auth"). + Str("action", "authentication_success"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Msg("user authenticated successfully, generating tokens") + + // 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 { + log.Error(). + Str("handler", "auth"). + Str("action", "access_token_generation_failed"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Err(err). + Msg("failed to generate access token") + // 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() + log.Info(). + Str("handler", "auth"). + Str("action", "generating_refresh_token"). + Str("user_id", user.ID.String()). + Str("ip", ipAddress). + Str("user_agent", userAgent). + Msg("generating refresh token and creating session") + + // 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 { + log.Error(). + Str("handler", "auth"). + Str("action", "refresh_token_generation_failed"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Err(err). + Msg("failed to generate refresh token") + + // 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) + log.Debug(). + Str("handler", "auth"). + Str("action", "updating_last_login"). + Str("user_id", user.ID.String()). + Str("ip", ipAddress). + Msg("updating user last login timestamp") + // 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) + if err = h.userService.UpdateLastLogin(ctx, user.ID, &ipAddress); err != nil { + log.Warn(). + Str("handler", "auth"). + Str("action", "last_login_update_failed"). + Str("user_id", user.ID.String()). + Err(err). + Msg("failed to update last login (non-critical)") + } + log.Info(). + Str("handler", "auth"). + Str("action", "login_success"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Str("ip", ipAddress). + Str("user_agent", userAgent). + Msg("user logged in successfully") + + // 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 { + log.Info(). + Str("handler", "auth"). + Str("action", "refresh_attempt"). + Str("ip", c.RealIP()). + Msg("token refresh attempt started") + // 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 { + log.Warn(). + Str("handler", "auth"). + Str("action", "refresh_missing_token"). + Str("ip", c.RealIP()). + Msg("refresh token cookie not found") + // 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() + + log.Debug(). + Str("handler", "auth"). + Str("action", "validating_refresh_token"). + Str("ip", c.RealIP()). + Msg("validating refresh token") + // 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 { + log.Warn(). + Str("handler", "auth"). + Str("action", "refresh_validation_failed"). + Str("ip", c.RealIP()). + Err(err). + Msg("refresh token validation failed") + // Return 401 with error details + // Could be: "invalid token", "token expired", "token revoked" + return echo.NewHTTPError(http.StatusUnauthorized, err.Error()) + } + log.Debug(). + Str("handler", "auth"). + Str("action", "refresh_fetching_user"). + Str("user_id", claims.UserID.String()). + Str("session_id", claims.SessionID.String()). + Msg("refresh token validated, fetching user data") + // 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 { + log.Warn(). + Str("handler", "auth"). + Str("action", "refresh_user_not_found"). + Str("user_id", claims.UserID.String()). + Str("session_id", claims.SessionID.String()). + Err(err). + Msg("user not found during token refresh") + // 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 { + log.Error(). + Str("handler", "auth"). + Str("action", "refresh_token_generation_failed"). + Str("user_id", user.ID.String()). + Err(err). + Msg("failed to generate new access token during refresh") + // 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) + log.Info(). + Str("handler", "auth"). + Str("action", "refresh_success"). + Str("user_id", user.ID.String()). + Str("session_id", claims.SessionID.String()). + Str("ip", c.RealIP()). + Msg("access token refreshed successfully") + // 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 { + log.Info(). + Str("handler", "auth"). + Str("action", "token_rotation_attempt"). + Str("ip", c.RealIP()). + Msg("token rotation with refresh attempt started") + // 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 + + var tokenSource string + + if req.RefreshToken != "" { + // Token provided in request body (mobile/SPA clients) + refreshToken = req.RefreshToken + tokenSource = "request_body" + } else { + // Try to get token from HTTP-only cookie (browser clients) + cookie, err := c.Cookie("refresh_token") + if err != nil { + log.Warn(). + Str("handler", "auth"). + Str("action", "rotation_missing_token"). + Str("ip", c.RealIP()). + Msg("refresh token not found in body or cookie") + // No token in body or cookie + return echo.NewHTTPError(http.StatusUnauthorized, "missing refresh token") + } + refreshToken = cookie.Value + tokenSource = "cookie" + } + + log.Debug(). + Str("handler", "auth"). + Str("action", "rotation_token_source"). + Str("token_source", tokenSource). + Str("ip", c.RealIP()). + Msg("refresh token source identified") + + // 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() + + log.Info(). + Str("handler", "auth"). + Str("action", "rotating_refresh_token"). + Str("ip", ipAddress). + Str("user_agent", userAgent). + Str("token_source", tokenSource). + Msg("rotating refresh token and creating new session") + // 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) { + log.Warn(). + Str("handler", "auth"). + Str("action", "rotation_expired_token"). + Str("ip", ipAddress). + Str("token_source", tokenSource). + Msg("refresh token expired during rotation") + + // Refresh token has expired (needs re-login) + return echo.NewHTTPError(http.StatusUnauthorized, "refresh token expired") + } + if errors.Is(err, services.ErrRevokedToken) { + log.Warn(). + Str("handler", "auth"). + Str("action", "rotation_revoked_token"). + Str("ip", ipAddress). + Str("token_source", tokenSource). + Msg("revoked refresh token used in rotation attempt") + // Session was revoked (logout, password change, etc.) + return echo.NewHTTPError(http.StatusUnauthorized, "refresh token revoked") + } + if errors.Is(err, services.ErrInvalidToken) { + log.Warn(). + Str("handler", "auth"). + Str("action", "rotation_invalid_token"). + Str("ip", ipAddress). + Str("token_source", tokenSource). + Msg("invalid refresh token in rotation attempt") + // Token signature invalid or malformed + return echo.NewHTTPError(http.StatusUnauthorized, "invalid refresh token") + } + log.Error(). + Str("handler", "auth"). + Str("action", "rotation_failed"). + Str("ip", ipAddress). + Str("token_source", tokenSource). + Err(err). + Msg("token rotation failed with unexpected error") + // 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) + + log.Info(). + Str("handler", "auth"). + Str("action", "rotation_success"). + Str("ip", ipAddress). + Str("user_agent", userAgent). + Str("token_source", tokenSource). + Msg("refresh token rotated successfully, new tokens issued") + + // 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 { + log.Info(). + Str("handler", "auth"). + Str("action", "logout_attempt"). + Str("ip", c.RealIP()). + Msg("user logout attempt") + // Step 1: Attempt to get refresh token from cookie + cookie, err := c.Cookie("refresh_token") + if err != nil { + log.Debug(). + Str("handler", "auth"). + Str("action", "logout_no_token"). + Str("ip", c.RealIP()). + Msg("logout attempt without refresh token cookie") + // 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() + log.Info(). + Str("handler", "auth"). + Str("action", "revoking_refresh_token"). + Str("ip", c.RealIP()). + Msg("revoking refresh token for logout") + + // 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 + if err = h.authService.RevokeRefreshToken(ctx, cookie.Value); err != nil { + log.Warn(). + Str("handler", "auth"). + Str("action", "revocation_failed"). + Str("ip", c.RealIP()). + Err(err). + Msg("failed to revoke refresh token during logout (continuing anyway)") + } + + // 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) + log.Info(). + Str("handler", "auth"). + Str("action", "logout_success"). + Str("ip", c.RealIP()). + Msg("user logged out successfully") + // 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) { + log.Debug(). + Str("handler", "auth"). + Str("action", "set_access_cookie"). + Str("domain", h.config.Cookie.CookieDomain). + Bool("secure", h.config.Cookie.CookieSecure). + Int("max_age", int(h.config.JWT.AccessExpiry.Seconds())). + Msg("setting access token cookie") + + 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) { + log.Debug(). + Str("handler", "auth"). + Str("action", "set_refresh_cookie"). + Str("domain", h.config.Cookie.CookieDomain). + Bool("secure", h.config.Cookie.CookieSecure). + Int("max_age", int(h.config.JWT.RefreshExpiry.Seconds())). + Msg("setting refresh token cookie") + + 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) { + log.Debug(). + Str("handler", "auth"). + Str("action", "clear_auth_cookies"). + Msg("clearing access and refresh token cookies") + + // 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/handlers/tenant_handler.go b/backend/internal/handlers/tenant_handler.go new file mode 100644 index 0000000..22fc31d --- /dev/null +++ b/backend/internal/handlers/tenant_handler.go @@ -0,0 +1,157 @@ +package handlers + +import ( + "net/http" + + "github.com/creativenoz/aurganize-v62/backend/internal/services" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" +) + +type TenantHandler struct { + tenantService *services.TenantService +} + +func NewTenantHanlder(tenantService *services.TenantService) *TenantHandler { + log.Info(). + Str("handler", "tenant"). + Str("component", "handler_init"). + Msg("tenant handler initialized") + return &TenantHandler{ + tenantService: tenantService, + } +} + +func (th *TenantHandler) GetTenant(c echo.Context) error { + rawTenantId := c.Param("id") + log.Info(). + Str("handler", "tenant"). + Str("action", "get_tenant_attempt"). + Str("tenant_id", rawTenantId). + Str("ip", c.RealIP()). + Msg("attempting to get tenant by id") + + tenantId, err := uuid.Parse(rawTenantId) + if err != nil { + log.Warn(). + Str("handler", "tenant"). + Str("action", "invalid_tenant_id_format"). + Str("invalid_id", rawTenantId). + Str("ip", c.RealIP()). + Err(err). + Msg("failed to parse tenant id - invalid uuid format") + return echo.NewHTTPError(http.StatusBadRequest, "invalid tenant id") + } + + ctx := c.Request().Context() + log.Debug(). + Str("handler", "tenant"). + Str("action", "fetching_tenant_from_service"). + Str("tenant_id", tenantId.String()). + Msg("querying tenant service for tenant data") + + tenant, err := th.tenantService.GetByID(ctx, tenantId) + if err != nil { + log.Warn(). + Str("handler", "tenant"). + Str("action", "tenant_not_found"). + Str("tenant_id", tenantId.String()). + Str("ip", c.RealIP()). + Err(err). + Msg("tenant not found in database") + return echo.NewHTTPError(http.StatusNotFound, "tenant not found") + } + log.Debug(). + Str("handler", "tenant"). + Str("action", "checking_tenant_authorization"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Msg("tenant found, verifying user authorization") + + userTenantId, ok := c.Get("tenant_id").(uuid.UUID) + if !ok { + log.Error(). + Str("handler", "tenant"). + Str("action", "missing_user_tenant_context"). + Interface("context_value", c.Get("tenant_id")). + Str("ip", c.RealIP()). + Msg("user tenant id missing or invalid in request context - middleware issue") + return echo.NewHTTPError(http.StatusBadRequest, "invalid user tenant id") + } + log.Debug(). + Str("handler", "tenant"). + Str("action", "tenant_authorization_check"). + Str("requested_tenant_id", tenant.ID.String()). + Str("user_tenant_id", userTenantId.String()). + Bool("match", tenant.ID == userTenantId). + Msg("comparing requested tenant with user's tenant") + + if tenant.ID != userTenantId { + log.Warn(). + Str("handler", "tenant"). + Str("action", "tenant_access_denied"). + Str("requested_tenant_id", tenant.ID.String()). + Str("user_tenant_id", userTenantId.String()). + Str("requested_tenant_name", tenant.Name). + Str("ip", c.RealIP()). + Str("user_agent", c.Request().UserAgent()). + Msg("access denied - user attempted to access different tenant") + return echo.NewHTTPError(http.StatusForbidden, "acces denied") + } + log.Info(). + Str("handler", "tenant"). + Str("action", "get_tenant_success"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("user_tenant_id", userTenantId.String()). + Str("ip", c.RealIP()). + Msg("tenant retrieved successfully") + return c.JSON(http.StatusOK, tenant.ToResponse()) +} + +func (th *TenantHandler) GetMyTenant(c echo.Context) error { + log.Info(). + Str("handler", "tenant"). + Str("action", "get_my_tenant_attempt"). + Str("ip", c.RealIP()). + Msg("user requesting their own tenant information") + tenantID, ok := c.Get("tenant_id").(uuid.UUID) + if !ok { + log.Error(). + Str("handler", "tenant"). + Str("action", "get_my_tenant_missing_context"). + Interface("context_value", c.Get("tenant_id")). + Str("ip", c.RealIP()). + Msg("tenant id missing from authenticated request context - authentication issue") + return echo.NewHTTPError(http.StatusBadRequest, "invalid tenant id") + } + log.Debug(). + Str("handler", "tenant"). + Str("action", "fetching_my_tenant_from_service"). + Str("tenant_id", tenantID.String()). + Msg("querying tenant service for user's tenant") + + ctx := c.Request().Context() + + tenant, err := th.tenantService.GetByID(ctx, tenantID) + if err != nil { + log.Error(). + Str("handler", "tenant"). + Str("action", "my_tenant_not_found"). + Str("tenant_id", tenantID.String()). + Str("ip", c.RealIP()). + Err(err). + Msg("CRITICAL: user's tenant not found in database - data consistency issue") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to retrieve tenant") + } + log.Info(). + Str("handler", "tenant"). + Str("action", "get_my_tenant_success"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("ip", c.RealIP()). + Msg("user's tenant retrieved successfully") + return c.JSON(http.StatusOK, tenant.ToResponse()) + +} diff --git a/backend/internal/handlers/user_handler.go b/backend/internal/handlers/user_handler.go new file mode 100644 index 0000000..a302824 --- /dev/null +++ b/backend/internal/handlers/user_handler.go @@ -0,0 +1,286 @@ +package handlers + +import ( + "net/http" + + "github.com/creativenoz/aurganize-v62/backend/internal/config" + "github.com/creativenoz/aurganize-v62/backend/internal/models" + "github.com/creativenoz/aurganize-v62/backend/internal/services" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" +) + +type UserRegisterHander struct { + config *config.Config + authService *services.AuthService + userService *services.UserService + tenantService *services.TenantService +} + +func NewUserRegisterHandler(config *config.Config, authService *services.AuthService, userService *services.UserService, tenantService *services.TenantService) *UserRegisterHander { + log.Info(). + Str("handler", "user_register"). + Str("component", "handler_init"). + Bool("has_auth_service", authService != nil). + Bool("has_user_service", userService != nil). + Bool("has_tenant_service", tenantService != nil). + Msg("user registration handler initialized") + return &UserRegisterHander{ + config: config, + authService: authService, + userService: userService, + tenantService: tenantService, + } +} + +type RegisterUserRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=8"` + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` + TenantName string `json:"tenant_name" validate:"required"` +} + +type RegisterUserResponse struct { + User *models.UserResponse `json:"user"` + Tenant interface{} `json:"tenant"` + AccessToken string `json:"access_token"` + RefershToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +func (h *UserRegisterHander) Register(c echo.Context) error { + log.Info(). + Str("handler", "user_register"). + Str("action", "registration_attempt"). + Str("ip", c.RealIP()). + Str("user_agent", c.Request().UserAgent()). + Msg("new user registration attempt started") + + var req RegisterUserRequest + if err := c.Bind(&req); err != nil { + log.Warn(). + Str("handler", "user_register"). + Str("action", "registration_bind_failed"). + Str("ip", c.RealIP()). + Err(err). + Msg("failed to bind registration request - malformed json or content-type") + return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + } + + if err := c.Validate(&req); err != nil { + log.Warn(). + Str("handler", "user_register"). + Str("action", "registration_validation_failed"). + Str("email", req.Email). + Str("tenant_name", req.TenantName). + Str("validation_error", err.Error()). + Bool("has_first_name", req.FirstName != nil). + Bool("has_last_name", req.LastName != nil). + Str("ip", c.RealIP()). + Msg("registration validation failed") + + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + log.Info(). + Str("handler", "user_register"). + Str("action", "checking_tenant_availability"). + Str("tenant_name", req.TenantName). + Str("email", req.Email). + Msg("validated registration request, checking tenant name availability") + + ctx := c.Request().Context() + + tenantExists, err := h.tenantService.SlugExists(ctx, req.TenantName) + if err != nil { + log.Error(). + Str("handler", "user_register"). + Str("action", "tenant_check_failed"). + Str("tenant_name", req.TenantName). + Str("email", req.Email). + Err(err). + Msg("failed to check tenant name availability - database or service error") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to check tenant") + } + + if tenantExists { + log.Warn(). + Str("handler", "user_register"). + Str("action", "tenant_name_conflict"). + Str("requested_tenant_name", req.TenantName). + Str("email", req.Email). + Str("ip", c.RealIP()). + Msg("registration failed - organization name already taken") + return echo.NewHTTPError(http.StatusConflict, "organization name already taken") + } + log.Info(). + Str("handler", "user_register"). + Str("action", "creating_tenant_and_user"). + Str("tenant_name", req.TenantName). + Str("email", req.Email). + Bool("has_first_name", req.FirstName != nil). + Bool("has_last_name", req.LastName != nil). + Msg("tenant available, creating organization and user account") + + tenant, user, err := h.tenantService.CreateWithUser(ctx, &models.CreateTenantWithUserInput{ + TenantName: req.TenantName, + Email: &req.Email, + Password: &req.Password, + FirstName: req.FirstName, + LastName: req.LastName, + }) + + if err != nil { + if err == services.ErrEmailAlreadyExists { + log.Warn(). + Str("handler", "user_register"). + Str("action", "email_already_exists"). + Str("email", req.Email). + Str("tenant_name", req.TenantName). + Str("ip", c.RealIP()). + Msg("registration failed - email already registered") + return echo.NewHTTPError(http.StatusConflict, "email already registered") + } + if err == services.ErrWeakPassword { + log.Warn(). + Str("handler", "user_register"). + Str("action", "weak_password_rejected"). + Str("email", req.Email). + Str("tenant_name", req.TenantName). + Int("password_length", len(req.Password)). + Msg("registration failed - password too weak") + return echo.NewHTTPError(http.StatusBadRequest, "password is too weak") + } + log.Error(). + Str("handler", "user_register"). + Str("action", "registration_failed_unexpected"). + Str("email", req.Email). + Str("tenant_name", req.TenantName). + Err(err). + Msg("registration failed - unexpected error during account creation") + return echo.NewHTTPError(http.StatusInternalServerError, "registration failed") + } + log.Info(). + Str("handler", "user_register"). + Str("action", "account_created_successfully"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("tenant_name", tenant.Name). + Str("email", user.Email). + Str("user_role", user.Role). + Msg("account created successfully, generating authentication tokens") + // Generate Tokens + accessToken, err := h.authService.GenerateAccessToken(user) + if err != nil { + log.Error(). + Str("handler", "user_register"). + Str("action", "access_token_generation_failed"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("email", user.Email). + Err(err). + Msg("CRITICAL: account created but failed to generate access token") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate access token") + } + + userAgent := c.Request().UserAgent() + ipAddress := c.RealIP() + + log.Debug(). + Str("handler", "user_register"). + Str("action", "generating_refresh_token"). + Str("user_id", user.ID.String()). + Str("ip", ipAddress). + Str("user_agent", userAgent). + Msg("generating refresh token and creating first session") + + refreshToken, _, err := h.authService.GenerateRefreshToken(ctx, user, &userAgent, &ipAddress) + if err != nil { + log.Error(). + Str("handler", "user_register"). + Str("action", "refresh_token_generation_failed"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("email", user.Email). + Err(err). + Msg("CRITICAL: account created but failed to generate refresh token") + + return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate refresh token") + } + log.Debug(). + Str("handler", "user_register"). + Str("action", "setting_auth_cookies"). + Str("user_id", user.ID.String()). + Str("cookie_domain", h.config.Cookie.CookieDomain). + Bool("cookie_secure", h.config.Cookie.CookieSecure). + Msg("setting access and refresh token cookies") + + h.setAccessTokenCookie(c, accessToken) + h.setRefreshTokenCookie(c, refreshToken) + log.Info(). + Str("handler", "user_register"). + Str("action", "registration_success"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("email", user.Email). + Str("tenant_name", tenant.Name). + Str("user_role", user.Role). + Str("ip", ipAddress). + Str("user_agent", userAgent). + Bool("has_full_name", req.FirstName != nil && req.LastName != nil). + Msg("user registration completed successfully") + + return c.JSON( + http.StatusCreated, RegisterUserResponse{ + User: user.ToResponse(), + Tenant: tenant.ToResponse(), + AccessToken: accessToken, + RefershToken: refreshToken, + ExpiresIn: int(h.config.JWT.AccessExpiry.Seconds()), + }, + ) + +} + +func (h *UserRegisterHander) 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) +} + +func (h *UserRegisterHander) 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) +} + +func (h *UserRegisterHander) 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 + } +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..26531f4 --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -0,0 +1,454 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/creativenoz/aurganize-v62/backend/internal/services" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" +) + +// 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 { + log.Info(). + Str("middleware", "auth"). + Str("component", "middleware_init"). + Bool("has_auth_service", authService != nil). + Msg("authentication middleware initialized") + 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 { + log.Debug(). + Str("middleware", "auth"). + Str("action", "authenticate_check_started"). + Str("path", c.Request().URL.Path). + Str("method", c.Request().Method). + Str("ip", c.RealIP()). + Str("user_agent", c.Request().UserAgent()). + Msg("checking authentication for protected route") + // 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 + var tokenSource string + if err == nil { + tokenSource = "cookie" + // Cookie found - use its value + // This path is taken by browser-based clients + tokenString = token.Value + log.Debug(). + Str("middleware", "auth"). + Str("action", "token_found_in_cookie"). + Str("path", c.Request().URL.Path). + Msg("access token found in cookie") + } else { + log.Debug(). + Str("middleware", "auth"). + Str("action", "no_cookie_checking_header"). + Str("path", c.Request().URL.Path). + Msg("no cookie found, checking authorization header") + // Step 2: Cookie not found, try Authorization header (mobile/API clients) + // Expected format: "Authorization: Bearer " + authHeader := c.Request().Header.Get("Authorization") + if authHeader == "" { + log.Warn(). + Str("middleware", "auth"). + Str("action", "missing_authentication"). + Str("path", c.Request().URL.Path). + Str("method", c.Request().Method). + Str("ip", c.RealIP()). + Str("user_agent", c.Request().UserAgent()). + Msg("authentication required but no token provided") + + // 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" { + log.Warn(). + Str("middleware", "auth"). + Str("action", "invalid_auth_header_format"). + Str("invalid_header", authHeader). + Int("header_parts_count", len(parts)). + Str("path", c.Request().URL.Path). + Str("ip", c.RealIP()). + Msg("authorization header present but format is invalid") + // 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] + tokenSource = "header" + + log.Debug(). + Str("middleware", "auth"). + Str("action", "token_found_in_header"). + Str("path", c.Request().URL.Path). + Msg("access token found in authorization header") + } + + log.Debug(). + Str("middleware", "auth"). + Str("action", "validating_access_token"). + Str("token_source", tokenSource). + Str("path", c.Request().URL.Path). + Msg("validating access token") + // 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 { + log.Warn(). + Str("middleware", "auth"). + Str("action", "expired_token"). + Str("token_source", tokenSource). + Str("path", c.Request().URL.Path). + Str("ip", c.RealIP()). + Str("user_agent", c.Request().UserAgent()). + Msg("access token has expired") + + // 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") + } + log.Warn(). + Str("middleware", "auth"). + Str("action", "invalid_token"). + Err(err). + Str("token_source", tokenSource). + Str("path", c.Request().URL.Path). + Str("ip", c.RealIP()). + Str("user_agent", c.Request().UserAgent()). + Msg("token validation failed - invalid or tampered token") + // Other errors: invalid signature, wrong type, malformed, etc. + // Return generic error to avoid leaking information + return echo.NewHTTPError(http.StatusUnauthorized, "invalid token") + } + + log.Info(). + Str("middleware", "auth"). + Str("action", "authentication_success"). + Str("user_id", claims.UserID.String()). + Str("tenant_id", claims.TenantID.String()). + Str("email", claims.Email). + Str("role", claims.Role). + Str("token_source", tokenSource). + Str("path", c.Request().URL.Path). + Str("method", c.Request().Method). + Str("ip", c.RealIP()). + Msg("user authenticated successfully") + // 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 { + log.Debug(). + Str("middleware", "auth"). + Str("action", "optional_auth_check_started"). + Str("path", c.Request().URL.Path). + Str("method", c.Request().Method). + Str("ip", c.RealIP()). + Msg("checking optional authentication") + // 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 { + log.Debug(). + Str("middleware", "auth"). + Str("action", "optional_auth_anonymous"). + Str("path", c.Request().URL.Path). + Str("ip", c.RealIP()). + Str("user_agent", c.Request().UserAgent()). + Msg("no authentication cookie - proceeding as anonymous user") + // 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) + } + log.Debug(). + Str("middleware", "auth"). + Str("action", "optional_auth_validating_token"). + Str("path", c.Request().URL.Path). + Msg("cookie found in optional auth, validating token") + // 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 { + log.Debug(). + Str("middleware", "auth"). + Str("action", "optional_auth_token_invalid"). + Err(err). + Str("path", c.Request().URL.Path). + Str("ip", c.RealIP()). + Msg("token validation failed in optional auth - proceeding as anonymous") + + // 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) + } + log.Info(). + Str("middleware", "auth"). + Str("action", "optional_auth_authenticated"). + Str("user_id", claims.UserID.String()). + Str("tenant_id", claims.TenantID.String()). + Str("email", claims.Email). + Str("role", claims.Role). + Str("path", c.Request().URL.Path). + Str("ip", c.RealIP()). + Msg("authenticated user accessing optionally-protected route") + // 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..1be4b9f --- /dev/null +++ b/backend/internal/middleware/cors.go @@ -0,0 +1,102 @@ +package middleware + +import ( + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/rs/zerolog/log" +) + +// 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 { + log.Info(). + Str("middleware", "cors"). + Str("component", "middleware_init"). + Strs("allowed_origins", []string{"http://localhost:5173", "http://localhost:3000"}). + Bool("allow_credentials", true). + Int("max_age", 3600). + Str("allowed_methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS"). + Str("allowed_headers", "Origin, Content-Type, Accept, Authorization"). + Msg("CORS middleware initialized with security configuration") + + log.Warn(). + Str("middleware", "cors"). + Str("action", "development_origins_configured"). + Msg("CORS configured with localhost origins - ensure this is updated for production") + 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..3c28fbd --- /dev/null +++ b/backend/internal/middleware/rate_limiter.go @@ -0,0 +1,414 @@ +package middleware + +import ( + "sync" + "time" + + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" +) + +// 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 { + log.Info(). + Str("middleware", "rate_limiter"). + Str("component", "middleware_init"). + Int("limit", limit). + Dur("window", window). + Float64("requests_per_second", float64(limit)/window.Seconds()). + Msg("rate limiter initialized with security limits") + if limit <= 0 { + log.Error(). + Str("middleware", "rate_limiter"). + Str("action", "invalid_limit_config"). + Int("limit", limit). + Msg("CRITICAL: rate limiter configured with zero or negative limit - all requests will be blocked!") + } else if limit > 1000 { + log.Warn(). + Str("middleware", "rate_limiter"). + Str("action", "very_high_limit"). + Int("limit", limit). + Dur("window", window). + Msg("rate limiter configured with very high limit - may not prevent abuse effectively") + } else if limit < 3 && window < time.Minute { + log.Warn(). + Str("middleware", "rate_limiter"). + Str("action", "very_strict_limit"). + Int("limit", limit). + Dur("window", window). + Msg("rate limiter configured with very strict limit - may impact legitimate users") + } + + 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) + +// 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() + log.Debug(). + Str("middleware", "rate_limiter"). + Str("action", "rate_check_started"). + Str("ip", ip). + Str("path", c.Request().URL.Path). + Str("method", c.Request().Method). + Msg("checking rate limit for request") + // 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{} + + expiredCount := 0 + + // 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) + } else { + expiredCount++ + } + // Old requests are automatically garbage collected + // This keeps memory usage bounded + } + + if expiredCount > 0 { + log.Debug(). + Str("middleware", "rate_limiter"). + Str("action", "expired_requests_cleaned"). + Str("ip", ip). + Int("expired_count", expiredCount). + Int("remaining_count", len(validRequests)). + Msg("cleaned up expired requests from sliding window") + } + log.Debug(). + Str("middleware", "rate_limiter"). + Str("action", "rate_limit_decision"). + Str("ip", ip). + Int("current_count", len(validRequests)). + Int("limit", rl.limit). + Dur("window", rl.window). + Bool("will_allow", len(validRequests) < rl.limit). + Msg("evaluating rate limit threshold") + + // 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 { + log.Warn(). + Str("middleware", "rate_limiter"). + Str("action", "rate_limit_exceeded"). + Str("ip", ip). + Str("path", c.Request().URL.Path). + Str("method", c.Request().Method). + Int("current_count", len(validRequests)). + Int("limit", rl.limit). + Dur("window", rl.window). + Str("user_agent", c.Request().UserAgent()). + Msg("rate limit exceeded - request blocked") + + path := c.Request().URL.Path + if path == "/auth/login" || path == "/api/auth/login" { + log.Warn(). + Str("middleware", "rate_limiter"). + Str("action", "login_rate_limit_hit"). + Str("ip", ip). + Int("attempt_count", len(validRequests)). + Msg("SECURITY: multiple failed login attempts - possible brute force attack") + } else if path == "/auth/register" || path == "/api/auth/register" { + log.Warn(). + Str("middleware", "rate_limiter"). + Str("action", "register_rate_limit_hit"). + Str("ip", ip). + Msg("SECURITY: multiple registration attempts - possible spam or abuse") + } + // 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 + uniqueIpCount := len(rl.requests) + if uniqueIpCount%100 == 0 { + log.Info(). + Str("middleware", "rate_limiter"). + Str("action", "unique_ip_milestone"). + Int("unique_ip_count", uniqueIpCount). + Int("limit", rl.limit). + Dur("window", rl.window). + Msg("rate limiter tracking milestone reached") + } + + log.Debug(). + Str("middleware", "rate_limiter"). + Str("action", "rate_limit_allowed"). + Str("ip", ip). + Str("path", c.Request().URL.Path). + Int("current_count", len(validRequests)). + Int("remaining", rl.limit-len(validRequests)). + Msg("request allowed through rate limiter") + // 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/tenants.go b/backend/internal/models/tenants.go new file mode 100644 index 0000000..6be2c3d --- /dev/null +++ b/backend/internal/models/tenants.go @@ -0,0 +1,82 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type Tenant struct { + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Slug string `json:"slug" db:"slug"` + Email *string `json:"email" db:"email"` + Phone *string `json:"phone" db:"phone"` + Website *string `json:"website" db:"website"` + AddressLine1 *string `json:"address_line1" db:"address_line1"` + AddressLine2 *string `json:"address_line2" db:"address_line2"` + City *string `json:"city" db:"city"` + State *string `json:"state" db:"state"` + Country *string `json:"country" db:"country"` + PostalCode *string `json:"postal_code" db:"postal_code"` + Timezone string `json:"timezone" db:"timezone"` + Currency string `json:"currency" db:"currency"` + Locale string `json:"locale" db:"locale"` + SubscriptionStatus string `json:"subscription_status" db:"subscription_status"` + SubscriptionPlan string `json:"subscription_plan" db:"subscription_plan"` + SubscriptionExpiresAt *time.Time `json:"subscription_expires_at" db:"subscription_expires_at"` + TrialEnds *time.Time `json:"trial_ends_at" db:"trial_ends_at"` + MaxUsers int `json:"max_users" db:"max_users"` + MaxContracts int `json:"max_contracts" db:"max_contracts"` + MaxStorageMB int `json:"max_storage_mb" db:"max_storage_mb"` + Status string `json:"status" db:"status"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt *time.Time `json:"updated_at" db:"updated_at"` + DeletedAt *time.Time `json:"deleted_at" db:"deleted_at"` +} + +type CreateTenantInput struct { + Name string + Email *string + Timezone string + Currency string + Locale string +} + +type CreateTenantWithUserInput struct { + TenantName string + Email *string + Password *string + FirstName *string + LastName *string +} + +type TenantResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Email *string `json:"email"` + Timezone string `json:"timezone"` + Currency string `json:"currency"` + SubscriptionStatus string `json:"subscription_status"` + SubscriptionPlan string `json:"subscription_plan"` + TrialEnds *time.Time `json:"trial_ends_at"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +func (t *Tenant) ToResponse() *TenantResponse { + return &TenantResponse{ + ID: t.ID, + Name: t.Name, + Slug: t.Slug, + Email: t.Email, + Timezone: t.Timezone, + Currency: t.Currency, + SubscriptionStatus: t.SubscriptionStatus, + SubscriptionPlan: t.SubscriptionPlan, + TrialEnds: t.TrialEnds, + Status: t.Status, + CreatedAt: t.CreatedAt, + } +} diff --git a/backend/internal/models/users.go b/backend/internal/models/users.go new file mode 100644 index 0000000..ff7442e --- /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" db:"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..b012fb6 --- /dev/null +++ b/backend/internal/repositories/session_repository.go @@ -0,0 +1,805 @@ +package repositories + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/base64" + "time" + + "github.com/creativenoz/aurganize-v62/backend/internal/models" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/rs/zerolog/log" +) + +// 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 { + log.Info(). + Str("repository", "session"). + Str("component", "repository_init"). + Bool("has_db_connection", db != nil). + Msg("session repository initialized") + 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 + + log.Info(). + Str("repository", "session"). + Str("action", "create_session_started"). + Str("user_id", input.UserID.String()). + Str("device_type", input.DeviceType). + Str("ip_address", *input.IPAddress). + Bool("has_device_name", input.DeviceName != nil). + Msg("creating new session record") + + 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, + ) + + if err != nil { + log.Error(). + Str("repository", "session"). + Str("action", "create_session_failed"). + Str("user_id", input.UserID.String()). + Str("device_type", input.DeviceType). + Err(err). + Msg("failed to create session record in database") + return nil, err + } + log.Info(). + Str("repository", "session"). + Str("action", "create_session_success"). + Str("session_id", session.ID.String()). + Str("user_id", session.UserID.String()). + Str("device_type", session.DeviceType). + Str("ip_address", *session.IPAddress). + Time("expires_at", session.ExpiresAt). + Msg("session created successfully") + 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) { + log.Debug(). + Str("repository", "session"). + Str("action", "find_session_by_token_started"). + Str("session_id", sessionId.String()). + Msg("looking up session by session id and refresh token") + 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 { + log.Warn(). + Str("repository", "session"). + Str("action", "session_not_found"). + Str("session_id", sessionId.String()). + Msg("session not found or invalid - may be expired, revoked, or token mismatch") + return nil, nil // Return nil session and nil error + } + if err != nil { + log.Error(). + Str("repository", "session"). + Str("action", "find_session_error"). + Str("session_id", sessionId.String()). + Err(err). + Msg("database error while looking up session") + return nil, err + } + + log.Debug(). + Str("repository", "session"). + Str("action", "session_found_valid"). + Str("session_id", session.ID.String()). + Str("user_id", session.UserID.String()). + Str("device_type", session.DeviceType). + Time("last_used_at", session.LastUsedAt). + Msg("session found and validated successfully") + 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) { + log.Debug(). + Str("repository", "session"). + Str("action", "find_session_by_id_started"). + Str("session_id", id.String()). + Msg("looking up session by id") + + 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 { + log.Debug(). + Str("repository", "session"). + Str("action", "session_not_found_by_id"). + Str("session_id", id.String()). + Msg("session not found by id") + return nil, nil // Not found is not an error + } + + if err != nil { + log.Error(). + Str("repository", "session"). + Str("action", "find_session_by_id_error"). + Str("session_id", id.String()). + Err(err). + Msg("database error while looking up session by id") + } + log.Debug(). + Str("repository", "session"). + Str("action", "session_found_by_id"). + Str("session_id", session.ID.String()). + Str("user_id", session.UserID.String()). + Msg("session found by id") + return session, nil +} + +// 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 { + + log.Debug(). + Str("repository", "session"). + Str("action", "update_last_used_started"). + Str("session_id", id.String()). + Msg("updating session last_used_at timestamp") + // 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 + result, err := r.db.ExecContext( + ctx, + query, + id, // $1 - Session ID + ) + if err != nil { + log.Warn(). + Str("repository", "session"). + Str("action", "update_last_used_failed"). + Str("session_id", id.String()). + Err(err). + Msg("failed to update session last_used_at timestamp") + } + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + log.Warn(). + Str("repository", "session"). + Str("action", "update_last_used_no_rows"). + Str("session_id", id.String()). + Msg("update succeeded but no session was modified - session may not exist") + } else { + log.Debug(). + Str("repository", "session"). + Str("action", "update_last_used_success"). + Str("session_id", id.String()). + Msg("session last_used_at updated successfully") + } + + return nil +} + +// 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 { + log.Info(). + Str("repository", "session"). + Str("action", "revoke_session_started"). + Str("revoke_reason", reason). + Msg("revoking session by token") + + // 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) + results, err := r.db.ExecContext( + ctx, + query, + tokenHash, // $1 - Token hash to find session + reason, // $2 - Why session is being revoked + ) + if err != nil { + log.Error(). + Str("repository", "session"). + Str("action", "revoke_session_failed"). + Str("revoke_reason", reason). + Err(err). + Msg("failed to revoke session") + return err + } + + rowsAffected, _ := results.RowsAffected() + if rowsAffected == 0 { + log.Warn(). + Str("repository", "session"). + Str("action", "revoke_no_session_found"). + Str("revoke_reason", reason). + Msg("revocation succeeded but no session was modified - token may not exist or already revoked") + } else { + log.Info(). + Str("repository", "session"). + Str("action", "revoke_session_success"). + Str("revoke_reason", reason). + Int64("rows_affected", rowsAffected). + Msg("session revoked successfully") + } + + return nil +} + +// 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 { + log.Info(). + Str("repository", "session"). + Str("action", "revoke_all_user_sessions_started"). + Str("user_id", userID.String()). + Str("revoke_reason", reason). + Msg("revoking all sessions for user") + // 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 + result, err := r.db.ExecContext( + ctx, + query, + userID, // $1 - User whose sessions to revoke + reason, // $2 - Reason for revocation + ) + + if err != nil { + log.Error(). + Str("repository", "session"). + Str("action", "revoke_all_user_sessions_failed"). + Str("user_id", userID.String()). + Str("revoke_reason", reason). + Err(err). + Msg("CRITICAL: failed to revoke all user sessions") + return err + } + rowsAffected, _ := result.RowsAffected() + log.Info(). + Str("repository", "session"). + Str("action", "revoke_all_user_sessions_success"). + Str("user_id", userID.String()). + Int64("sessions_revoked", rowsAffected). + Str("revoke_reason", reason). + Msg("all user sessions revoked successfully") + + return nil +} + +// 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) { + log.Info(). + Str("repository", "session"). + Str("action", "delete_expired_sessions_started"). + Msg("starting cleanup of expired and old revoked sessions") + + // 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 { + log.Error(). + Str("repository", "session"). + Str("action", "delete_expired_sessions_failed"). + Err(err). + Msg("failed to delete expired sessions") + return 0, err // Return 0 and error if delete fails + } + rowsAffected, _ := result.RowsAffected() + log.Info(). + Str("repository", "session"). + Str("action", "delete_expired_sessions_success"). + Int64("sessions_deleted", rowsAffected). + Msg("expired sessions cleanup completed") + + // Extract number of rows deleted + // This is useful for logging: "Deleted 1,234 expired sessions" + return rowsAffected, nil +} + +// 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) { + log.Debug(). + Str("repository", "session"). + Str("action", "list_user_sessions_started"). + Str("user_id", userId.String()). + Msg("listing all sessions for user") + + // 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 + ) + if err != nil { + log.Error(). + Str("repository", "session"). + Str("action", "list_user_sessions_failed"). + Str("user_id", userId.String()). + Err(err). + Msg("failed to list user sessions") + return nil, err + } + activeCount := 0 + for _, sesion := range sessions { + if !sesion.IsRevoked && sesion.ExpiresAt.After(time.Now()) { + activeCount++ + } + } + + log.Info(). + Str("repository", "session"). + Str("action", "list_user_sessions_success"). + Str("user_id", userId.String()). + Int("total_sessions", len(sessions)). + Int("active_sessions", activeCount). + Msg("user sessions listed successfully") + + return sessions, nil +} + +// 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/tenant_repository.go b/backend/internal/repositories/tenant_repository.go new file mode 100644 index 0000000..8900d10 --- /dev/null +++ b/backend/internal/repositories/tenant_repository.go @@ -0,0 +1,392 @@ +package repositories + +import ( + "context" + "database/sql" + "time" + + "github.com/creativenoz/aurganize-v62/backend/internal/models" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/rs/zerolog/log" +) + +type TenantRepository struct { + db *sqlx.DB +} + +func NewTenantRepository(db *sqlx.DB) *TenantRepository { + log.Info(). + Str("repository", "tenant"). + Str("component", "repository_init"). + Bool("has_db_connection", db != nil). + Msg("tenant repository initialized") + return &TenantRepository{ + db: db, + } +} + +type Execer interface { + GetContext(ctx context.Context, des interface{}, query string, args ...interface{}) error + ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) + SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error + QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row +} + +func (tr *TenantRepository) CreateTx(ctx context.Context, tx Execer, input *models.CreateTenantInput, slug string) (*models.Tenant, error) { + log.Info(). + Str("repository", "tenant"). + Str("action", "create_tenant_tx_started"). + Str("tenant_name", input.Name). + Str("slug", slug). + Str("email", *input.Email). + Str("timezone", input.Timezone). + Str("subscription_plan", "basic"). + Str("subscription_status", "trial"). + Bool("in_transaction", true). + Msg("creating tenant within transaction") + tenant := &models.Tenant{} + // trailEndsAt := time.Now().Add(14 * 24 * time.Hour) + loc, err := time.LoadLocation(input.Timezone) + if err != nil { + log.Warn(). + Str("repository", "tenant"). + Str("action", "invalid_timezone_fallback"). + Str("invalid_timezone", input.Timezone). + Str("fallback_timezone", "UTC"). + Str("tenant_name", input.Name). + Msg("invalid timezone provided, falling back to UTC") + + loc = time.UTC + } + now := time.Now().In(loc) + trialEndsAt := now.Add(14 * 24 * time.Hour) + + log.Debug(). + Str("repository", "tenant"). + Str("action", "trial_period_calculated"). + Int("trial_duration_days", 14). + Time("trial_ends_at", trialEndsAt). + Str("tenant_name", input.Name). + Msg("trial period configured for new tenant") + + query := ` + INSERT INTO tenants ( + name, + slug, + email, + timezone, + currency, + locale, + subscription_status, + subscription_plan, + trial_ends_at, + status + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id, name, slug, email, phone, website, + address_line1, address_line2, city, state, country, postal_code, + timezone, currency, locale, subscription_status, subscription_plan, + subscription_expires_at, trial_ends_at, max_users, max_contracts, + max_storage_mb, status, created_at, updated_at, deleted_at + ` + err = tx.GetContext( + ctx, + tenant, + query, + input.Name, + slug, + input.Email, + input.Timezone, + input.Currency, + input.Locale, + "trial", + "basic", + trialEndsAt, + "active", + ) + + if err != nil { + log.Error(). + Str("repository", "tenant"). + Str("action", "create_tenant_tx_failed"). + Str("tenant_name", input.Name). + Str("slug", slug). + Str("email", *input.Email). + Bool("in_transaction", true). + Err(err). + Msg("CRITICAL: failed to create tenant in transaction - registration will fail") + return nil, err + } + log.Info(). + Str("repository", "tenant"). + Str("action", "create_tenant_tx_success"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("slug", tenant.Slug). + Str("email", *tenant.Email). + Str("subscription_status", tenant.SubscriptionStatus). + Str("subscription_plan", tenant.SubscriptionPlan). + Time("trial_ends_at", *tenant.TrialEnds). + Str("currency", tenant.Currency). + Str("timezone", tenant.Timezone). + Bool("in_transaction", true). + Msg("tenant created successfully in transaction") + + return tenant, nil +} + +// Create creates a new tenant +func (tr *TenantRepository) Create(ctx context.Context, input *models.CreateTenantInput, slug string) (*models.Tenant, error) { + log.Info(). + Str("repository", "tenant"). + Str("action", "create_tenant_started"). + Str("tenant_name", input.Name). + Str("slug", slug). + Str("email", *input.Email). + Str("timezone", input.Timezone). + Bool("in_transaction", false). + Msg("creating tenant (standalone, not in transaction)") + + tenant := &models.Tenant{} + + // trailEndsAt := time.Now().Add(14 * 24 * time.Hour) + + loc, err := time.LoadLocation(input.Timezone) + if err != nil { + log.Warn(). + Str("repository", "tenant"). + Str("action", "invalid_timezone_fallback"). + Str("invalid_timezone", input.Timezone). + Str("fallback_timezone", "UTC"). + Str("tenant_name", input.Name). + Msg("invalid timezone provided, falling back to UTC") + loc = time.UTC + } + now := time.Now().In(loc) + trialEndsAt := now.Add(14 * 24 * time.Hour) + + log.Debug(). + Str("repository", "tenant"). + Str("action", "trial_period_calculated"). + Int("trial_duration_days", 14). + Time("trial_ends_at", trialEndsAt). + Str("tenant_name", input.Name). + Msg("trial period configured for new tenant") + + query := ` + INSERT INTO tenants ( + name, + slug, + email, + timezone, + currency, + locale, + subscription_status, + subscription_plan, + trial_ends_at, + status + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id, name, slug, email, phone, website, + address_line1, address_line2, city, state, country, postal_code, + timezone, currency, locale, subscription_status, subscription_plan, + subscription_expires_at, trial_ends_at, max_users, max_contracts, + max_storage_mb, status, created_at, updated_at, deleted_at + ` + + err = tr.db.GetContext( + ctx, + tenant, + query, + input.Name, + slug, + input.Email, + input.Timezone, + input.Currency, + input.Locale, + "trial", + "basic", + trialEndsAt, + "active", + ) + + if err != nil { + log.Error(). + Str("repository", "tenant"). + Str("action", "create_tenant_failed"). + Str("tenant_name", input.Name). + Str("slug", slug). + Err(err). + Msg("failed to create tenant") + return nil, err + } + log.Info(). + Str("repository", "tenant"). + Str("action", "create_tenant_success"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("slug", tenant.Slug). + Str("subscription_status", tenant.SubscriptionStatus). + Time("trial_ends_at", *tenant.TrialEnds). + Bool("in_transaction", false). + Msg("tenant created successfully") + return tenant, nil +} + +func (tr *TenantRepository) FindByID(ctx context.Context, tenantId uuid.UUID) (*models.Tenant, error) { + log.Debug(). + Str("repository", "tenant"). + Str("action", "find_tenant_by_id_started"). + Str("tenant_id", tenantId.String()). + Msg("looking up tenant by id") + + tenant := &models.Tenant{} + + query := ` + SELECT id, name, slug, email, phone, website, + address_line1, address_line2, city, state, country, postal_code, + timezone, currency, locale, subscription_status, subscription_plan, + subscription_expires_at, trial_ends_at, max_users, max_contracts, + max_storage_mb, status, created_at, updated_at, deleted_at + FROM tenants + WHERE id = $1 + AND deleted_at IS NULL + ` + + err := tr.db.GetContext(ctx, tenant, query, tenantId) + + if err == sql.ErrNoRows { + log.Warn(). + Str("repository", "tenant"). + Str("action", "tenant_not_found_by_id"). + Str("tenant_id", tenantId.String()). + Msg("tenant not found - may be deleted or never existed") + return nil, nil + } + if err != nil { + log.Error(). + Str("repository", "tenant"). + Str("action", "find_tenant_by_id_error"). + Str("tenant_id", tenantId.String()). + Err(err). + Msg("database error while looking up tenant by id") + return nil, err + } + log.Debug(). + Str("repository", "tenant"). + Str("action", "tenant_found_by_id"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("subscription_status", tenant.SubscriptionStatus). + Str("subscription_plan", tenant.SubscriptionPlan). + Str("status", tenant.Status). + Msg("tenant found by id") + + return tenant, nil +} + +func (tr *TenantRepository) FindBySlug(ctx context.Context, slug string) (*models.Tenant, error) { + + log.Debug(). + Str("repository", "tenant"). + Str("action", "find_tenant_by_slug_started"). + Str("slug", slug). + Msg("looking up tenant by slug") + + tenant := &models.Tenant{} + + query := ` + SELECT id, name, slug, email, phone, website, + address_line1, address_line2, city, state, country, postal_code, + timezone, currency, locale, subscription_status, subscription_plan, + subscription_expires_at, trial_ends_at, max_users, max_contracts, + max_storage_mb, status, created_at, updated_at, deleted_at + FROM tenants + WHERE slug = $1 + AND deleted_at IS NULL + ` + err := tr.db.GetContext(ctx, tenant, query, slug) + if err == sql.ErrNoRows { + log.Warn(). + Str("repository", "tenant"). + Str("action", "tenant_not_found_by_slug"). + Str("slug", slug). + Msg("tenant not found by slug") + + return nil, nil + } + + if err != nil { + log.Error(). + Str("repository", "tenant"). + Str("action", "find_tenant_by_slug_error"). + Str("slug", slug). + Err(err). + Msg("database error while looking up tenant by slug") + return nil, err + } + + log.Debug(). + Str("repository", "tenant"). + Str("action", "tenant_found_by_slug"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("slug", tenant.Slug). + Str("subscription_status", tenant.SubscriptionStatus). + Msg("tenant found by slug") + return tenant, nil + +} + +func (tr *TenantRepository) SlugExists(ctx context.Context, slug string) (bool, error) { + + log.Debug(). + Str("repository", "tenant"). + Str("action", "checking_slug_exists"). + Str("slug", slug). + Msg("checking if tenant slug is available") + + var exists bool = false + + query := ` + SELECT EXISTS ( + SELECT 1 FROM tenants + WHERE slug = $1 + AND deleted_at IS NULL + ) + ` + + err := tr.db.GetContext(ctx, &exists, query, slug) + + if err != nil { + log.Error(). + Str("repository", "tenant"). + Str("action", "slug_exists_check_error"). + Str("slug", slug). + Err(err). + Msg("database error while checking slug existence") + return true, err + } + + if exists { + log.Info(). + Str("repository", "tenant"). + Str("action", "slug_exists_conflict"). + Str("slug", slug). + Bool("exists", true). + Msg("slug already taken - registration will require different name") + } else { + log.Debug(). + Str("repository", "tenant"). + Str("action", "slug_available"). + Str("slug", slug). + Bool("exists", false). + Msg("slug is available for registration") + } + return exists, nil +} + +func (tr TenantRepository) Update(ctx context.Context, id uuid.UUID, updates map[string]interface{}) error { + // TODO + return nil +} diff --git a/backend/internal/repositories/user_repository.go b/backend/internal/repositories/user_repository.go new file mode 100644 index 0000000..3bc0788 --- /dev/null +++ b/backend/internal/repositories/user_repository.go @@ -0,0 +1,842 @@ +package repositories + +import ( + "context" + "database/sql" + "fmt" + + "github.com/creativenoz/aurganize-v62/backend/internal/models" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/rs/zerolog/log" + "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 { + log.Info(). + Str("repository", "user"). + Str("component", "repository_init"). + Bool("has_db_connection", db != nil). + Msg("user repository initialized") + 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) { + log.Info(). + Str("repository", "user"). + Str("action", "create_user_started"). + Str("tenant_id", input.TenantID.String()). + Str("email", input.Email). + Str("role", input.Role). + Str("status", input.Status). + Bool("has_first_name", input.FirstName != nil). + Bool("has_last_name", input.LastName != nil). + Msg("creating new user") + // 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) + log.Debug(). + Str("repository", "user"). + Str("action", "hashing_password"). + Int("bcrypt_cost", bcrypt.DefaultCost). + Msg("hashing user password with bcrypt") + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + log.Error(). + Str("repository", "user"). + Str("action", "password_hashing_failed"). + Str("email", input.Email). + Int("bcrypt_cost", bcrypt.DefaultCost). + Err(err). + Msg("CRITICAL: failed to hash password with bcrypt") + // Wrap error with context for better debugging + return nil, fmt.Errorf("failed to hash password : %w", err) + } + log.Debug(). + Str("repository", "user"). + Str("action", "password_hashed"). + Msg("password hashed successfully") + + // 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, + ) + + if err != nil { + log.Error(). + Str("repository", "user"). + Str("action", "create_user_failed"). + Str("tenant_id", input.TenantID.String()). + Str("email", input.Email). + Str("role", input.Role). + Err(err). + Msg("failed to create user in database") + return nil, err + } + log.Info(). + Str("repository", "user"). + Str("action", "create_user_success"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("email", user.Email). + Str("role", user.Role). + Str("status", user.Status). + Str("full_name", user.FullName). + Bool("email_verified", user.EmailVerified). + Msg("user created successfully") + + return user, err +} + +// CreateTx creates a new user within an existing transaction. +// This is used during registration to ensure tenant and user are created atomically. +// +// Why this exists: +// - Registration creates tenant + user in one transaction +// - User INSERT has FK constraint to tenants.id +// - FK check must run within the same transaction to see uncommitted tenant +// - Using r.db.GetContext() would use a different session (FK validation fails) +// - Using tx.GetContext() keeps everything in the same transaction +// +// Parameters: +// - ctx: Context for cancellation/timeout +// - tx: Transaction object (implements Execer interface) +// - input: User creation data +// +// Returns: +// - (*User, nil): User created successfully within transaction +// - (nil, error): Failed (password hashing or database error) +func (r *UserRepository) CreateTx(ctx context.Context, tx Execer, input *models.CreateUserInput) (*models.User, error) { + log.Info(). + Str("repository", "user"). + Str("action", "create_user_in_transaction"). + Str("tenant_id", input.TenantID.String()). + Str("email", input.Email). + Str("role", input.Role). + Bool("in_transaction", true). + Msg("creating new user within transaction") + + // Hash password (same as Create method) + log.Debug(). + Str("repository", "user"). + Str("action", "hashing_password"). + Int("bcrypt_cost", bcrypt.DefaultCost). + Msg("hashing user password with bcrypt") + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + log.Error(). + Str("repository", "user"). + Str("action", "password_hashing_failed"). + Str("email", input.Email). + Err(err). + Msg("CRITICAL: failed to hash password with bcrypt in transaction") + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + log.Debug(). + Str("repository", "user"). + Str("action", "password_hashed"). + Msg("password hashed successfully") + + user := &models.User{} + + query := ` + INSERT INTO users ( + tenant_id, + email, + password_hash, + first_name, + last_name, + role, + status + ) 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 + ` + + // ✅ CRITICAL: Use tx.GetContext() to stay within transaction + err = tx.GetContext( + ctx, + user, + query, + input.TenantID, + input.Email, + string(hashedPassword), + input.FirstName, + input.LastName, + input.Role, + input.Status, + ) + + if err != nil { + log.Error(). + Str("repository", "user"). + Str("action", "create_user_in_transaction_failed"). + Str("tenant_id", input.TenantID.String()). + Str("email", input.Email). + Bool("in_transaction", true). + Err(err). + Msg("failed to create user within transaction") + return nil, err + } + + log.Info(). + Str("repository", "user"). + Str("action", "create_user_in_transaction_success"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("email", user.Email). + Str("role", user.Role). + Bool("in_transaction", true). + Msg("user created successfully within transaction") + + return user, nil +} + +// 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) { + log.Debug(). + Str("repository", "user"). + Str("action", "find_user_by_email_started"). + Str("email", email). + Msg("looking up user by email") + + 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 { + log.Warn(). + Str("repository", "user"). + Str("action", "user_not_found_by_email"). + Str("email", email). + Msg("user not found by email") + + return nil, nil // User not found (not an error) + } + if err != nil { + log.Error(). + Str("repository", "user"). + Str("action", "find_user_by_email_error"). + Str("email", email). + Err(err). + Msg("database error while looking up user by email") + return nil, err // Real database error + } + log.Debug(). + Str("repository", "user"). + Str("action", "user_found_by_email"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Str("status", user.Status). + Bool("email_verified", user.EmailVerified). + Msg("user found by email") + + return user, nil +} + +// 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) { + log.Debug(). + Str("repository", "user"). + Str("action", "find_user_by_id_started"). + Str("user_id", id.String()). + Msg("looking up user by id") + + 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 { + log.Warn(). + Str("repository", "user"). + Str("action", "user_not_found_by_id"). + Str("user_id", id.String()). + Msg("user not found by id - may be deleted") + + return nil, nil // User not found (not an error) + } + if err != nil { + log.Debug(). + Str("repository", "user"). + Str("action", "user_found_by_id"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Str("status", user.Status). + Msg("user found by id") + 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) { + log.Debug(). + Str("repository", "user"). + Str("action", "checking_email_exists"). + Str("email", email). + Msg("checking if email already exists") + + 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 + ) + if err != nil { + log.Error(). + Str("repository", "user"). + Str("action", "email_exists_check_error"). + Str("email", email). + Err(err). + Msg("database error while checking email existence") + + return true, err + } + if email_already_exists { + log.Info(). + Str("repository", "user"). + Str("action", "email_already_exists"). + Str("email", email). + Bool("exists", true). + Msg("email already registered - registration will be blocked") + } else { + log.Debug(). + Str("repository", "user"). + Str("action", "email_available"). + Str("email", email). + Bool("exists", false). + Msg("email is available for registration") + } + + 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 { + var ipStr string + if ip != nil { + ipStr = *ip + } + log.Debug(). + Str("repository", "user"). + Str("action", "update_last_login_started"). + Str("user_id", id.String()). + Str("ip", ipStr). + Msg("updating user last login timestamp and ip") + + // 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) + results, err := r.db.ExecContext( + ctx, + query, + id, // $1 - User ID + ipStr, // $2 - IP address + ) + if err != nil { + log.Warn(). + Str("repository", "user"). + Str("action", "update_last_login_failed"). + Str("user_id", id.String()). + Err(err). + Msg("failed to update last login timestamp") + return err + } + rowsAffected, _ := results.RowsAffected() + if rowsAffected == 0 { + log.Warn(). + Str("repository", "user"). + Str("action", "update_last_login_no_rows"). + Str("user_id", id.String()). + Msg("update succeeded but no user was modified - user may not exist") + } else { + log.Debug(). + Str("repository", "user"). + Str("action", "update_last_login_success"). + Str("user_id", id.String()). + Msg("last login updated successfully") + } + return nil +} + +// 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 { + log.Info(). + Str("repository", "user"). + Str("action", "update_password_started"). + Str("user_id", id.String()). + Msg("updating user password") + log.Debug(). + Str("repository", "user"). + Str("action", "hashing_new_password"). + Int("bcrypt_cost", bcrypt.DefaultCost). + Msg("hashing new password with bcrypt") + // 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 { + log.Error(). + Str("repository", "user"). + Str("action", "password_hash_failed_on_update"). + Str("user_id", id.String()). + Err(err). + Msg("failed to hash new password during update") + + // 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 + results, err := r.db.ExecContext( + ctx, + query, + id, // $1 - User ID + string(hashedPassword), // $2 - New password hash + ) + if err != nil { + log.Error(). + Str("repository", "user"). + Str("action", "update_password_failed"). + Str("user_id", id.String()). + Err(err). + Msg("failed to update password in database") + + return err + } + + rowsAffected, _ := results.RowsAffected() + if rowsAffected == 0 { + log.Warn(). + Str("repository", "user"). + Str("action", "update_password_no_rows"). + Str("user_id", id.String()). + Msg("password update succeeded but no user was modified") + } else { + log.Info(). + Str("repository", "user"). + Str("action", "update_password_success"). + Str("user_id", id.String()). + Msg("password updated successfully - all sessions should be revoked") + } + + return nil +} + +// 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 { + log.Debug(). + Str("repository", "user"). + Str("action", "verify_password_started"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Bool("has_password_hash", user.PasswordHash != nil). + Msg("verifying user password") + // 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 { + log.Error(). + Str("repository", "user"). + Str("action", "missing_password_hash"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Msg("CRITICAL: user has no password hash - data corruption or migration issue") + 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)) + + if err != nil { + log.Warn(). + Str("repository", "user"). + Str("action", "password_verification_failed"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Err(err). + Msg("password verification failed - incorrect password") + return false + } + log.Info(). + Str("repository", "user"). + Str("action", "password_verification_success"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Msg("password verified successfully") + return true +} diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go new file mode 100644 index 0000000..04cd577 --- /dev/null +++ b/backend/internal/routes/routes.go @@ -0,0 +1,346 @@ +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, + userHandler *handlers.UserRegisterHander, + tenantHandler *handlers.TenantHandler, + authMiddleware *middleware.AuthMiddleware, + globalRateLimiterMiddleware *middleware.RateLimiter, +) { + // 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") + auth.POST("/register", userHandler.Register, globalRateLimiterMiddleware.Limit) + + // 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, globalRateLimiterMiddleware.Limit) + + // 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, globalRateLimiterMiddleware.Limit) + + // 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, globalRateLimiterMiddleware.Limit) + + // ============================================================================ + // 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) + + tenants := protected.Group("/tenants") + tenants.GET("/mine", tenantHandler.GetMyTenant) + tenants.GET("/:id", tenantHandler.GetTenant) +} diff --git a/backend/internal/services/auth_services.go b/backend/internal/services/auth_services.go new file mode 100644 index 0000000..245766c --- /dev/null +++ b/backend/internal/services/auth_services.go @@ -0,0 +1,1213 @@ +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" + "github.com/rs/zerolog/log" +) + +// 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 { + log.Info(). + Str("service", "auth"). + Str("component", "service_init"). + Dur("access_expiry", config.JWT.AccessExpiry). + Dur("refresh_expiry", config.JWT.RefreshExpiry). + Bool("has_session_repo", sessionRepo != nil). + Bool("has_user_repo", userRepo != nil). + Msg("auth service initialized with JWT configuration") + 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) { + log.Debug(). + Str("service", "auth"). + Str("action", "generate_access_token_started"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Str("token_type", "access"). + Dur("expiry_duration", a.config.JWT.AccessExpiry). + Msg("generating access token") + // 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) + signedToken, err := token.SignedString([]byte(a.config.JWT.AccessSecret)) + if err != nil { + log.Error(). + Str("service", "auth"). + Str("action", "generate_access_token_failed"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Err(err). + Msg("CRITICAL: failed to sign access token - authentication broken") + return "", nil + } + + log.Debug(). + Str("service", "auth"). + Str("action", "generate_access_token_success"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Time("expires_at", expiresAt). + Int("token_length", len(signedToken)). + Msg("access token generated successfully") + // 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 signedToken, nil +} + +// 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) { + + var ipStr, uaStr, deviceType string + if ipAddress != nil { + ipStr = *ipAddress + } + if userAgent != nil { + uaStr = *userAgent + } + + deviceType = detectDeviceType(userAgent) + + log.Info(). + Str("service", "auth"). + Str("action", "generate_refresh_token_started"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("email", user.Email). + Str("device_type", deviceType). + Str("ip", ipStr). + Str("user_agent", uaStr). + Dur("expiry_duration", a.config.JWT.RefreshExpiry). + Msg("generating refresh token and creating session") + + log.Debug(). + Str("service", "auth"). + Str("action", "generating_random_token_id"). + Int("token_bytes", 32). + Msg("generating cryptographically secure random token id") + + // 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 { + log.Error(). + Str("service", "auth"). + Str("action", "random_token_generation_failed"). + Str("user_id", user.ID.String()). + Err(err). + Msg("CRITICAL: failed to generate random token bytes - crypto RNG issue") + 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 + + log.Debug(). + Str("service", "auth"). + Str("action", "creating_session_record"). + Str("user_id", user.ID.String()). + Str("device_type", deviceType). + Msg("creating session record in database") + + // 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 { + log.Error(). + Str("service", "auth"). + Str("action", "session_creation_failed"). + Str("user_id", user.ID.String()). + Str("device_type", deviceType). + Err(err). + Msg("failed to create session in database - login will fail") + 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 { + log.Error(). + Str("service", "auth"). + Str("action", "refresh_token_signing_failed"). + Str("user_id", user.ID.String()). + Str("session_id", session.ID.String()). + Err(err). + Msg("CRITICAL: failed to sign refresh token JWT - session created but unusable") + return "", nil, err // Failed to sign token + } + log.Info(). + Str("service", "auth"). + Str("action", "generate_refresh_token_success"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("email", user.Email). + Str("session_id", session.ID.String()). + Str("device_type", deviceType). + Str("ip", ipStr). + Time("expires_at", expiresAt). + Int("token_length", len(signedToken)). + Msg("refresh token generated successfully - user logged in") + + // 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) { + log.Debug(). + Str("service", "auth"). + Str("action", "validate_access_token_started"). + Int("token_length", len(tokenString)). + Msg("validating access token") + // 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 { + log.Warn(). + Str("service", "auth"). + Str("action", "invalid_signing_algorithm"). + Str("algorithm", token.Method.Alg()). + Msg("token validation failed - invalid signing algorithm (possible attack)") + 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) { + log.Warn(). + Str("service", "auth"). + Str("action", "access_token_expired"). + Msg("access token validation failed - token expired") + return nil, ErrExpiredToken // Return specific expiration error + } + log.Warn(). + Str("service", "auth"). + Str("action", "access_token_validation_failed"). + Err(err). + Msg("access token validation failed - invalid token") + + // 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 { + log.Warn(). + Str("service", "auth"). + Str("action", "access_token_claims_invalid"). + Msg("access token validation failed - claims invalid or token not 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" { + log.Warn(). + Str("service", "auth"). + Str("action", "wrong_token_type_for_access"). + Str("provided_type", claims.TokenType). + Str("expected_type", "access"). + Msg("token validation failed - refresh token used as access token") + return nil, ErrInvalidTokenType + } + tokenAge := time.Since(claims.IssuedAt.Time) + log.Debug(). + Str("service", "auth"). + Str("action", "validate_access_token_success"). + Str("user_id", claims.UserID.String()). + Str("tenant_id", claims.TenantID.String()). + Str("role", claims.Role). + Dur("token_age", tokenAge). + Msg("access token validated successfully") + // 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) { + log.Info(). + Str("service", "auth"). + Str("action", "validate_refresh_token_started"). + Int("token_length", len(tokenString)). + Msg("validating refresh token") + + // 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 { + log.Warn(). + Str("service", "auth"). + Str("action", "invalid_signing_algorithm_refresh"). + Str("algorithm", token.Method.Alg()). + Msg("refresh token validation failed - invalid signing algorithm") + 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) { + log.Warn(). + Str("service", "auth"). + Str("action", "refresh_token_expired"). + Msg("refresh token validation failed - token expired, user must re-login") + return nil, nil, ErrExpiredToken + } + log.Warn(). + Str("service", "auth"). + Str("action", "refresh_token_validation_failed"). + Err(err). + Msg("refresh token validation failed - invalid token") + return nil, nil, ErrInvalidToken + } + + // Step 2: Extract and validate claims + claims, ok := token.Claims.(*auth.RefreshTokenClaims) + if !ok || !token.Valid { + log.Warn(). + Str("service", "auth"). + Str("action", "refresh_token_claims_invalid"). + Msg("refresh token validation failed - claims invalid") + return nil, nil, ErrInvalidToken + } + + // Step 3: Verify token type + if claims.TokenType != "refresh" { + log.Warn(). + Str("service", "auth"). + Str("action", "wrong_token_type_for_refresh"). + Str("provided_type", claims.TokenType). + Msg("token validation failed - access token used as refresh token") + return nil, nil, ErrInvalidToken + } + log.Debug(). + Str("service", "auth"). + Str("action", "looking_up_session"). + Str("session_id", claims.SessionID.String()). + Str("user_id", claims.UserID.String()). + Msg("looking up session in database for refresh token") + + // 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 { + log.Error(). + Str("service", "auth"). + Str("action", "session_lookup_error"). + Str("session_id", claims.SessionID.String()). + Err(err). + Msg("database error during session lookup") + return nil, nil, err // Database error + } + + // Step 5: Verify session was found + if session == nil { + log.Warn(). + Str("service", "auth"). + Str("action", "session_not_found"). + Str("session_id", claims.SessionID.String()). + Str("user_id", claims.UserID.String()). + Msg("session not found - may be revoked, expired, or invalid") + // 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 { + log.Warn(). + Str("service", "auth"). + Str("action", "session_revoked"). + Str("session_id", session.ID.String()). + Str("user_id", session.UserID.String()). + Str("revoked_reason", func() string { + if session.RevokedReason != nil { + return *session.RevokedReason + } + return "unknown" + }()). + Msg("session is revoked - refresh token invalid") + // 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()) { + log.Warn(). + Str("service", "auth"). + Str("action", "session_expired"). + Str("session_id", session.ID.String()). + Time("expired_at", session.ExpiresAt). + Msg("session has expired") + // 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) + log.Info(). + Str("service", "auth"). + Str("action", "validate_refresh_token_success"). + Str("user_id", claims.UserID.String()). + Str("session_id", claims.SessionID.String()). + Time("session_last_used", session.LastUsedAt). + Msg("refresh token validated successfully") + + // 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) { + log.Info(). + Str("service", "auth"). + Str("action", "rotate_refresh_token_started"). + Msg("starting refresh token rotation") + // Step 1: Validate the old refresh token + claims, _, err := a.ValidateRefreshToken(ctx, oldTokenString) + if err != nil { + log.Warn(). + Str("service", "auth"). + Str("action", "rotation_validation_failed"). + Err(err). + Msg("token rotation failed - old token invalid") + return "", "", nil, err + } + + // Step 2: Get user details + user, err := a.userRepo.FindByID(ctx, claims.UserID) + if err != nil { + log.Error(). + Str("service", "auth"). + Str("action", "rotation_user_not_found"). + Str("user_id", claims.UserID.String()). + Err(err). + Msg("token rotation failed - database error") + return "", "", nil, err + } + + if user == nil { + log.Error(). + Str("service", "auth"). + Str("action", "rotation_user_not_found"). + Str("user_id", claims.UserID.String()). + Err(err). + Msg("token rotation failed - user not found") + 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 + } + + log.Info(). + Str("service", "auth"). + Str("action", "revoking_old_session_after_rotation"). + Str("old_session_id", claims.SessionID.String()). + Str("new_session_id", newSession.ID.String()). + Str("user_id", user.ID.String()). + Msg("revoking old session after successful token rotation") + + // 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") + }() + log.Info(). + Str("service", "auth"). + Str("action", "rotate_refresh_token_success"). + Str("user_id", user.ID.String()). + Str("old_session_id", claims.SessionID.String()). + Str("new_session_id", newSession.ID.String()). + Msg("refresh token rotated successfully") + + 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) { + log.Info(). + Str("service", "auth"). + Str("action", "validate_with_theft_detection_started"). + Msg("validating refresh token with rotation theft detection") + // 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" { + log.Error(). + Str("service", "auth"). + Str("action", "token_theft_detected"). + Str("user_id", session.UserID.String()). + Str("session_id", session.ID.String()). + Str("ip", func() string { + if session.IPAddress != nil { + return *session.IPAddress + } + return "unknown" + }()). + Msg("SECURITY ALERT: Revoked token reused after rotation - possible token theft! Revoking all user sessions") + // 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 { + log.Info(). + Str("service", "auth"). + Str("action", "revoke_refresh_token_started"). + Msg("revoking refresh token (logout)") + + // 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 { + log.Warn(). + Str("service", "auth"). + Str("action", "revoke_token_parse_failed"). + Err(err). + Msg("failed to parse token for revocation") + // 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 + } + err = a.sessionRepo.Revoke(ctx, claims.TokenID, "user_logout") + if err != nil { + log.Error(). + Str("service", "auth"). + Str("action", "revoke_refresh_token_failed"). + Str("session_id", claims.SessionID.String()). + Err(err). + Msg("failed to revoke refresh token") + return err + } + log.Info(). + Str("service", "auth"). + Str("action", "revoke_refresh_token_success"). + Str("user_id", claims.UserID.String()). + Str("session_id", claims.SessionID.String()). + Msg("refresh token revoked successfully - user logged out") + // 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 nil +} + +// 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 { + log.Info(). + Str("service", "auth"). + Str("action", "revoke_all_user_tokens_started"). + Str("user_id", userId.String()). + Msg("revoking all refresh tokens for user (logout all devices)") + err := a.sessionRepo.RevokeByUserId(ctx, userId, "revoke_all") + if err != nil { + log.Error(). + Str("service", "auth"). + Str("action", "revoke_all_tokens_failed"). + Str("user_id", userId.String()). + Err(err). + Msg("CRITICAL: failed to revoke all user tokens") + return err + } + log.Info(). + Str("service", "auth"). + Str("action", "revoke_all_user_tokens_success"). + Str("user_id", userId.String()). + Msg("all user tokens revoked successfully - logged out from all devices") + + // Revoke all sessions for user + // Reason "revoke_all" indicates this was bulk revocation + // Repository handles finding and updating all sessions + return nil +} + +// 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/tenant_service.go b/backend/internal/services/tenant_service.go new file mode 100644 index 0000000..064e6e7 --- /dev/null +++ b/backend/internal/services/tenant_service.go @@ -0,0 +1,405 @@ +package services + +import ( + "context" + "errors" + "fmt" + "strings" + + "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/slug" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/lib/pq" + "github.com/rs/zerolog/log" +) + +var ( + ErrTenantNotFound = errors.New("tenant not found") + ErrSlugExists = errors.New("tenant slug already exists") +) + +type TenantService struct { + config *config.Config + tenantRepo *repositories.TenantRepository + userRepo *repositories.UserRepository + db *sqlx.DB +} + +func NewTenantService( + config *config.Config, + tenantRepo *repositories.TenantRepository, + userRepo *repositories.UserRepository, + db *sqlx.DB, +) *TenantService { + log.Info(). + Str("service", "tenant"). + Str("component", "service_init"). + Bool("has_tenant_repo", tenantRepo != nil). + Bool("has_user_repo", userRepo != nil). + Bool("has_db", db != nil). + Msg("tenant service initialized") + return &TenantService{ + config: config, + tenantRepo: tenantRepo, + userRepo: userRepo, + db: db, + } +} + +func (ts *TenantService) Create(ctx context.Context, input *models.CreateTenantInput) (*models.Tenant, error) { + const maxRetries = 5 + log.Info(). + Str("service", "tenant"). + Str("action", "create_tenant_started"). + Str("tenant_name", input.Name). + Str("email", *input.Email). + Int("max_retries", maxRetries). + Msg("creating tenant (standalone, not with user)") + if input.Name == "" { + log.Warn(). + Str("service", "tenant"). + Str("action", "create_tenant_validation_failed"). + Str("validation_error", "empty_name"). + Msg("tenant creation failed - tenant name is required") + return nil, fmt.Errorf("tenant name is required") + } + if *input.Email == "" { + log.Warn(). + Str("service", "tenant"). + Str("action", "create_tenant_validation_failed"). + Str("validation_error", "empty_email"). + Msg("tenant creation failed - tenant email is required") + return nil, fmt.Errorf("tenant email is required") + } + + for attempt := 0; attempt < maxRetries; attempt++ { + baseSlug := slug.Generate(input.Name) + log.Debug(). + Str("service", "tenant"). + Str("action", "generating_unique_slug"). + Int("attempt", attempt+1). + Int("max_retries", maxRetries). + Str("tenant_name", input.Name). + Str("base_slug", baseSlug). + Msg("attempting to generate unique slug for tenant") + uniqueSlug := slug.GenerateUnique(baseSlug, func(candidate string) bool { + exits, err := ts.tenantRepo.SlugExists(ctx, candidate) + if err != nil { + log.Error(). + Str("service", "tenant"). + Str("action", "slug_exists_check_error"). + Str("candidate_slug", candidate). + Err(err). + Msg("database error while checking slug existence") + return true + } + return exits + }) + + log.Debug(). + Str("service", "tenant"). + Str("action", "unique_slug_generated"). + Str("tenant_name", input.Name). + Str("unique_slug", uniqueSlug). + Int("attempt", attempt+1). + Msg("unique slug generated for tenant") + + tenant, err := ts.tenantRepo.Create(ctx, input, uniqueSlug) + + if err == nil { + log.Info(). + Str("service", "tenant"). + Str("action", "create_tenant_success"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("slug", tenant.Slug). + Int("attempts_needed", attempt+1). + Msg("tenant created successfully") + return tenant, nil + } + + if isUniqueViolationError(err, "tenant_slug_key") { + log.Warn(). + Str("service", "tenant"). + Str("action", "slug_collision_detected"). + Str("tenant_name", input.Name). + Str("attempted_slug", uniqueSlug). + Int("attempt", attempt+1). + Int("remaining_attempts", maxRetries-attempt-1). + Msg("slug collision detected, retrying with different slug") + continue + } + log.Error(). + Str("service", "tenant"). + Str("action", "create_tenant_database_error"). + Str("tenant_name", input.Name). + Str("slug", uniqueSlug). + Err(err). + Msg("tenant creation failed with database error") + + return nil, fmt.Errorf("failed to create tenant : %w", err) + } + + log.Error(). + Str("service", "tenant"). + Str("action", "create_tenant_max_retries_exceeded"). + Str("tenant_name", input.Name). + Int("max_retries", maxRetries). + Msg("failed to create tenant - exhausted all slug generation attempts") + return nil, fmt.Errorf("failed to create unique slug after %d attempts", maxRetries) +} + +func (ts *TenantService) CreateWithUser(ctx context.Context, input *models.CreateTenantWithUserInput) (*models.Tenant, *models.User, error) { + + log.Info(). + Str("service", "tenant"). + Str("action", "create_tenant_with_user_started"). + Str("tenant_name", input.TenantName). + Str("email", *input.Email). + Bool("has_first_name", input.FirstName != nil). + Bool("has_last_name", input.LastName != nil). + Msg("starting tenant and user creation transaction (registration)") + + tx, err := ts.db.BeginTxx(ctx, nil) + if err != nil { + log.Error(). + Str("service", "tenant"). + Str("action", "transaction_begin_failed"). + Err(err). + Msg("CRITICAL: failed to begin database transaction - registration broken") + return nil, nil, fmt.Errorf("failed to begin transaction : %w", err) + } + + defer func() { + if err != nil { + log.Info(). + Str("service", "tenant"). + Str("action", "transaction_rollback"). + Str("tenant_name", input.TenantName). + Msg("rolling back transaction due to error") + tx.Rollback() + } + }() + + if input.TenantName == "" { + log.Warn(). + Str("service", "tenant"). + Str("action", "registration_validation_failed"). + Str("validation_error", "empty_tenant_name"). + Msg("registration failed - tenant name is required") + return nil, nil, fmt.Errorf("tenant name is required") + } + if *input.Email == "" { + log.Warn(). + Str("service", "tenant"). + Str("action", "registration_validation_failed"). + Str("validation_error", "empty_email"). + Msg("registration failed - email is required") + return nil, nil, fmt.Errorf("tenant email is required") + } + if *input.Password == "" { + log.Warn(). + Str("service", "tenant"). + Str("action", "registration_validation_failed"). + Str("validation_error", "empty_password"). + Msg("registration failed - password is required") + return nil, nil, fmt.Errorf("password is required") + } + log.Debug(). + Str("service", "tenant"). + Str("action", "generating_registration_slug"). + Str("tenant_name", input.TenantName). + Msg("generating unique slug for registration") + + baseSlug := slug.Generate(input.TenantName) + uniqueSlug := slug.GenerateUnique(baseSlug, func(candidate string) bool { + exists, err := ts.tenantRepo.SlugExists(ctx, candidate) + if err != nil { + log.Error(). + Str("service", "tenant"). + Str("action", "registration_slug_check_error"). + Str("candidate_slug", candidate). + Err(err). + Msg("error checking slug existence during registration") + return true + } + return exists + }) + + log.Debug(). + Str("service", "tenant"). + Str("action", "registration_slug_generated"). + Str("tenant_name", input.TenantName). + Str("unique_slug", uniqueSlug). + Msg("unique slug generated for registration") + + tenantInput := &models.CreateTenantInput{ + Name: input.TenantName, + Email: input.Email, + Timezone: "UTC", + Currency: "INR", + Locale: "en-US", + } + + log.Debug(). + Str("service", "tenant"). + Str("action", "creating_tenant_in_transaction"). + Str("tenant_name", input.TenantName). + Str("slug", uniqueSlug). + Msg("creating tenant record in transaction") + + tenant, err := ts.tenantRepo.CreateTx(ctx, tx, tenantInput, uniqueSlug) + if err != nil { + log.Error(). + Str("service", "tenant"). + Str("action", "tenant_creation_in_transaction_failed"). + Str("tenant_name", input.TenantName). + Str("slug", uniqueSlug). + Err(err). + Msg("failed to create tenant in transaction - registration will fail") + return nil, nil, fmt.Errorf("failed to create tenant: %w", err) + } + + log.Debug(). + Str("service", "tenant"). + Str("action", "creating_user_in_transaction"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("email", *input.Email). + Msg("tenant created, now creating user in same transaction") + + userInput := &models.CreateUserInput{ + TenantID: tenant.ID, + Email: *input.Email, + Password: *input.Password, + FirstName: input.FirstName, + LastName: input.LastName, + Role: "admin", + Status: "active", + } + + user, err := ts.userRepo.CreateTx(ctx, tx, userInput) + if err != nil { + log.Error(). + Str("service", "tenant"). + Str("action", "user_creation_in_transaction_failed"). + Str("tenant_id", tenant.ID.String()). + Str("email", *input.Email). + Err(err). + Msg("failed to create user in transaction - entire registration will rollback") + return nil, nil, fmt.Errorf("failed to create user: %w", err) + } + + log.Debug(). + Str("service", "tenant"). + Str("action", "committing_registration_transaction"). + Str("tenant_id", tenant.ID.String()). + Str("user_id", user.ID.String()). + Msg("committing registration transaction") + + if err = tx.Commit(); err != nil { + log.Error(). + Str("service", "tenant"). + Str("action", "transaction_commit_failed"). + Str("tenant_id", tenant.ID.String()). + Str("user_id", user.ID.String()). + Err(err). + Msg("CRITICAL: failed to commit registration transaction - data may be lost") + return nil, nil, fmt.Errorf("failed to commit transaction: %w", err) + } + log.Info(). + Str("service", "tenant"). + Str("action", "create_tenant_with_user_success"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("slug", tenant.Slug). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("role", user.Role). + Str("timezone", tenant.Timezone). + Str("currency", tenant.Currency). + Str("locale", tenant.Locale). + Msg("registration completed successfully - new tenant and user created") + return tenant, user, nil +} + +func (ts *TenantService) GetByID(ctx context.Context, id uuid.UUID) (*models.Tenant, error) { + log.Debug(). + Str("service", "tenant"). + Str("action", "get_tenant_by_id"). + Str("tenant_id", id.String()). + Msg("retrieving tenant by id") + + tenant, err := ts.tenantRepo.FindByID(ctx, id) + if err != nil { + log.Error(). + Str("service", "tenant"). + Str("action", "get_tenant_error"). + Str("tenant_id", id.String()). + Err(err). + Msg("database error while retrieving tenant") + return nil, fmt.Errorf("database error : %w", err) + } + + if tenant == nil { + log.Warn(). + Str("service", "tenant"). + Str("action", "tenant_not_found"). + Str("tenant_id", id.String()). + Msg("tenant not found by id") + return nil, ErrTenantNotFound + } + log.Debug(). + Str("service", "tenant"). + Str("action", "get_tenant_success"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Msg("tenant retrieved successfully") + return tenant, nil +} + +func (ts *TenantService) SlugExists(ctx context.Context, name string) (bool, error) { + log.Debug(). + Str("service", "tenant"). + Str("action", "check_slug_exists"). + Str("name", name). + Msg("checking if tenant name/slug is available") + slug := slug.Generate(name) + exists, err := ts.tenantRepo.SlugExists(ctx, slug) + if err != nil { + log.Error(). + Str("service", "tenant"). + Str("action", "slug_exists_check_error"). + Str("name", name). + Str("slug", slug). + Err(err). + Msg("error checking slug existence") + } + if exists { + log.Info(). + Str("service", "tenant"). + Str("action", "slug_unavailable"). + Str("name", name). + Str("slug", slug). + Msg("tenant name/slug is already taken") + } else { + log.Debug(). + Str("service", "tenant"). + Str("action", "slug_available"). + Str("name", name). + Str("slug", slug). + Msg("tenant name/slug is available") + } + return exists, nil +} + +func isUniqueViolationError(err error, constraintName string) bool { + if pqErr, ok := err.(*pq.Error); ok { + return pqErr.Code == "23505" && + (constraintName == "" || strings.Contains(pqErr.Constraint, constraintName)) + } + return false +} diff --git a/backend/internal/services/user_service.go b/backend/internal/services/user_service.go new file mode 100644 index 0000000..d21301d --- /dev/null +++ b/backend/internal/services/user_service.go @@ -0,0 +1,930 @@ +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" + "github.com/rs/zerolog/log" +) + +// 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 { + log.Info(). + Str("service", "user"). + Str("component", "service_init"). + Bool("has_user_repo", userRepo != nil). + Msg("user service initialized") + 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) { + log.Info(). + Str("service", "user"). + Str("action", "register_user_started"). + Str("tenant_id", userInput.TenantID.String()). + Str("email", userInput.Email). + Str("role", userInput.Role). + Bool("has_first_name", userInput.FirstName != nil). + Bool("has_last_name", userInput.LastName != nil). + Msg("user registration started") + + log.Debug(). + Str("service", "user"). + Str("action", "validating_registration_input"). + Str("email", userInput.Email). + Msg("validating user registration input") + + // 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 { + var validationError string + switch { + case errors.Is(err, ErrInvalidEmail): + validationError = "invalid_email_format" + case errors.Is(err, ErrEmailAlreadyExists): + validationError = "email_already_taken" + case errors.Is(err, ErrWeakPassword): + validationError = "weak_password" + default: + validationError = "unknown_validation_error" + } + + log.Warn(). + Str("service", "user"). + Str("action", "register_validation_failed"). + Str("validation_error", validationError). + Str("email", userInput.Email). + Str("tenant_id", userInput.TenantID.String()). + Err(err). + Msg("user registration validation failed") + + 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" + originalEmail := userInput.Email + userInput.Email = strings.ToLower(strings.TrimSpace(userInput.Email)) + + if originalEmail != userInput.Email { + log.Debug(). + Str("service", "user"). + Str("action", "email_normalized"). + Str("original_email", originalEmail). + Str("normalized_email", userInput.Email). + Msg("email normalized for consistent storage") + } + log.Debug(). + Str("service", "user"). + Str("action", "creating_user_in_repository"). + Str("email", userInput.Email). + Str("tenant_id", userInput.TenantID.String()). + Msg("creating user in database") + // 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 { + log.Error(). + Str("service", "user"). + Str("action", "register_user_failed"). + Str("email", userInput.Email). + Str("tenant_id", userInput.TenantID.String()). + Err(err). + Msg("failed to create user in database") + // Wrap error with context for better debugging + return nil, fmt.Errorf("failed to create user : %w", err) + } + log.Info(). + Str("service", "user"). + Str("action", "register_user_success"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Str("full_name", user.FullName). + Msg("user registered successfully") + // Step 4: Return created user + // User object includes generated ID, timestamps, etc. + return user, nil +} + +// 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) { + log.Info(). + Str("service", "user"). + Str("action", "authenticate_user_started"). + Str("email", email). + Msg("user authentication attempt") + // Step 1: Normalize email + // Must match normalization done during registration + originalEmail := email + email = strings.ToLower(strings.TrimSpace(email)) + if email != originalEmail { + log.Debug(). + Str("service", "user"). + Str("action", "auth_email_normalized"). + Str("original_email", originalEmail). + Str("normalized_email", email). + Msg("email normalized for authentication") + } + log.Debug(). + Str("service", "user"). + Str("action", "looking_up_user_for_auth"). + Str("email", email). + Msg("looking up user by email for authentication") + // Step 2: Look up user by email + user, err := u.userRepo.FindByEmail(ctx, email) + if err != nil { + log.Error(). + Str("service", "user"). + Str("action", "authenticate_db_error"). + Str("email", email). + Err(err). + Msg("database error during authentication") + // 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 { + log.Warn(). + Str("service", "user"). + Str("action", "authenticate_failed_user_not_found"). + Str("email", email). + Msg("authentication failed - user not found (returning generic error)") + // Email not found in database + // Return generic error (don't reveal email doesn't exist) + return nil, ErrInvalidCredentials + } + log.Debug(). + Str("service", "user"). + Str("action", "verifying_password"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Msg("user found, verifying password") + // Step 4: Verify password + // Repository method uses bcrypt to compare + // Returns false if password doesn't match + if !u.userRepo.VerifyPassword(user, password) { + log.Warn(). + Str("service", "user"). + Str("action", "authenticate_failed_wrong_password"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("tenant_id", user.TenantID.String()). + Msg("authentication failed - incorrect password (returning generic error)") + // Password incorrect + // Return generic error (don't reveal password was wrong) + return nil, ErrInvalidCredentials + } + log.Info(). + Str("service", "user"). + Str("action", "authenticate_user_success"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Str("status", user.Status). + Msg("user authenticated successfully") + // 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) { + log.Debug(). + Str("service", "user"). + Str("action", "get_user_by_id"). + Str("user_id", id.String()). + Msg("retrieving user by id") + + // Look up user by ID + user, err := u.userRepo.FindByID(ctx, id) + if err != nil { + log.Error(). + Str("service", "user"). + Str("action", "get_user_by_id_error"). + Str("user_id", id.String()). + Err(err). + Msg("database error while retrieving user") + // Database error + return nil, fmt.Errorf("repository error : %w", err) + } + + // Check if user was found + if user == nil { + log.Warn(). + Str("service", "user"). + Str("action", "user_not_found_by_id"). + Str("user_id", id.String()). + Msg("user not found by id") + // User doesn't exist (or is soft-deleted) + return nil, ErrUserNotFound + } + log.Debug(). + Str("service", "user"). + Str("action", "get_user_by_id_success"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Msg("user retrieved successfully") + 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) { + log.Debug(). + Str("service", "user"). + Str("action", "get_user_by_email"). + Str("email", email). + Msg("retrieving user by email") + // Look up user by email + user, err := u.userRepo.FindByEmail(ctx, email) + if err != nil { + log.Error(). + Str("service", "user"). + Str("action", "get_user_by_email_error"). + Str("email", email). + Err(err). + Msg("database error while retrieving user") + // Database error + return nil, fmt.Errorf("repository error : %w", err) + } + + // Check if user was found + if user == nil { + log.Warn(). + Str("service", "user"). + Str("action", "user_not_found_by_email"). + Str("email", email). + Msg("user not found by email") + // User doesn't exist + return nil, ErrUserNotFound + } + + log.Debug(). + Str("service", "user"). + Str("action", "get_user_by_email_success"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Msg("user retrieved successfully") + + 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 { + + var ipStr string + if ipAddress != nil { + ipStr = *ipAddress + } + + log.Debug(). + Str("service", "user"). + Str("action", "update_last_login"). + Str("user_id", id.String()). + Str("ip", ipStr). + Msg("updating user last login timestamp") + + err := u.userRepo.UpdateLastLogin(ctx, id, ipAddress) + + if err != nil { + log.Warn(). + Str("service", "user"). + Str("action", "update_last_login_failed"). + Str("user_id", id.String()). + Err(err). + Msg("failed to update last login timestamp") + return err + } + + log.Debug(). + Str("service", "user"). + Str("action", "update_last_login_success"). + Str("user_id", id.String()). + Msg("last login updated successfully") + // Delegate to repository + return nil +} + +// 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 { + + log.Info(). + Str("service", "user"). + Str("action", "update_password_started"). + Str("user_id", id.String()). + Msg("user password update started") + + log.Debug(). + Str("service", "user"). + Str("action", "validating_new_password"). + Str("user_id", id.String()). + Msg("validating new password strength") + + err := u.userRepo.UpdatePassword(ctx, id, newPassword) + if err != nil { + log.Error(). + Str("service", "user"). + Str("action", "update_password_failed"). + Str("user_id", id.String()). + Err(err). + Msg("failed to update user password") + return err + } + log.Info(). + Str("service", "user"). + Str("action", "update_password_success"). + Str("user_id", id.String()). + Msg("user password updated successfully - caller should revoke all sessions") + // Delegate to repository + // Repository handles bcrypt hashing + return nil +} + +// 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 { + log.Debug(). + Str("service", "user"). + Str("action", "validate_registration_started"). + Str("email", input.Email). + Msg("validating registration input") + + log.Debug(). + Str("service", "user"). + Str("action", "validating_email_format"). + Str("email", input.Email). + Msg("validating email format") + // Step 1: Validate email format + // Checks structure, length, basic format + // This is cheap (no database query) + if !isValidEmail(input.Email) { + log.Warn(). + Str("service", "user"). + Str("action", "invalid_email_format"). + Str("email", input.Email). + Msg("email format validation failed") + return ErrInvalidEmail + } + log.Debug(). + Str("service", "user"). + Str("action", "checking_email_uniqueness"). + Str("email", strings.ToLower(input.Email)). + Msg("checking if email already exists") + // 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 { + log.Error(). + Str("service", "user"). + Str("action", "email_uniqueness_check_error"). + Str("email", input.Email). + Err(err). + Msg("database error while checking email uniqueness") + // 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 { + log.Info(). + Str("service", "user"). + Str("action", "email_already_exists"). + Str("email", input.Email). + Msg("email already registered - validation failed") + // Email already registered + return ErrEmailAlreadyExists + } + log.Debug(). + Str("service", "user"). + Str("action", "validating_password_strength"). + Int("password_length", len(input.Password)). + Msg("validating password strength") + + // 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) { + + log.Warn(). + Str("service", "user"). + Str("action", "weak_password_rejected"). + Str("email", input.Email). + Int("password_length", len(input.Password)). + Msg("password failed strength requirements") + return ErrWeakPassword + } + log.Debug(). + Str("service", "user"). + Str("action", "validate_registration_success"). + Str("email", input.Email). + Msg("registration input validated successfully") + + // 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/jobs/session_cleanup.go b/backend/jobs/session_cleanup.go new file mode 100644 index 0000000..9d17fa1 --- /dev/null +++ b/backend/jobs/session_cleanup.go @@ -0,0 +1,82 @@ +package jobs + +import ( + "context" + "time" + + "github.com/rs/zerolog/log" + + "github.com/creativenoz/aurganize-v62/backend/internal/repositories" +) + +type SesssionCleanUpJob struct { + sessionRep *repositories.SessionRepository + interval time.Duration + stopChannel chan struct{} +} + +func NewSessionCleanUpJob(sessionRepo *repositories.SessionRepository) *SesssionCleanUpJob { + return &SesssionCleanUpJob{ + sessionRep: sessionRepo, + interval: 12 * time.Hour, + stopChannel: make(chan struct{}), + } +} + +func (j *SesssionCleanUpJob) Start(ctx context.Context) { + log.Info().Msg("session cleanup job started") + ticker := time.NewTicker(j.interval) + + defer ticker.Stop() + for { + select { + case <-ticker.C: + j.run(ctx) + case <-j.stopChannel: + log.Info().Msg("stop cleanup session command recieved") + return + case <-ctx.Done(): + log.Info().Msg("session cleanup job cancelled") + return + } + } +} + +func (j *SesssionCleanUpJob) Stop() { + close(j.stopChannel) +} + +func (j *SesssionCleanUpJob) run(ctx context.Context) { + startTime := time.Now() + deleted, err := j.sessionRep.DeleteExpired(ctx) + if err != nil { + log.Info().Msg("failed to clean up sessions") + log.Error(). + Err(err). + Dur("duration", time.Since(startTime)). + Msg("failed to clean up sessions") + return + } + log.Info(). + Int64("deleted_count", deleted). + Dur("duration", time.Since(startTime)). + Msg("session clean up completed") +} + +func (j *SesssionCleanUpJob) RunOnce(ctx context.Context) error { + startTime := time.Now() + log.Info().Msg("manually triggered session cleanup") + deleted, err := j.sessionRep.DeleteExpired(ctx) + if err != nil { + log.Error(). + Err(err). + Dur("duration", time.Since(startTime)). + Msg("Failed to cleanup sessions") + return err + } + log.Info(). + Int64("deleted_count", deleted). + Dur("duration", time.Since(startTime)). + Msg("Manual session cleanup completed") + return nil +} 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/backend/pkg/slug/slug.go b/backend/pkg/slug/slug.go new file mode 100644 index 0000000..8b6412c --- /dev/null +++ b/backend/pkg/slug/slug.go @@ -0,0 +1,128 @@ +package slug + +import ( + "crypto/rand" + "regexp" + "strconv" + "strings" + "time" + "unicode" + + "github.com/rs/zerolog/log" + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" +) + +func Generate(text string) string { + log.Debug(). + Str("service", "slugService"). + Str("action", "generate_slug_started"). + Str("input_text", text). + Msg("starting slug generation") + text = strings.ToLower(text) + t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + text, _, _ = transform.String(t, text) + + text = strings.Replace(text, " ", "-", -1) + text = strings.Replace(text, "_", "-", -1) + + reg := regexp.MustCompile(`[^a-z0-9-]+`) + text = reg.ReplaceAllString(text, "") + + reg = regexp.MustCompile(`-+`) + text = reg.ReplaceAllString(text, "-") + + text = strings.Trim(text, "-") + + if len(text) > 100 { + log.Debug(). + Str("service", "slugService"). + Str("action", "slug_truncated"). + Int("original_length", len(text)). + Msg("slug exceeded 100 characters and was truncated") + text = text[:100] + text = strings.TrimRight(text, "-") + } + + if text == "" { + log.Warn(). + Str("service", "slugService"). + Str("action", "empty_slug_generated"). + Str("input_text", text). + Msg("slug is empty after sanitization; generating random fallback") + text = "tenant" + generateRandomString(8) + } + + return text + +} + +func GenerateUnique(base string, exists func(string) bool) string { + log.Debug(). + Str("service", "slugService"). + Str("action", "generate_unique_slug_started"). + Str("base", base). + Msg("generating unique slug") + + slug := Generate(base) + + if !exists(slug) { + log.Debug(). + Str("service", "slugService"). + Str("action", "unique_slug_available"). + Str("slug", slug). + Msg("slug is available without modification") + return slug + } + + for i := 2; i < 1000; i++ { + candidate := slug + "-" + strconv.Itoa(i) + if !exists(candidate) { + log.Info(). + Str("service", "slugService"). + Str("action", "unique_slug_generated_with_suffix"). + Str("slug", candidate). + Int("attempt", i-1). + Msg("slug collision resolved with numeric suffix") + return candidate + } + } + fallback := slug + "-" + strconv.Itoa(int(time.Now().Unix())) + log.Warn(). + Str("service", "slugService"). + Str("action", "unique_slug_fallback_timestamp"). + Str("slug", fallback). + Msg("exhausted 1000 attempts; using timestamp fallback") + + return fallback +} + +func generateRandomString(length int) string { + log.Debug(). + Str("service", "slugService"). + Str("action", "generate_random_string"). + Int("length", length). + Msg("generating random slug string") + const charset = "abcdefghijklmopqrstuvwxyz0123456789" + randBytes := make([]byte, length) + if _, err := rand.Read(randBytes); err != nil { + log.Error(). + Str("service", "slugService"). + Str("action", "rand_read_failed"). + Err(err). + Msg("falling back to time-based random string") + return strconv.FormatInt(time.Now().UnixNano(), 36)[:length] + } + result := make([]byte, length) + for i := 0; i < length; i++ { + result[i] = charset[randBytes[i]%byte(len(charset))] + } + str := string(result) + log.Debug(). + Str("service", "slugService"). + Str("action", "random_string_generated"). + Str("value", str). + Msg("random slug string generated") + return str +} diff --git a/backend/tests/scripts/auth_test_api.sh b/backend/tests/scripts/auth_test_api.sh new file mode 100644 index 0000000..93d6f07 --- /dev/null +++ b/backend/tests/scripts/auth_test_api.sh @@ -0,0 +1,652 @@ +#!/bin/bash +# ============================================================================== +# AURGANIZE V6.2 - API TESTING SCRIPT +# ============================================================================== +# Purpose: Comprehensive testing of all API endpoints +# Usage: ./test-api.sh +# Requires: curl, jq (for JSON parsing) +# ============================================================================== + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# API Base URL +BASE_URL="http://localhost:8080/api/v1" + +# Test data +TEST_EMAIL="test-$(date +%s)@example.com" # Unique email with timestamp +TEST_PASSWORD="Test123!@#" +TEST_FIRST_NAME="Test" +TEST_LAST_NAME="User" +TEST_TENANT_NAME="Test Company $(date +%s)" + +# Variables to store tokens +ACCESS_TOKEN="" +REFRESH_TOKEN="" +USER_ID="" +TENANT_ID="" + +echo "==========================================" +echo "AURGANIZE V6.2 API TESTING" +echo "==========================================" +echo "" +echo "Base URL: $BASE_URL" +echo "Test Email: $TEST_EMAIL" +echo "" + +# ============================================================================== +# Helper Functions +# ============================================================================== + +# Function to print test header +print_test() { + echo "" + echo -e "${BLUE}==========================================" + echo "TEST: $1" + echo -e "==========================================${NC}" + echo "" +} + +# Function to print success +print_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +# Function to print error +print_error() { + echo -e "${RED}❌ $1${NC}" +} + +# Function to print warning +print_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +# Function to check if jq is installed +check_jq() { + if ! command -v jq &> /dev/null; then + print_warning "jq is not installed (JSON parsing will be limited)" + echo " Install: apt-get install jq or brew install jq" + return 1 + fi + return 0 +} + +# Check for jq +HAS_JQ=false +if check_jq; then + HAS_JQ=true +fi + +# ============================================================================== +# TEST 1: Health Check (Unprotected) +# ============================================================================== +print_test "1. Health Check (Unprotected Route)" + +echo "Request: GET /health" +RESPONSE=$(curl -s -w "\n%{http_code}" http://localhost:8080/health) +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "200" ]; then + print_success "Health check passed" +else + print_error "Health check failed (expected 200, got $HTTP_CODE)" + exit 1 +fi + +# ============================================================================== +# TEST 2: User Registration +# ============================================================================== +print_test "2. User Registration" + +echo "Creating new user account..." +echo " Email: $TEST_EMAIL" +echo " Password: $TEST_PASSWORD" +echo " Name: $TEST_FIRST_NAME $TEST_LAST_NAME" +echo " Tenant: $TEST_TENANT_NAME" +echo "" + +REGISTER_PAYLOAD=$(cat </dev/null || echo "$REGISTER_PAYLOAD" +echo "" + +RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/register" \ + -H "Content-Type: application/json" \ + -d "$REGISTER_PAYLOAD") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "201" ]; then + print_success "User registered successfully" + + # Extract tokens and IDs + if [ "$HAS_JQ" = true ]; then + ACCESS_TOKEN=$(echo "$BODY" | jq -r '.access_token') + REFRESH_TOKEN=$(echo "$BODY" | jq -r '.refresh_token') + USER_ID=$(echo "$BODY" | jq -r '.user.id') + TENANT_ID=$(echo "$BODY" | jq -r '.tenant.id') + + echo "" + echo "Extracted data:" + echo " User ID: $USER_ID" + echo " Tenant ID: $TENANT_ID" + echo " Access Token: ${ACCESS_TOKEN:0:30}..." + echo " Refresh Token: ${REFRESH_TOKEN:0:30}..." + else + print_warning "Install jq to extract tokens automatically" + echo "Manually extract access_token and refresh_token from response above" + fi +else + print_error "Registration failed (expected 201, got $HTTP_CODE)" + echo "" + echo "Common issues:" + echo " 1. Validator not registered (check main.go line 128)" + echo " 2. Database user doesn't exist" + echo " 3. Transaction interface mismatch" + exit 1 +fi + +# ============================================================================== +# TEST 3: Duplicate Registration (Should Fail) +# ============================================================================== +print_test "3. Duplicate Registration (Should Fail with 409)" + +echo "Attempting to register same email again..." +RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/register" \ + -H "Content-Type: application/json" \ + -d "$REGISTER_PAYLOAD") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "409" ]; then + print_success "Correctly rejected duplicate email" +else + print_warning "Expected 409 Conflict, got $HTTP_CODE (check uniqueness constraint)" +fi + +# ============================================================================== +# TEST 4: Invalid Registration (Weak Password) +# ============================================================================== +print_test "4. Invalid Registration - Weak Password" + +WEAK_PASSWORD_PAYLOAD=$(cat </dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "400" ]; then + print_success "Correctly rejected weak password" +else + print_warning "Expected 400 Bad Request, got $HTTP_CODE (check validator)" +fi + +# ============================================================================== +# TEST 5: Rate Limiting (Registration) +# ============================================================================== +print_test "5. Rate Limiting on Registration (5 requests/minute)" + +echo "Sending 6 rapid registration requests..." +echo "(Rate limit: 5 requests/minute)" +echo "" + +RATE_LIMITED=false +for i in {1..6}; do + UNIQUE_EMAIL="ratelimit-$i-$(date +%s)@example.com" + RATE_LIMIT_PAYLOAD=$(cat </dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "200" ]; then + print_success "Login successful" + + # Update tokens (login gives new tokens) + if [ "$HAS_JQ" = true ]; then + ACCESS_TOKEN=$(echo "$BODY" | jq -r '.access_token') + REFRESH_TOKEN=$(echo "$BODY" | jq -r '.refresh_token') + + echo "" + echo "New tokens received:" + echo " Access Token: ${ACCESS_TOKEN:0:30}..." + echo " Refresh Token: ${REFRESH_TOKEN:0:30}..." + fi +else + print_error "Login failed (expected 200, got $HTTP_CODE)" + exit 1 +fi + +# ============================================================================== +# TEST 7: Login with Wrong Password +# ============================================================================== +print_test "7. Login with Wrong Password (Should Fail)" + +WRONG_PASSWORD_PAYLOAD=$(cat </dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "401" ]; then + print_success "Correctly rejected invalid credentials" +else + print_warning "Expected 401 Unauthorized, got $HTTP_CODE" +fi + +# ============================================================================== +# TEST 8: Protected Route (Health with Auth) +# ============================================================================== +print_test "8. Protected Health Endpoint" + +if [ -z "$ACCESS_TOKEN" ]; then + print_error "No access token available (login may have failed)" + exit 1 +fi + +echo "Request: GET /api/v1/health" +echo "Authorization: Bearer ${ACCESS_TOKEN:0:30}..." +echo "" + +RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/health" \ + -H "Authorization: Bearer $ACCESS_TOKEN") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "200" ]; then + print_success "Authentication middleware working" +else + print_error "Protected route failed (expected 200, got $HTTP_CODE)" +fi + +# ============================================================================== +# TEST 9: Protected Route WITHOUT Token (Should Fail) +# ============================================================================== +print_test "9. Protected Route Without Token (Should Fail)" + +echo "Request: GET /api/v1/health" +echo "Authorization: (none)" +echo "" + +RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/health") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "401" ]; then + print_success "Correctly rejected request without token" +else + print_warning "Expected 401 Unauthorized, got $HTTP_CODE" +fi + +# ============================================================================== +# TEST 10: Get My Tenant +# ============================================================================== +print_test "10. Get My Tenant (Row-Level Security Test)" + +echo "Request: GET /api/v1/tenants/mine" +echo "This tests:" +echo " - Authentication middleware" +echo " - Row-Level Security (RLS)" +echo " - Tenant context extraction" +echo "" + +RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/tenants/mine" \ + -H "Authorization: Bearer $ACCESS_TOKEN") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "200" ]; then + print_success "Tenant retrieval successful (RLS working)" + + if [ "$HAS_JQ" = true ]; then + RETRIEVED_TENANT_ID=$(echo "$BODY" | jq -r '.id') + TENANT_NAME=$(echo "$BODY" | jq -r '.name') + + echo "" + echo "Tenant details:" + echo " ID: $RETRIEVED_TENANT_ID" + echo " Name: $TENANT_NAME" + + if [ "$RETRIEVED_TENANT_ID" = "$TENANT_ID" ]; then + print_success "Tenant ID matches registration" + else + print_warning "Tenant ID mismatch!" + fi + fi +else + print_error "Get tenant failed (expected 200, got $HTTP_CODE)" +fi + +# ============================================================================== +# TEST 11: Get Specific Tenant by ID +# ============================================================================== +print_test "11. Get Specific Tenant by ID" + +if [ -z "$TENANT_ID" ]; then + print_warning "Skipping: No tenant ID available" +else + echo "Request: GET /api/v1/tenants/$TENANT_ID" + echo "" + + RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/tenants/$TENANT_ID" \ + -H "Authorization: Bearer $ACCESS_TOKEN") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | head -n-1) + + echo "HTTP Status: $HTTP_CODE" + echo "Response Body:" + echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + + if [ "$HTTP_CODE" = "200" ]; then + print_success "Specific tenant retrieval working" + else + print_error "Get specific tenant failed (expected 200, got $HTTP_CODE)" + echo "" + echo "This might fail if route order is wrong:" + echo " /tenants/mine must come BEFORE /tenants/:id" + echo " Otherwise ':id' matches 'mine' as a UUID" + fi +fi + +# ============================================================================== +# TEST 12: Token Refresh +# ============================================================================== +print_test "12. Token Refresh (Token Rotation)" + +if [ -z "$REFRESH_TOKEN" ]; then + print_error "No refresh token available" + exit 1 +fi + +echo "Request: POST /api/v1/auth/refresh" +echo "Using refresh token: ${REFRESH_TOKEN:0:30}..." +echo "" +echo "This tests:" +echo " - Refresh token validation" +echo " - Token rotation (new access + refresh tokens)" +echo " - Session management" +echo "" + +RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/refresh" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $REFRESH_TOKEN") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "200" ]; then + print_success "Token refresh successful" + + if [ "$HAS_JQ" = true ]; then + NEW_ACCESS_TOKEN=$(echo "$BODY" | jq -r '.access_token') + NEW_REFRESH_TOKEN=$(echo "$BODY" | jq -r '.refresh_token') + + echo "" + echo "New tokens received:" + echo " Access Token: ${NEW_ACCESS_TOKEN:0:30}..." + echo " Refresh Token: ${NEW_REFRESH_TOKEN:0:30}..." + + # Verify tokens are different (rotation) + if [ "$NEW_ACCESS_TOKEN" != "$ACCESS_TOKEN" ]; then + print_success "Access token rotated (security best practice)" + else + print_warning "Access token not rotated" + fi + + if [ "$NEW_REFRESH_TOKEN" != "$REFRESH_TOKEN" ]; then + print_success "Refresh token rotated (security best practice)" + else + print_warning "Refresh token not rotated" + fi + + # Update tokens for logout test + ACCESS_TOKEN="$NEW_ACCESS_TOKEN" + REFRESH_TOKEN="$NEW_REFRESH_TOKEN" + fi +else + print_error "Token refresh failed (expected 200, got $HTTP_CODE)" +fi + +# ============================================================================== +# TEST 13: Use Old Refresh Token (Should Fail) +# ============================================================================== +print_test "13. Use Old Refresh Token After Rotation (Should Fail)" + +echo "After token rotation, old refresh token should be invalid..." +echo "" + +# We'd need to save the old token before refresh to test this properly +print_warning "Skipping: Requires saving old token before rotation" +echo "Manual test: Try using old refresh token after successful refresh" + +# ============================================================================== +# TEST 14: User Logout +# ============================================================================== +print_test "14. User Logout (Session Revocation)" + +echo "Request: POST /api/v1/auth/logout" +echo "This should:" +echo " - Revoke current refresh token" +echo " - Mark session as revoked" +echo " - Old refresh token should no longer work" +echo "" + +RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/logout" \ + -H "Authorization: Bearer $ACCESS_TOKEN") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "200" ]; then + print_success "Logout successful" +else + print_warning "Logout returned $HTTP_CODE (expected 200)" +fi + +# ============================================================================== +# TEST 15: Use Tokens After Logout (Should Fail) +# ============================================================================== +print_test "15. Use Tokens After Logout (Should Fail)" + +echo "Attempting to refresh token after logout..." +RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/refresh" \ + -H "Authorization: Bearer $REFRESH_TOKEN") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "401" ]; then + print_success "Refresh token correctly revoked after logout" +else + print_warning "Expected 401, got $HTTP_CODE (session may not be revoked)" +fi + +# ============================================================================== +# FINAL SUMMARY +# ============================================================================== +echo "" +echo "==========================================" +echo "TEST SUMMARY" +echo "==========================================" +echo "" +print_success "All critical tests completed!" +echo "" +echo "Tested functionality:" +echo " ✅ Health check (public route)" +echo " ✅ User registration with transaction" +echo " ✅ Duplicate email detection" +echo " ✅ Password validation" +echo " ✅ Rate limiting" +echo " ✅ User login" +echo " ✅ Invalid credential handling" +echo " ✅ Authentication middleware" +echo " ✅ Row-Level Security (RLS)" +echo " ✅ Tenant context management" +echo " ✅ Token refresh & rotation" +echo " ✅ Session revocation (logout)" +echo "" +echo "Database verification:" +echo " Check sessions table:" +echo " docker exec -it aurganize-postgres psql -U postgres -d aurganize_dev -c \"SELECT id, user_id, is_revoked, device_type, created_at FROM sessions;\"" +echo "" +echo " Check users table:" +echo " docker exec -it aurganize-postgres psql -U postgres -d aurganize_dev -c \"SELECT id, email, role, created_at FROM users;\"" +echo "" +echo " Check tenants table:" +echo " docker exec -it aurganize-postgres psql -U postgres -d aurganize_dev -c \"SELECT id, name, type, created_at FROM tenants;\"" +echo "" +echo "==========================================" +echo "TESTING COMPLETE" +echo "==========================================" \ No newline at end of file diff --git a/database/migrations/000001_initial_schema.down.sql b/database/migrations/000001_initial_schema.down.sql index 13552c2..31fd023 100644 --- a/database/migrations/000001_initial_schema.down.sql +++ b/database/migrations/000001_initial_schema.down.sql @@ -1,33 +1,246 @@ -- ============================================================================= --- ROLLBACK: 000001_initial_schema +-- AURGANIZE V6.2 - INITIAL SCHEMA ROLLBACK +-- ============================================================================= +-- Migration: 000001_initial_schema (DOWN) +-- Description: Safely removes all tables, functions, triggers, and types +-- Author: Aurganize Team +-- Date: 2025-12-11 +-- Version: 2.0 (Marketplace Edition) +-- ============================================================================= +-- This rollback migration removes the entire Aurganize V6.2 schema in the +-- correct order to avoid foreign key constraint violations. +-- +-- CRITICAL SAFETY NOTES: +-- 1. This will DESTROY ALL DATA in the database +-- 2. Always backup before running this migration +-- 3. Cannot be undone - data recovery requires restoring from backup +-- 4. Runs in reverse dependency order (child tables before parent tables) +-- +-- Order of operations: +-- 1. Drop all RLS policies +-- 2. Drop all triggers +-- 3. Drop all tables (child → parent order) +-- 4. Drop all functions +-- 5. Drop all custom types +-- 6. Drop all extensions (optional - usually kept for other schemas) -- ============================================================================= --- Drop tables in reverse order (respecting foreign keys) +-- ============================================================================= +-- SECTION 1: DISABLE ROW-LEVEL SECURITY +-- ============================================================================= +-- Must disable RLS before dropping policies +-- ============================================================================= + +-- Disable RLS on all tables (if they exist) +ALTER TABLE IF EXISTS notifications DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS analytics_events DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS audit_logs DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS attachments DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS comments DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS milestones DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS deliverables DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS contracts DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS users DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS tenants DISABLE ROW LEVEL SECURITY; + +-- ============================================================================= +-- SECTION 2: DROP ROW-LEVEL SECURITY POLICIES +-- ============================================================================= +-- Drop policies before dropping tables +-- Using IF EXISTS to prevent errors if policies don't exist +-- ============================================================================= + +-- Notifications policies +DROP POLICY IF EXISTS notifications_tenant_isolation ON notifications; + +-- Analytics policies +DROP POLICY IF EXISTS analytics_events_access ON analytics_events; + +-- Audit logs policies +DROP POLICY IF EXISTS audit_logs_tenant_isolation ON audit_logs; + +-- Attachments policies +DROP POLICY IF EXISTS attachments_collaboration_access ON attachments; + +-- Comments policies +DROP POLICY IF EXISTS comments_collaboration_access ON comments; + +-- Milestones policies +DROP POLICY IF EXISTS milestones_collaboration_access ON milestones; + +-- Deliverables policies +DROP POLICY IF EXISTS deliverables_collaboration_access ON deliverables; + +-- Contracts policies +DROP POLICY IF EXISTS contracts_collaboration_access ON contracts; + +-- Users policies +DROP POLICY IF EXISTS users_marketplace_access ON users; + +-- Tenants policies +DROP POLICY IF EXISTS tenants_marketplace_access ON tenants; + +-- ============================================================================= +-- SECTION 3: DROP ALL TRIGGERS +-- ============================================================================= +-- Triggers must be dropped before functions they reference +-- Drop in any order (triggers are independent) +-- ============================================================================= + +-- Updated_at triggers (applied to multiple tables) +DROP TRIGGER IF EXISTS update_tenants_updated_at ON tenants; +DROP TRIGGER IF EXISTS update_users_updated_at ON users; +DROP TRIGGER IF EXISTS update_contracts_updated_at ON contracts; +DROP TRIGGER IF EXISTS update_deliverables_updated_at ON deliverables; +DROP TRIGGER IF EXISTS update_milestones_updated_at ON milestones; +DROP TRIGGER IF EXISTS update_comments_updated_at ON comments; +DROP TRIGGER IF EXISTS update_attachments_updated_at ON attachments; + +-- Full name generation trigger (users table) +DROP TRIGGER IF EXISTS update_user_full_name ON users; + +-- ============================================================================= +-- SECTION 4: DROP ALL TABLES +-- ============================================================================= +-- CRITICAL ORDER: Drop child tables BEFORE parent tables +-- Foreign key constraints prevent dropping parent tables first +-- +-- Dependency tree: +-- notifications → tenants, users +-- analytics_events → tenants, users +-- audit_logs → tenants, users +-- attachments → tenants, users +-- comments → tenants, users +-- milestones → tenants, deliverables +-- deliverables → tenants, contracts +-- contracts → tenants, users +-- users → tenants +-- tenants (root) +-- ============================================================================= + +-- Level 4: Supporting tables (no dependencies) DROP TABLE IF EXISTS notifications CASCADE; DROP TABLE IF EXISTS analytics_events CASCADE; DROP TABLE IF EXISTS audit_logs CASCADE; + +-- Level 3: Polymorphic relationship tables (depend on multiple entities) DROP TABLE IF EXISTS attachments CASCADE; DROP TABLE IF EXISTS comments CASCADE; + +-- Level 2: Milestones → Deliverables DROP TABLE IF EXISTS milestones CASCADE; + +-- Level 1: Deliverables → Contracts DROP TABLE IF EXISTS deliverables CASCADE; + +-- Level 0: Core business tables DROP TABLE IF EXISTS contracts CASCADE; DROP TABLE IF EXISTS users CASCADE; DROP TABLE IF EXISTS tenants CASCADE; --- Drop functions -DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE; -DROP FUNCTION IF EXISTS set_tenant_context(UUID) CASCADE; -DROP FUNCTION IF EXISTS get_current_tenant() CASCADE; +-- ============================================================================= +-- SECTION 5: DROP ALL FUNCTIONS +-- ============================================================================= +-- Functions can be dropped after triggers that use them +-- Order doesn't matter for functions +-- ============================================================================= --- Drop enums +-- Trigger function for updating updated_at timestamps +DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE; + +-- Trigger function for generating full_name from first_name + last_name +DROP FUNCTION IF EXISTS generate_full_name() CASCADE; + +-- ============================================================================= +-- SECTION 6: DROP ALL CUSTOM TYPES +-- ============================================================================= +-- Types must be dropped after all tables/functions that use them +-- Order doesn't matter for types (no dependencies between types) +-- ============================================================================= + +-- Drop all ENUM types DROP TYPE IF EXISTS milestone_status CASCADE; DROP TYPE IF EXISTS milestone_type CASCADE; DROP TYPE IF EXISTS deliverable_status CASCADE; DROP TYPE IF EXISTS contract_status CASCADE; -DROP TYPE IF EXISTS tenant_type CASCADE; DROP TYPE IF EXISTS user_role CASCADE; --- Drop extensions (optional - might be used by other databases) --- DROP EXTENSION IF EXISTS "btree_gin"; --- DROP EXTENSION IF EXISTS "pg_trgm"; --- DROP EXTENSION IF EXISTS "uuid-ossp"; \ No newline at end of file +-- ============================================================================= +-- SECTION 7: DROP EXTENSIONS (OPTIONAL) +-- ============================================================================= +-- WARNING: Only drop extensions if you're certain no other schemas use them +-- Usually extensions are shared across the entire database +-- Commented out by default for safety +-- ============================================================================= + +-- Uncomment only if you're certain these extensions are not used elsewhere: +-- DROP EXTENSION IF EXISTS "unaccent" CASCADE; +-- DROP EXTENSION IF EXISTS "btree_gin" CASCADE; +-- DROP EXTENSION IF EXISTS "pg_trgm" CASCADE; +-- DROP EXTENSION IF EXISTS "uuid-ossp" CASCADE; + +-- ============================================================================= +-- SECTION 8: VERIFICATION QUERIES (OPTIONAL) +-- ============================================================================= +-- Run these after rollback to verify complete removal +-- These queries should return 0 rows if rollback was successful +-- ============================================================================= + +-- Verify no tables remain from our schema +DO $$ +DECLARE + remaining_tables INTEGER; +BEGIN + SELECT COUNT(*) INTO remaining_tables + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ( + 'tenants', 'users', 'contracts', 'deliverables', 'milestones', + 'comments', 'attachments', 'audit_logs', 'analytics_events', 'notifications' + ); + + IF remaining_tables > 0 THEN + RAISE WARNING 'WARNING: % tables still exist after rollback', remaining_tables; + ELSE + RAISE NOTICE 'SUCCESS: All Aurganize tables removed'; + END IF; +END $$; + +-- Verify no custom types remain +DO $$ +DECLARE + remaining_types INTEGER; +BEGIN + SELECT COUNT(*) INTO remaining_types + FROM pg_type + WHERE typname IN ( + 'user_role', 'contract_status', 'deliverable_status', + 'milestone_type', 'milestone_status' + ); + + IF remaining_types > 0 THEN + RAISE WARNING 'WARNING: % custom types still exist after rollback', remaining_types; + ELSE + RAISE NOTICE 'SUCCESS: All Aurganize custom types removed'; + END IF; +END $$; + +-- Verify no functions remain +DO $$ +DECLARE + remaining_functions INTEGER; +BEGIN + SELECT COUNT(*) INTO remaining_functions + FROM pg_proc + WHERE proname IN ('update_updated_at_column', 'generate_full_name'); + + IF remaining_functions > 0 THEN + RAISE WARNING 'WARNING: % functions still exist after rollback', remaining_functions; + ELSE + RAISE NOTICE 'SUCCESS: All Aurganize functions removed'; + END IF; +END $$; + +-- ============================================================================= +-- END OF ROLLBACK MIGRATION 000001_initial_schema.down.sql +-- ============================================================================= diff --git a/database/migrations/000001_initial_schema.up.sql b/database/migrations/000001_initial_schema.up.sql index 80c4671..05f570a 100644 --- a/database/migrations/000001_initial_schema.up.sql +++ b/database/migrations/000001_initial_schema.up.sql @@ -1,197 +1,318 @@ -- ============================================================================= --- AURGANIZE V6.2 - INITIAL SCHEMA (CORRECTED) +-- AURGANIZE V6.2 - INITIAL SCHEMA (MARKETPLACE ARCHITECTURE) -- ============================================================================= -- Migration: 000001_initial_schema --- Description: Creates core tables for multi-tenant project management +-- Description: Creates core tables matching Go models exactly -- Author: Aurganize Team --- Date: 2025-11-26 +-- Date: 2025-12-11 +-- Version: 2.0 (Marketplace Edition - Matching Go Models) +-- ============================================================================= +-- This migration establishes the foundational database schema for Aurganize V6.2, +-- a B2B marketplace platform connecting vendors and consumers. +-- +-- Key Design Decisions: +-- 1. Schema matches Go models EXACTLY (no mismatches) +-- 2. Multi-tenancy WITHOUT strict RLS on tenants/users (marketplace discovery) +-- 3. Collaboration-aware RLS on contracts (both vendor & consumer can access) +-- 4. System role with BYPASSRLS for registration flow +-- 5. Full-text search for marketplace discovery -- ============================================================================= -- ============================================================================= --- EXTENSIONS +-- SECTION 1: EXTENSIONS -- ============================================================================= CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -CREATE EXTENSION IF NOT EXISTS "btree_gin"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- Fuzzy text search +CREATE EXTENSION IF NOT EXISTS "btree_gin"; -- JSONB indexes +CREATE EXTENSION IF NOT EXISTS "unaccent"; -- For slug generation -- ============================================================================= --- ENUMS +-- SECTION 2: CUSTOM ENUM TYPES -- ============================================================================= -CREATE TYPE user_role AS ENUM ('admin', 'vendor', 'consumer', 'project_manager'); -CREATE TYPE tenant_type AS ENUM ('permanent', 'temporary'); -CREATE TYPE contract_status AS ENUM ('draft', 'active', 'completed', 'cancelled'); -CREATE TYPE deliverable_status AS ENUM ('pending', 'in_progress', 'submitted', 'approved', 'rejected'); -CREATE TYPE milestone_type AS ENUM ('fixed_date', 'duration_from_start', 'duration_from_previous'); -CREATE TYPE milestone_status AS ENUM ('pending', 'in_progress', 'completed'); +-- User roles for marketplace participants +CREATE TYPE user_role AS ENUM ( + 'admin', -- Platform administrator + 'vendor', -- Service provider (interior designers, agencies, etc.) + 'consumer', -- Service buyer (hotels, companies, etc.) + 'project_manager' -- Project coordinator +); + +-- Contract lifecycle states +CREATE TYPE contract_status AS ENUM ( + 'draft', -- Being negotiated + 'active', -- Work in progress + 'completed', -- All deliverables approved + 'cancelled' -- Terminated before completion +); + +-- Deliverable workflow states +CREATE TYPE deliverable_status AS ENUM ( + 'pending', -- Not yet started + 'in_progress', -- Vendor working on it + 'submitted', -- Awaiting consumer approval + 'approved', -- Consumer accepted + 'rejected' -- Needs rework +); + +-- Milestone scheduling types +CREATE TYPE milestone_type AS ENUM ( + 'fixed_date', -- Specific calendar date (e.g., "2025-12-31") + 'duration_from_start', -- Days from contract start (e.g., "30") + 'duration_from_previous' -- Days from previous milestone (e.g., "14") +); + +-- Milestone completion states +CREATE TYPE milestone_status AS ENUM ( + 'pending', -- Not yet eligible + 'in_progress', -- Conditions being met + 'completed' -- Fully satisfied +); -- ============================================================================= --- CORE TABLES +-- SECTION 3: TENANTS TABLE +-- ============================================================================= +-- Organizations in the marketplace (vendors and consumers) +-- ✅ Matches Go model exactly +-- ✅ NO strict RLS - discoverable for marketplace -- ============================================================================= - --- ----------------------------------------------------------------------------- --- Tenants Table --- ----------------------------------------------------------------------------- --- Stores tenant (organization) information --- Supports both permanent tenants (companies) and temporary tenants (projects) CREATE TABLE tenants ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - name VARCHAR(255) NOT NULL, - type tenant_type NOT NULL DEFAULT 'permanent', - parent_tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, - is_active BOOLEAN NOT NULL DEFAULT true, - expires_at TIMESTAMPTZ, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT valid_tenant_type CHECK ( - (type = 'permanent' AND parent_tenant_id IS NULL AND expires_at IS NULL) OR - (type = 'temporary' AND parent_tenant_id IS NOT NULL AND expires_at IS NOT NULL) - ) + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) UNIQUE NOT NULL, + + -- Contact information (matches Go model) + email VARCHAR(255), + phone VARCHAR(50), + website TEXT, + + -- Address (matches Go model) + address_line1 VARCHAR(255), + address_line2 VARCHAR(255), + city VARCHAR(100), + state VARCHAR(100), + country VARCHAR(100), + postal_code VARCHAR(20), + + -- Localization (matches Go model) + timezone VARCHAR(50) NOT NULL DEFAULT 'UTC', + currency VARCHAR(3) NOT NULL DEFAULT 'USD', + locale VARCHAR(10) NOT NULL DEFAULT 'en-US', + + -- Subscription & billing (matches Go model) + subscription_status VARCHAR(50) NOT NULL DEFAULT 'trial', + subscription_plan VARCHAR(50) NOT NULL DEFAULT 'basic', + subscription_expires_at TIMESTAMPTZ, + trial_ends_at TIMESTAMPTZ, + + -- Limits (matches Go model) + max_users INTEGER NOT NULL DEFAULT 10, + max_contracts INTEGER NOT NULL DEFAULT 50, + max_storage_mb INTEGER NOT NULL DEFAULT 5120, + + -- Status (matches Go model - NOT is_active) + status VARCHAR(50) NOT NULL DEFAULT 'active', + + -- Audit fields (matches Go model) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT chk_tenant_subscription_status CHECK ( + subscription_status IN ('trial', 'active', 'cancelled', 'expired', 'suspended') + ), + CONSTRAINT chk_tenant_subscription_plan CHECK ( + subscription_plan IN ('basic', 'professional', 'enterprise') + ), + CONSTRAINT chk_tenant_status CHECK ( + status IN ('active', 'inactive', 'suspended', 'deleted') + ) ); --- Indexes -CREATE INDEX idx_tenants_parent ON tenants(parent_tenant_id) WHERE parent_tenant_id IS NOT NULL; -CREATE INDEX idx_tenants_active ON tenants(is_active) WHERE is_active = true; -CREATE INDEX idx_tenants_expires ON tenants(expires_at) WHERE expires_at IS NOT NULL; +-- Performance indexes +CREATE INDEX idx_tenants_slug ON tenants(slug); +CREATE INDEX idx_tenants_status ON tenants(status) WHERE status = 'active'; +CREATE INDEX idx_tenants_subscription ON tenants(subscription_status, subscription_expires_at); +CREATE INDEX idx_tenants_trial ON tenants(trial_ends_at) WHERE trial_ends_at IS NOT NULL; --- Comments -COMMENT ON TABLE tenants IS 'Organizations and project workspaces'; -COMMENT ON COLUMN tenants.type IS 'permanent: Long-lived organization, temporary: Project-specific workspace'; -COMMENT ON COLUMN tenants.parent_tenant_id IS 'For temporary tenants, links to parent permanent tenant'; +-- Full-text search for marketplace directory +CREATE INDEX idx_tenants_search ON tenants + USING GIN(to_tsvector('english', + name || ' ' || + COALESCE(city, '') || ' ' || + COALESCE(country, '') + )); --- ----------------------------------------------------------------------------- --- Users Table --- ----------------------------------------------------------------------------- +COMMENT ON TABLE tenants IS 'Organizations in marketplace - discoverable without RLS (matches Go models.Tenant)'; +COMMENT ON COLUMN tenants.slug IS 'URL-friendly unique identifier generated from name'; +COMMENT ON COLUMN tenants.status IS 'active, inactive, suspended, deleted (NOT boolean is_active)'; + +-- ============================================================================= +-- SECTION 4: USERS TABLE +-- ============================================================================= +-- User accounts with complete profile support +-- ✅ Matches Go model exactly (first_name, last_name, full_name, etc.) +-- ✅ NO strict RLS - profiles discoverable in marketplace +-- ============================================================================= CREATE TABLE users ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - - -- Authentication - email VARCHAR(255) NOT NULL, - password_hash TEXT NOT NULL, - - -- Profile - name VARCHAR(255) NOT NULL, - avatar_url TEXT, - role user_role NOT NULL DEFAULT 'consumer', - - -- Status - is_active BOOLEAN NOT NULL DEFAULT true, - email_verified_at TIMESTAMPTZ, - last_login_at TIMESTAMPTZ, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT unique_email_per_tenant UNIQUE(tenant_id, email), - CONSTRAINT valid_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$') + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Authentication + email VARCHAR(255) NOT NULL, + password_hash TEXT NOT NULL, + + -- Profile (matches Go model - first_name, last_name, full_name) + first_name VARCHAR(255), + last_name VARCHAR(255), + full_name VARCHAR(255), -- Will be auto-generated via trigger + avatar_url TEXT, + phone VARCHAR(50), + + -- Role & permissions (matches Go model) + role user_role NOT NULL DEFAULT 'consumer', + + -- Account status (matches Go model - status string, NOT is_active boolean) + status VARCHAR(50) NOT NULL DEFAULT 'active', + email_verified BOOLEAN NOT NULL DEFAULT false, + email_verified_at TIMESTAMPTZ, + is_onboarded BOOLEAN NOT NULL DEFAULT false, + + -- Activity tracking (matches Go model - includes last_login_ip) + last_login_at TIMESTAMPTZ, + last_login_ip INET, + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT unique_email_per_tenant UNIQUE(tenant_id, email), + CONSTRAINT valid_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'), + CONSTRAINT chk_user_status CHECK ( + status IN ('active', 'inactive', 'suspended', 'pending_verification') + ) ); --- Indexes +-- Performance indexes CREATE INDEX idx_users_tenant ON users(tenant_id); CREATE INDEX idx_users_email ON users(email); CREATE INDEX idx_users_role ON users(role); -CREATE INDEX idx_users_active ON users(is_active) WHERE is_active = true; +CREATE INDEX idx_users_status ON users(status) WHERE status = 'active'; +CREATE INDEX idx_users_tenant_role ON users(tenant_id, role); --- Comments -COMMENT ON TABLE users IS 'User accounts with multi-tenant support'; -COMMENT ON CONSTRAINT unique_email_per_tenant ON users IS 'Email must be unique within a tenant, but can exist in multiple tenants'; +-- Full-text search for user discovery in marketplace +CREATE INDEX idx_users_search ON users + USING GIN(to_tsvector('english', + COALESCE(full_name, '') || ' ' || + COALESCE(first_name, '') || ' ' || + COALESCE(last_name, '') || ' ' || + email + )); --- ----------------------------------------------------------------------------- --- Contracts Table --- ----------------------------------------------------------------------------- +COMMENT ON TABLE users IS 'User accounts - profiles discoverable in marketplace (matches Go models.User)'; +COMMENT ON COLUMN users.full_name IS 'Auto-generated from first_name + last_name via trigger'; +COMMENT ON COLUMN users.status IS 'String status (active, inactive, suspended, pending_verification) NOT boolean'; +COMMENT ON COLUMN users.email_verified IS 'Boolean flag (separate from email_verified_at timestamp)'; + +-- ============================================================================= +-- SECTION 5: CONTRACTS TABLE +-- ============================================================================= +-- Agreements between vendors and consumers +-- ✅ RLS allows BOTH vendor and consumer tenants to access (collaboration-aware) +-- ============================================================================= CREATE TABLE contracts ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - - -- Parties - vendor_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - consumer_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - - -- Details - title VARCHAR(500) NOT NULL, - description TEXT, - status contract_status NOT NULL DEFAULT 'draft', - - -- Dates - start_date DATE NOT NULL, - end_date DATE NOT NULL, - - -- Financial - total_amount NUMERIC(12,2) NOT NULL DEFAULT 0.00, - currency VARCHAR(3) NOT NULL DEFAULT 'USD', - - -- Version control (optimistic locking) - version INTEGER NOT NULL DEFAULT 1, - - -- Metadata - created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT valid_dates CHECK (end_date > start_date), - CONSTRAINT valid_amount CHECK (total_amount >= 0), - CONSTRAINT different_parties CHECK (vendor_id != consumer_id) + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Parties (vendor provides service, consumer receives it) + vendor_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + consumer_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + + -- Contract details + title VARCHAR(500) NOT NULL, + description TEXT, + status contract_status NOT NULL DEFAULT 'draft', + + -- Timeline + start_date DATE NOT NULL, + end_date DATE NOT NULL, + + -- Financial terms + total_amount NUMERIC(12,2) NOT NULL DEFAULT 0.00, + currency VARCHAR(3) NOT NULL DEFAULT 'USD', + + -- Concurrency control + version INTEGER NOT NULL DEFAULT 1, + + -- Audit fields + created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT valid_dates CHECK (end_date > start_date), + CONSTRAINT valid_amount CHECK (total_amount >= 0), + CONSTRAINT different_parties CHECK (vendor_id != consumer_id) ); --- Indexes +-- Performance indexes CREATE INDEX idx_contracts_tenant ON contracts(tenant_id); CREATE INDEX idx_contracts_vendor ON contracts(vendor_id); CREATE INDEX idx_contracts_consumer ON contracts(consumer_id); CREATE INDEX idx_contracts_status ON contracts(status); CREATE INDEX idx_contracts_dates ON contracts(start_date, end_date); -CREATE INDEX idx_contracts_search ON contracts USING GIN(to_tsvector('english', title || ' ' || COALESCE(description, ''))); --- Comments -COMMENT ON TABLE contracts IS 'Agreements between vendors and consumers'; -COMMENT ON COLUMN contracts.version IS 'For optimistic locking - increment on each update'; +-- Full-text search +CREATE INDEX idx_contracts_search ON contracts + USING GIN(to_tsvector('english', title || ' ' || COALESCE(description, ''))); --- ----------------------------------------------------------------------------- --- Deliverables Table --- ----------------------------------------------------------------------------- +COMMENT ON TABLE contracts IS 'Vendor-consumer agreements with collaboration-aware RLS (both parties can access)'; +COMMENT ON COLUMN contracts.tenant_id IS 'Primary tenant (usually vendor) but both vendor/consumer tenants have access'; + +-- ============================================================================= +-- SECTION 6: DELIVERABLES TABLE +-- ============================================================================= CREATE TABLE deliverables ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE, - - -- Details - title VARCHAR(500) NOT NULL, - description TEXT, - sequence_number INTEGER NOT NULL, - status deliverable_status NOT NULL DEFAULT 'pending', - - -- Dates - deadline DATE NOT NULL, - submitted_at TIMESTAMPTZ, - approved_at TIMESTAMPTZ, - - -- Submission - submitted_by UUID REFERENCES users(id) ON DELETE SET NULL, - approved_by UUID REFERENCES users(id) ON DELETE SET NULL, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT unique_sequence_per_contract UNIQUE(contract_id, sequence_number), - CONSTRAINT valid_sequence CHECK (sequence_number > 0) + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE, + + -- Deliverable details + title VARCHAR(500) NOT NULL, + description TEXT, + sequence_number INTEGER NOT NULL, + status deliverable_status NOT NULL DEFAULT 'pending', + + -- Timeline + deadline DATE NOT NULL, + submitted_at TIMESTAMPTZ, + approved_at TIMESTAMPTZ, + + -- Workflow tracking + submitted_by UUID REFERENCES users(id) ON DELETE SET NULL, + approved_by UUID REFERENCES users(id) ON DELETE SET NULL, + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT unique_sequence_per_contract UNIQUE(contract_id, sequence_number), + CONSTRAINT valid_sequence CHECK (sequence_number > 0) ); -- Indexes @@ -200,36 +321,35 @@ CREATE INDEX idx_deliverables_contract ON deliverables(contract_id); CREATE INDEX idx_deliverables_status ON deliverables(status); CREATE INDEX idx_deliverables_deadline ON deliverables(deadline); --- Comments -COMMENT ON TABLE deliverables IS 'Work items to be delivered as part of contracts'; -COMMENT ON COLUMN deliverables.sequence_number IS 'Order of deliverable in contract (1, 2, 3...)'; +COMMENT ON TABLE deliverables IS 'Work items within contracts - inherit collaboration from parent contract'; --- ----------------------------------------------------------------------------- --- Milestones Table --- ----------------------------------------------------------------------------- +-- ============================================================================= +-- SECTION 7: MILESTONES TABLE +-- ============================================================================= CREATE TABLE milestones ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - deliverable_id UUID NOT NULL REFERENCES deliverables(id) ON DELETE CASCADE, - - -- Details - title VARCHAR(500) NOT NULL, - type milestone_type NOT NULL, - condition_value VARCHAR(100) NOT NULL, - amount NUMERIC(12,2) NOT NULL DEFAULT 0.00, - status milestone_status NOT NULL DEFAULT 'pending', - - -- Tracking - completed_at TIMESTAMPTZ, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT valid_amount CHECK (amount >= 0) + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + deliverable_id UUID NOT NULL REFERENCES deliverables(id) ON DELETE CASCADE, + + -- Milestone configuration + title VARCHAR(500) NOT NULL, + type milestone_type NOT NULL, + condition_value VARCHAR(100) NOT NULL, + amount NUMERIC(12,2) NOT NULL DEFAULT 0.00, + status milestone_status NOT NULL DEFAULT 'pending', + + -- Completion tracking + completed_at TIMESTAMPTZ, + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT valid_milestone_amount CHECK (amount >= 0) ); -- Indexes @@ -237,36 +357,40 @@ CREATE INDEX idx_milestones_tenant ON milestones(tenant_id); CREATE INDEX idx_milestones_deliverable ON milestones(deliverable_id); CREATE INDEX idx_milestones_status ON milestones(status); --- Comments -COMMENT ON TABLE milestones IS 'Payment milestones within deliverables'; -COMMENT ON COLUMN milestones.type IS 'Determines how condition_value is interpreted'; -COMMENT ON COLUMN milestones.condition_value IS 'Date or duration depending on type'; +COMMENT ON TABLE milestones IS 'Payment milestones with flexible scheduling logic'; + +-- ============================================================================= +-- SECTION 8: SUPPORTING TABLES +-- ============================================================================= -- ----------------------------------------------------------------------------- --- Comments Table +-- Comments Table (Polymorphic - can comment on any entity) -- ----------------------------------------------------------------------------- CREATE TABLE comments ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - - -- Polymorphic relation - entity_type VARCHAR(50) NOT NULL, - entity_id UUID NOT NULL, - - -- Content - content TEXT NOT NULL, - - -- Author - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT valid_entity_type CHECK (entity_type IN ('contract', 'deliverable', 'milestone')) + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Polymorphic relationship + entity_type VARCHAR(50) NOT NULL, + entity_id UUID NOT NULL, + + -- Comment content + content TEXT NOT NULL, + + -- Author + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT valid_comment_entity_type CHECK ( + entity_type IN ('contract', 'deliverable', 'milestone') + ) ); -- Indexes @@ -275,45 +399,47 @@ CREATE INDEX idx_comments_entity ON comments(entity_type, entity_id); CREATE INDEX idx_comments_user ON comments(user_id); CREATE INDEX idx_comments_created ON comments(created_at DESC); --- Comments -COMMENT ON TABLE comments IS 'Discussion comments on various entities'; -COMMENT ON COLUMN comments.entity_type IS 'Type of entity: contract, deliverable, milestone'; -COMMENT ON COLUMN comments.entity_id IS 'ID of the entity (contract, deliverable, or milestone)'; +COMMENT ON TABLE comments IS 'Discussion comments on contracts, deliverables, milestones'; -- ----------------------------------------------------------------------------- --- Attachments Table +-- Attachments Table (File storage metadata) -- ----------------------------------------------------------------------------- CREATE TABLE attachments ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - - -- Polymorphic relation - entity_type VARCHAR(50) NOT NULL, - entity_id UUID NOT NULL, - - -- File details - filename VARCHAR(255) NOT NULL, - content_type VARCHAR(100) NOT NULL, - size BIGINT NOT NULL, - object_name TEXT NOT NULL, - - -- Status - status VARCHAR(20) NOT NULL DEFAULT 'pending', - - -- Tracking - uploaded_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - uploaded_at TIMESTAMPTZ, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT valid_size CHECK (size > 0), - CONSTRAINT valid_status CHECK (status IN ('pending', 'uploaded', 'processing', 'failed')), - CONSTRAINT valid_entity_type CHECK (entity_type IN ('contract', 'deliverable', 'milestone', 'comment')) + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Polymorphic relationship + entity_type VARCHAR(50) NOT NULL, + entity_id UUID NOT NULL, + + -- File details + filename VARCHAR(255) NOT NULL, + content_type VARCHAR(100) NOT NULL, + size BIGINT NOT NULL, + object_name TEXT NOT NULL, -- S3/MinIO object key + + -- Status tracking + status VARCHAR(20) NOT NULL DEFAULT 'pending', + + -- Upload tracking + uploaded_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + uploaded_at TIMESTAMPTZ, + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT valid_attachment_size CHECK (size > 0), + CONSTRAINT valid_attachment_status CHECK ( + status IN ('pending', 'uploaded', 'processing', 'failed') + ), + CONSTRAINT valid_attachment_entity_type CHECK ( + entity_type IN ('contract', 'deliverable', 'milestone', 'comment') + ) ); -- Indexes @@ -322,40 +448,35 @@ CREATE INDEX idx_attachments_entity ON attachments(entity_type, entity_id); CREATE INDEX idx_attachments_uploaded_by ON attachments(uploaded_by); CREATE INDEX idx_attachments_status ON attachments(status); --- Comments -COMMENT ON TABLE attachments IS 'File attachments for various entities'; -COMMENT ON COLUMN attachments.object_name IS 'Object key in MinIO/S3'; - --- ============================================================================= --- AUDIT TABLES --- ============================================================================= +COMMENT ON TABLE attachments IS 'File attachments stored in MinIO/S3'; -- ----------------------------------------------------------------------------- -- Audit Logs Table -- ----------------------------------------------------------------------------- CREATE TABLE audit_logs ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - - -- Action details - action VARCHAR(100) NOT NULL, - entity_type VARCHAR(50) NOT NULL, - entity_id UUID NOT NULL, - - -- Actor - actor_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - - -- Changes - old_values JSONB, - new_values JSONB, - - -- Context - ip_address INET, - user_agent TEXT, - - -- Timestamp - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Action details + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(50) NOT NULL, + entity_id UUID NOT NULL, + + -- Actor information + actor_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + + -- Change tracking + old_values JSONB, + new_values JSONB, + + -- Security context + ip_address INET, + user_agent TEXT, + + -- Timestamp + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Indexes @@ -366,31 +487,30 @@ CREATE INDEX idx_audit_action ON audit_logs(action); CREATE INDEX idx_audit_created ON audit_logs(created_at DESC); CREATE INDEX idx_audit_values ON audit_logs USING GIN(old_values, new_values); --- Comments -COMMENT ON TABLE audit_logs IS 'Audit trail of all important actions'; -COMMENT ON COLUMN audit_logs.action IS 'e.g., contract.created, deliverable.submitted'; +COMMENT ON TABLE audit_logs IS 'Immutable audit trail for compliance'; -- ----------------------------------------------------------------------------- -- Analytics Events Table -- ----------------------------------------------------------------------------- CREATE TABLE analytics_events ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, - - -- Event details - event_type VARCHAR(100) NOT NULL, - event_data JSONB NOT NULL DEFAULT '{}', - - -- User (nullable for anonymous events) - user_id UUID REFERENCES users(id) ON DELETE SET NULL, - - -- Context - ip_address INET, - user_agent TEXT, - - -- Timestamp - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, + + -- Event details + event_type VARCHAR(100) NOT NULL, + event_data JSONB NOT NULL DEFAULT '{}', + + -- User (nullable for anonymous events) + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + + -- Context + ip_address INET, + user_agent TEXT, + + -- Timestamp + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Indexes @@ -400,34 +520,34 @@ CREATE INDEX idx_analytics_user ON analytics_events(user_id); CREATE INDEX idx_analytics_created ON analytics_events(created_at DESC); CREATE INDEX idx_analytics_data ON analytics_events USING GIN(event_data); --- Comments COMMENT ON TABLE analytics_events IS 'User behavior and system events for analytics'; --- ============================================================================= --- NOTIFICATION TABLES --- ============================================================================= +-- ----------------------------------------------------------------------------- +-- Notifications Table +-- ----------------------------------------------------------------------------- CREATE TABLE notifications ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - - -- Recipient - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Content - type VARCHAR(50) NOT NULL, - title VARCHAR(255) NOT NULL, - message TEXT NOT NULL, - - -- Related entity - entity_type VARCHAR(50), - entity_id UUID, - - -- Status - read_at TIMESTAMPTZ, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Recipient + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Notification content + type VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + + -- Related entity (optional) + entity_type VARCHAR(50), + entity_id UUID, + + -- Read status + read_at TIMESTAMPTZ, + + -- Timestamp + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Indexes @@ -437,49 +557,72 @@ CREATE INDEX idx_notifications_unread ON notifications(user_id, read_at) WHERE r CREATE INDEX idx_notifications_entity ON notifications(entity_type, entity_id); CREATE INDEX idx_notifications_created ON notifications(created_at DESC); --- Comments -COMMENT ON TABLE notifications IS 'In-app notifications for users'; +COMMENT ON TABLE notifications IS 'In-app notifications with read/unread tracking'; -- ============================================================================= --- TRIGGERS FOR UPDATED_AT +-- SECTION 9: TRIGGERS FOR AUTOMATIC UPDATES -- ============================================================================= --- Function to update updated_at timestamp +-- ----------------------------------------------------------------------------- +-- Trigger: Auto-update updated_at column +-- ----------------------------------------------------------------------------- + CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN - NEW.updated_at = NOW(); - RETURN NEW; + NEW.updated_at = NOW(); + RETURN NEW; END; $$ LANGUAGE plpgsql; -- Apply to all tables with updated_at CREATE TRIGGER update_tenants_updated_at BEFORE UPDATE ON tenants - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_contracts_updated_at BEFORE UPDATE ON contracts - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_deliverables_updated_at BEFORE UPDATE ON deliverables - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_milestones_updated_at BEFORE UPDATE ON milestones - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_comments_updated_at BEFORE UPDATE ON comments - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_attachments_updated_at BEFORE UPDATE ON attachments - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- ----------------------------------------------------------------------------- +-- Trigger: Auto-generate full_name from first_name + last_name +-- ----------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION generate_full_name() +RETURNS TRIGGER AS $$ +BEGIN + NEW.full_name := TRIM( + COALESCE(NEW.first_name, '') || ' ' || COALESCE(NEW.last_name, '') + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_user_full_name + BEFORE INSERT OR UPDATE OF first_name, last_name ON users + FOR EACH ROW + EXECUTE FUNCTION generate_full_name(); + +COMMENT ON FUNCTION generate_full_name() IS 'Auto-generates full_name from first_name + last_name'; -- ============================================================================= --- ROW-LEVEL SECURITY +-- SECTION 10: ROW-LEVEL SECURITY (MARKETPLACE-AWARE) -- ============================================================================= --- Enable RLS on all tenant-scoped tables +-- Enable RLS on all tables ALTER TABLE tenants ENABLE ROW LEVEL SECURITY; ALTER TABLE users ENABLE ROW LEVEL SECURITY; ALTER TABLE contracts ENABLE ROW LEVEL SECURITY; @@ -491,47 +634,264 @@ ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY; ALTER TABLE analytics_events ENABLE ROW LEVEL SECURITY; ALTER TABLE notifications ENABLE ROW LEVEL SECURITY; --- Create policies -CREATE POLICY tenants_tenant_isolation ON tenants - USING (id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (id = current_setting('app.current_tenant_id', true)::UUID); +-- ----------------------------------------------------------------------------- +-- Tenants: Marketplace discovery (allow NULL tenant_id for registration) +-- ----------------------------------------------------------------------------- -CREATE POLICY users_tenant_isolation ON users - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +CREATE POLICY tenants_marketplace_access ON tenants + FOR ALL + USING ( + -- Own tenant + id = current_setting('app.current_tenant_id', true)::UUID + OR + -- Allow during registration (no tenant context yet) + current_setting('app.current_tenant_id', true) IS NULL + OR + -- Public tenants (for marketplace directory - all tenants visible) + status = 'active' + ) + WITH CHECK ( + id = current_setting('app.current_tenant_id', true)::UUID + OR + current_setting('app.current_tenant_id', true) IS NULL + ); -CREATE POLICY contracts_tenant_isolation ON contracts - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +COMMENT ON POLICY tenants_marketplace_access ON tenants IS + 'Allows marketplace discovery of active tenants + registration without tenant context'; -CREATE POLICY deliverables_tenant_isolation ON deliverables - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +-- ----------------------------------------------------------------------------- +-- Users: Marketplace discovery (vendor/consumer profiles visible) +-- ----------------------------------------------------------------------------- -CREATE POLICY milestones_tenant_isolation ON milestones - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +CREATE POLICY users_marketplace_access ON users + FOR ALL + USING ( + -- Own tenant users + tenant_id = current_setting('app.current_tenant_id', true)::UUID + OR + -- Allow during registration (no tenant context yet) + current_setting('app.current_tenant_id', true) IS NULL + OR + -- Active users from other tenants (marketplace discovery) + status = 'active' + ) + WITH CHECK ( + tenant_id = current_setting('app.current_tenant_id', true)::UUID + OR + current_setting('app.current_tenant_id', true) IS NULL + ); -CREATE POLICY comments_tenant_isolation ON comments - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +COMMENT ON POLICY users_marketplace_access ON users IS + 'Allows discovery of active users across tenants + registration flow'; -CREATE POLICY attachments_tenant_isolation ON attachments - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +-- ----------------------------------------------------------------------------- +-- Contracts: Collaboration-aware (both vendor and consumer can access) +-- ----------------------------------------------------------------------------- + +CREATE POLICY contracts_collaboration_access ON contracts + FOR ALL + USING ( + -- Vendor's tenant + EXISTS ( + SELECT 1 FROM users + WHERE users.id = contracts.vendor_id + AND users.tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + OR + -- Consumer's tenant + EXISTS ( + SELECT 1 FROM users + WHERE users.id = contracts.consumer_id + AND users.tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + OR + -- Allow during creation + current_setting('app.current_tenant_id', true) IS NULL + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM users + WHERE users.id = contracts.vendor_id + AND users.tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + OR + EXISTS ( + SELECT 1 FROM users + WHERE users.id = contracts.consumer_id + AND users.tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + OR + current_setting('app.current_tenant_id', true) IS NULL + ); + +COMMENT ON POLICY contracts_collaboration_access ON contracts IS + 'Allows both vendor and consumer tenants to access contract (marketplace collaboration)'; + +-- ----------------------------------------------------------------------------- +-- Deliverables: Inherit collaboration from parent contract +-- ----------------------------------------------------------------------------- + +CREATE POLICY deliverables_collaboration_access ON deliverables + FOR ALL + USING ( + EXISTS ( + SELECT 1 FROM contracts c + WHERE c.id = deliverables.contract_id + AND ( + EXISTS ( + SELECT 1 FROM users u + WHERE u.id = c.vendor_id + AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + OR + EXISTS ( + SELECT 1 FROM users u + WHERE u.id = c.consumer_id + AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + ) + ) + OR + current_setting('app.current_tenant_id', true) IS NULL + ); + +-- ----------------------------------------------------------------------------- +-- Milestones: Inherit collaboration via deliverable → contract +-- ----------------------------------------------------------------------------- + +CREATE POLICY milestones_collaboration_access ON milestones + FOR ALL + USING ( + EXISTS ( + SELECT 1 FROM deliverables d + JOIN contracts c ON c.id = d.contract_id + WHERE d.id = milestones.deliverable_id + AND ( + EXISTS ( + SELECT 1 FROM users u + WHERE u.id = c.vendor_id + AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + OR + EXISTS ( + SELECT 1 FROM users u + WHERE u.id = c.consumer_id + AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + ) + ) + OR + current_setting('app.current_tenant_id', true) IS NULL + ); + +-- ----------------------------------------------------------------------------- +-- Comments: Inherit collaboration from parent entity +-- ----------------------------------------------------------------------------- + +CREATE POLICY comments_collaboration_access ON comments + FOR ALL + USING ( + -- Comments on contracts + (entity_type = 'contract' AND + EXISTS ( + SELECT 1 FROM contracts c + WHERE c.id = comments.entity_id + AND ( + EXISTS (SELECT 1 FROM users u WHERE u.id = c.vendor_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID) + OR + EXISTS (SELECT 1 FROM users u WHERE u.id = c.consumer_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID) + ) + ) + ) + OR + -- Comments on deliverables + (entity_type = 'deliverable' AND + EXISTS ( + SELECT 1 FROM deliverables d + JOIN contracts c ON c.id = d.contract_id + WHERE d.id = comments.entity_id + AND ( + EXISTS (SELECT 1 FROM users u WHERE u.id = c.vendor_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID) + OR + EXISTS (SELECT 1 FROM users u WHERE u.id = c.consumer_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID) + ) + ) + ) + OR + -- Comments on milestones + (entity_type = 'milestone' AND + EXISTS ( + SELECT 1 FROM milestones m + JOIN deliverables d ON d.id = m.deliverable_id + JOIN contracts c ON c.id = d.contract_id + WHERE m.id = comments.entity_id + AND ( + EXISTS (SELECT 1 FROM users u WHERE u.id = c.vendor_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID) + OR + EXISTS (SELECT 1 FROM users u WHERE u.id = c.consumer_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID) + ) + ) + ) + OR + current_setting('app.current_tenant_id', true) IS NULL + ); + +-- ----------------------------------------------------------------------------- +-- Attachments: Similar to comments +-- ----------------------------------------------------------------------------- + +CREATE POLICY attachments_collaboration_access ON attachments + FOR ALL + USING ( + (entity_type = 'contract' AND + EXISTS ( + SELECT 1 FROM contracts c WHERE c.id = attachments.entity_id + AND ( + EXISTS (SELECT 1 FROM users u WHERE u.id = c.vendor_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID) + OR + EXISTS (SELECT 1 FROM users u WHERE u.id = c.consumer_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID) + ) + ) + ) + OR + current_setting('app.current_tenant_id', true) IS NULL + ); + +-- ----------------------------------------------------------------------------- +-- Audit Logs: Strict tenant isolation +-- ----------------------------------------------------------------------------- CREATE POLICY audit_logs_tenant_isolation ON audit_logs - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + USING ( + tenant_id = current_setting('app.current_tenant_id', true)::UUID + OR + current_setting('app.current_tenant_id', true) IS NULL + ); -CREATE POLICY analytics_events_tenant_isolation ON analytics_events - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID OR tenant_id IS NULL) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID OR tenant_id IS NULL); +-- ----------------------------------------------------------------------------- +-- Analytics: System-wide OR tenant-specific +-- ----------------------------------------------------------------------------- + +CREATE POLICY analytics_events_access ON analytics_events + USING ( + tenant_id = current_setting('app.current_tenant_id', true)::UUID + OR + tenant_id IS NULL + OR + current_setting('app.current_tenant_id', true) IS NULL + ); + +-- ----------------------------------------------------------------------------- +-- Notifications: Strict tenant isolation +-- ----------------------------------------------------------------------------- CREATE POLICY notifications_tenant_isolation ON notifications - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + USING ( + tenant_id = current_setting('app.current_tenant_id', true)::UUID + OR + current_setting('app.current_tenant_id', true) IS NULL + ); -- ============================================================================= --- END OF MIGRATION +-- END OF MIGRATION 000001_initial_schema.up.sql -- ============================================================================= \ No newline at end of file diff --git a/database/migrations/000002_sessions.down.sql b/database/migrations/000002_sessions.down.sql new file mode 100644 index 0000000..0136a7d --- /dev/null +++ b/database/migrations/000002_sessions.down.sql @@ -0,0 +1,98 @@ +-- ============================================================================= +-- AURGANIZE V6.2 - SESSIONS TABLE ROLLBACK +-- ============================================================================= +-- Migration: 000002_add_sessions (DOWN) +-- Description: Removes sessions table and related objects +-- Author: Aurganize Team +-- Date: 2025-12-11 +-- Version: 2.1 (Aligned to Tenant-less Sessions Model) +-- ============================================================================= +-- This rollback migration removes the sessions table and all associated +-- database objects (indexes, policies, constraints). +-- +-- CRITICAL WARNINGS: +-- 1. This PERMANENTLY DELETES all user sessions +-- 2. All users will be logged out immediately +-- 3. Active refresh tokens become invalid +-- 4. Cannot be undone without database backup +-- 5. Production impact: Users must re-login +-- +-- SAFE ROLLBACK ORDER: +-- 1. Disable RLS +-- 2. Drop policies +-- 3. Drop triggers (if any) +-- 4. Drop constraints (implicit via table drop) +-- 5. Drop indexes +-- 6. Drop table +-- ============================================================================= + + +-- ============================================================================= +-- SECTION 1: DISABLE ROW-LEVEL SECURITY +-- ============================================================================= + +ALTER TABLE IF EXISTS sessions DISABLE ROW LEVEL SECURITY; + + +-- ============================================================================= +-- SECTION 2: DROP RLS POLICIES +-- ============================================================================= +-- Note: Tenant isolation policy removed in Option B. Only user-based policy exists. + +DROP POLICY IF EXISTS sessions_user_isolation ON sessions; + + +-- ============================================================================= +-- SECTION 3: DROP TRIGGERS +-- ============================================================================= +-- The updated model does NOT include updated_at → no update trigger exists. +-- Still kept for safety in case older deployments had it. + +DROP TRIGGER IF EXISTS update_sessions_updated_at ON sessions; + + +-- ============================================================================= +-- SECTION 4: DROP INDEXES +-- ============================================================================= +-- Explicit drops included even though DROP TABLE will remove all dependent indexes. + +DROP INDEX IF EXISTS idx_sessions_user_id; +DROP INDEX IF EXISTS idx_sessions_active; +DROP INDEX IF EXISTS idx_sessions_expires_at; +DROP INDEX IF EXISTS idx_sessions_ip_address; +DROP INDEX IF EXISTS idx_sessions_last_used; + + +-- ============================================================================= +-- SECTION 5: DROP TABLE +-- ============================================================================= +-- CASCADE ensures removal of dependencies (FKs, RLS metadata, etc.) + +DROP TABLE IF EXISTS sessions CASCADE; + + +-- ============================================================================= +-- SECTION 6: VERIFICATION BLOCK +-- ============================================================================= + +DO $$ +DECLARE + table_exists BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'sessions' + ) INTO table_exists; + + IF table_exists THEN + RAISE WARNING 'WARNING: sessions table still exists after rollback!'; + ELSE + RAISE NOTICE 'SUCCESS: sessions table removed completely'; + END IF; +END $$; + + +-- ============================================================================= +-- END OF ROLLBACK MIGRATION 000002_add_sessions.down.sql +-- ============================================================================= diff --git a/database/migrations/000002_sessions.up.sql b/database/migrations/000002_sessions.up.sql new file mode 100644 index 0000000..51bd15a --- /dev/null +++ b/database/migrations/000002_sessions.up.sql @@ -0,0 +1,172 @@ +-- ============================================================================= +-- AURGANIZE V6.2 - SESSIONS TABLE (USER-ISOLATED, NO MULTI-TENANCY) +-- ============================================================================= +-- Migration: 000002_add_sessions +-- Description: Creates sessions table for JWT refresh token lifecycle +-- Author: Aurganize Team +-- Date: 2025-12-11 +-- Version: 2.1 (Aligned to Go Model, Tenant-less RLS Edition) +-- ============================================================================= +-- This migration creates the sessions table exactly matching Go models.Session. +-- Multi-tenant isolation is intentionally removed (no tenant_id column). +-- RLS still protects session rows, ensuring users cannot see other users’ data. +-- ============================================================================= + + +-- ============================================================================= +-- SECTION 1: SESSIONS TABLE +-- ============================================================================= + +CREATE TABLE sessions ( + -- ====================================================================== + -- IDENTITY COLUMNS + -- ====================================================================== + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- Purpose: Unique identifier for a session + -- Example: "a3bb189e-8bf9-4558-93c9-62cd9c8b9e5e" + + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- Purpose: Maps each session to a specific user + -- Cascade delete ensures all user sessions are removed when user is deleted + + -- ====================================================================== + -- AUTHENTICATION TOKENS + -- ====================================================================== + refresh_token_hash TEXT NOT NULL, + -- Purpose: Stores bcrypt/SHA256 hash of refresh token (never plaintext) + -- Security: Hash-only approach protects against DB compromise + + -- ====================================================================== + -- DEVICE + CLIENT METADATA + -- ====================================================================== + user_agent TEXT, + -- Purpose: Browser/device fingerprinting for security and activity display + + ip_address INET, + -- Purpose: Track login-origin IP for anomaly detection + + device_name TEXT, + -- Purpose: Optional user-friendly device label (e.g., "John's iPhone") + + device_type TEXT NOT NULL DEFAULT 'unknown', + -- Purpose: Categorize device ("mobile", "desktop", "web", "unknown") + + -- ====================================================================== + -- SESSION LIFECYCLE + -- ====================================================================== + expires_at TIMESTAMPTZ NOT NULL, + -- Purpose: Refresh token expiry timestamp + + is_revoked BOOLEAN NOT NULL DEFAULT FALSE, + -- Purpose: Marks a session as invalidated due to logout/security rules + + revoked_at TIMESTAMPTZ NULL, + -- Purpose: Timestamp of revocation; NULL means active session + + revoked_reason TEXT, + -- Purpose: Optional context ("logout", "password_change", "admin_action") + + -- ====================================================================== + -- AUDIT TIMESTAMPS + -- ====================================================================== + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- Purpose: Login timestamp (never updated) + + last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + -- Purpose: Updated on every refresh token usage +); + +-- ============================================================================= +-- SECTION 2: INDEXES FOR PERFORMANCE & SECURITY +-- ============================================================================= + +-- Fast retrieval of all sessions for a user +CREATE INDEX idx_sessions_user_id ON sessions(user_id); + +-- Lookup active sessions quickly +CREATE INDEX idx_sessions_active + ON sessions(user_id, is_revoked) + WHERE is_revoked = FALSE; + +-- Cleanup expired sessions +CREATE INDEX idx_sessions_expires_at + ON sessions(expires_at) + WHERE is_revoked = FALSE; + +-- IP anomaly investigations +CREATE INDEX idx_sessions_ip_address + ON sessions(ip_address) + WHERE is_revoked = FALSE; + +-- Idle session detection +CREATE INDEX idx_sessions_last_used + ON sessions(last_used_at) + WHERE is_revoked = FALSE; + + +-- ============================================================================= +-- SECTION 3: AUTOMATIC TRIGGERS +-- ============================================================================= +-- Note: updated_at column removed because the Go model does not include it. +-- No trigger required. + + +-- ============================================================================= +-- SECTION 4: ROW LEVEL SECURITY (RLS) +-- ============================================================================= + +-- Enable row-level isolation +ALTER TABLE sessions ENABLE ROW LEVEL SECURITY; + +-- User-based isolation policy +CREATE POLICY sessions_user_isolation ON sessions + FOR ALL + USING ( + user_id = current_setting('app.current_user_id', true)::UUID + ) + WITH CHECK ( + user_id = current_setting('app.current_user_id', true)::UUID + ); + +COMMENT ON POLICY sessions_user_isolation ON sessions IS + 'Restricts all session operations to the authenticated user (no tenant-level RLS).'; + + +-- ============================================================================= +-- SECTION 5: TABLE CONSTRAINTS & VALIDATION +-- ============================================================================= + +-- Expiry must occur after creation +ALTER TABLE sessions ADD CONSTRAINT sessions_valid_expiry + CHECK (expires_at > created_at); + +-- Revoked_at cannot be before created_at +ALTER TABLE sessions ADD CONSTRAINT sessions_valid_revocation + CHECK (revoked_at IS NULL OR revoked_at >= created_at); + + +-- ============================================================================= +-- SECTION 6: COMMENTS FOR DOCUMENTATION +-- ============================================================================= + +COMMENT ON TABLE sessions IS + 'User authentication sessions with refresh token hashes. Exact match to Go models.Session (tenant-less).'; + +COMMENT ON COLUMN sessions.id IS 'Unique session ID (UUID v4).'; +COMMENT ON COLUMN sessions.user_id IS 'User who owns this session.'; +COMMENT ON COLUMN sessions.refresh_token_hash IS 'Hash of refresh token (never store plaintext).'; +COMMENT ON COLUMN sessions.user_agent IS 'Client user-agent string.'; +COMMENT ON COLUMN sessions.ip_address IS 'IP address at session creation.'; +COMMENT ON COLUMN sessions.device_name IS 'Optional user-friendly device name.'; +COMMENT ON COLUMN sessions.device_type IS 'Device category: mobile/desktop/web.'; +COMMENT ON COLUMN sessions.expires_at IS 'Refresh token expiration timestamp.'; +COMMENT ON COLUMN sessions.is_revoked IS 'TRUE when session has been explicitly revoked.'; +COMMENT ON COLUMN sessions.revoked_at IS 'Timestamp of revocation event.'; +COMMENT ON COLUMN sessions.revoked_reason IS 'Reason for revocation.'; +COMMENT ON COLUMN sessions.created_at IS 'Timestamp when session was created.'; +COMMENT ON COLUMN sessions.last_used_at IS 'Timestamp of last refresh token usage.'; + + +-- ============================================================================= +-- END OF MIGRATION 000002_add_sessions.up.sql +-- ============================================================================= diff --git a/infrastructure/docker/docker-compose.yml b/infrastructure/docker/docker-compose.yml index 9720c76..632582c 100644 --- a/infrastructure/docker/docker-compose.yml +++ b/infrastructure/docker/docker-compose.yml @@ -94,55 +94,55 @@ services: dockerfile: Dockerfile.dev container_name: aurganize-backend restart: unless-stopped - environment: - # Application config - ENV: development - PORT: 8080 + # environment: + # # Application config + # ENV: development + # PORT: 8080 - # Database connection - DB_HOST: postgres - DB_PORT: 5432 - DB_USER: aurganize - DB_PASSWORD: dev_password_change_in_prod - DB_NAME: aurganize - DB_SSL_MODE: disable + # # Database connection + # DB_HOST: postgres + # DB_PORT: 5432 + # DB_USER: aurganize + # DB_PASSWORD: dev_password_change_in_prod + # DB_NAME: aurganize + # DB_SSL_MODE: disable - # Redis connection - REDIS_HOST: redis - REDIS_PORT: 6379 - REDIS_PASSWORD: "" + # # Redis connection + # REDIS_HOST: redis + # REDIS_PORT: 6379 + # REDIS_PASSWORD: "" - # NATS connection - NATS_URL: nats://nats:4222 + # # NATS connection + # NATS_URL: nats://nats:4222 - # MinIO connection - MINIO_ENDPOINT: minio:9000 - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin - MINIO_USE_SSL: false + # # MinIO connection + # MINIO_ENDPOINT: minio:9000 + # MINIO_ACCESS_KEY: minioadmin + # MINIO_SECRET_KEY: minioadmin + # MINIO_USE_SSL: false - # JWT secrets (development only) - JWT_SECRET: dev-secret-change-in-production + # # JWT secrets (development only) + # JWT_SECRET: dev-secret-change-in-production - # CORS settings - CORS_ORIGINS: http://localhost:3000 + # # CORS settings + # CORS_ORIGINS: http://localhost:3000 ports: - "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 diff --git a/infrastructure/docker/init-scripts/06-grant-bypassrls.sql b/infrastructure/docker/init-scripts/06-grant-bypassrls.sql new file mode 100644 index 0000000..1440c6c --- /dev/null +++ b/infrastructure/docker/init-scripts/06-grant-bypassrls.sql @@ -0,0 +1,111 @@ +-- ========================================== +-- 06: GRANT BYPASSRLS TO BACKEND USER +-- ========================================== +-- This script grants Row-Level Security bypass privilege +-- Required for registration flow (tenant + user creation in same transaction) +-- Runs as: postgres (superuser) + +\echo '🔓 Granting BYPASSRLS privilege...' + +-- ========================================== +-- WHY BYPASSRLS IS NECESSARY +-- ========================================== +-- During registration, we create tenant and user in a single transaction: +-- +-- BEGIN; +-- INSERT INTO tenants (...) VALUES (...); -- Creates tenant +-- INSERT INTO users (tenant_id, ...) VALUES (...); -- References tenant +-- COMMIT; +-- +-- PROBLEM WITHOUT BYPASSRLS: +-- - PostgreSQL validates foreign key (users.tenant_id → tenants.id) +-- - Foreign key check runs: SELECT 1 FROM tenants WHERE id = ? +-- - RLS policy blocks this SELECT (no tenant context during registration) +-- - Foreign key check fails: "violates foreign key constraint" +-- - Transaction rolls back +-- +-- SOLUTION WITH BYPASSRLS: +-- - Backend user can see ALL rows during registration +-- - Foreign key check succeeds (tenant visible immediately) +-- - Transaction commits successfully +-- - Regular operations still protected by RLS (when app.current_tenant_id is set) +-- ========================================== + +-- Grant BYPASSRLS to backend API user +ALTER USER aurganize_backend_api WITH BYPASSRLS; + +\echo ' ✅ BYPASSRLS privilege granted to aurganize_backend_api' + +-- ========================================== +-- VERIFY PRIVILEGE GRANTED +-- ========================================== +DO $$ +DECLARE + has_bypassrls BOOLEAN; + user_privileges TEXT; +BEGIN + -- Check if BYPASSRLS was granted + SELECT rolbypassrls INTO has_bypassrls + FROM pg_roles + WHERE rolname = 'aurganize_backend_api'; + + IF has_bypassrls THEN + RAISE NOTICE ' ✅ Verification: BYPASSRLS is active'; + ELSE + RAISE WARNING ' ❌ Verification: BYPASSRLS not active!'; + RAISE EXCEPTION 'Failed to grant BYPASSRLS privilege'; + END IF; + + -- Build privilege summary + SELECT CASE + WHEN rolsuper THEN '🔴 SUPERUSER' + WHEN rolbypassrls THEN '🟡 RLS BYPASS' + ELSE '🟢 STANDARD' + END INTO user_privileges + FROM pg_roles + WHERE rolname = 'aurganize_backend_api'; + + RAISE NOTICE ' 🔐 Privilege Level: %', user_privileges; +END $$; + +-- ========================================== +-- DISPLAY FINAL USER CONFIGURATION +-- ========================================== +\echo '' +\echo '📋 Final user configuration:' + +SELECT + rolname AS "Username", + rolcanlogin AS "Can Login", + rolsuper AS "Superuser", + rolbypassrls AS "Bypass RLS", + rolconnlimit AS "Conn Limit", + CASE + WHEN rolsuper THEN '🔴 Full Access' + WHEN rolbypassrls THEN '🟡 RLS Bypass (for registration)' + ELSE '🟢 Standard (RLS enforced)' + END AS "Access Level" +FROM pg_roles +WHERE rolname = 'aurganize_backend_api'; + +-- ========================================== +-- SECURITY NOTES +-- ========================================== +\echo '' +\echo '==========================================' +\echo '🔒 SECURITY NOTES' +\echo '==========================================' +\echo '' +\echo '✅ SAFE USAGE:' +\echo ' - Backend sets app.current_tenant_id for regular operations' +\echo ' - RLS still protects all normal CRUD operations' +\echo ' - Only registration flow runs without tenant context' +\echo ' - Audit logs capture all operations' +\echo '' +\echo '⚠️ IMPORTANT:' +\echo ' - BYPASSRLS only needed for registration endpoint' +\echo ' - All other operations MUST set tenant context' +\echo ' - Readonly user does NOT have BYPASSRLS' +\echo '' +\echo '✅ BYPASSRLS configuration complete!' +\echo '' \ No newline at end of file