Merge pull request #9 from creativenoz/develop

since foundational auth commit, im pulling the bare minimum auth framework code to main (neeeeds a lot of improvement before can be considered even prod or staging ready)
This commit is contained in:
Rezon Philip 2025-12-11 02:17:12 +05:30 committed by GitHub
commit 987ac80d58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 10725 additions and 658 deletions

View File

@ -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
# Delete tmp folder on exit
clean_on_exit = true
[screen]
# Clear screen on rebuild
clear_on_rebuild = true
# Enable or disable keep screen scrolling
keep_scroll = true

View File

@ -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)
# ==============================================================================
@ -53,3 +61,5 @@ MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=aurganize
MINIO_USE_SSL=false

View File

@ -2,7 +2,7 @@
# Development Dockerfile with hot reload support
# Stage 1: Base image with Go tools
FROM golang:1.21-alpine AS base
FROM golang:1.25-alpine AS base
# Install developement tools
# - git: Required for go get
@ -22,7 +22,7 @@ WORKDIR /app
FROM base as development
# Copy dependency files first (for layer caching)
COPY go.mod go.sum./
COPY go.mod go.sum ./
# Download dependencies
# This layer is cached unless go.mod or go.sum changes
@ -30,4 +30,11 @@ RUN go mod download
# Copy Air configuration
COPY .air.t
COPY .air.toml ./
# EXPOSE port 8080 for API
# This is documentation only - doesn't actually expose port
EXPOSE 8080
# Use Air for hot reload
# Air watches for file changes and recompiles automatically
CMD [ "air", "-c", ".air.toml" ]

View File

@ -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

View File

@ -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
)

View File

@ -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=

View File

@ -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)
}

View File

@ -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
}

View File

@ -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())
}

View File

@ -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
}
}

View File

@ -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 <token>" (for mobile/API clients)
//
// Why check cookie first?
// - More secure for browsers (HttpOnly prevents XSS)
// - Automatically sent by browsers
// - Primary method for web applications
//
// Response codes:
// - 200: Token valid, request proceeds to handler
// - 401: Missing token, invalid token, or expired token
//
// What gets stored in context (accessible in handlers):
// - user_id: UUID of authenticated user
// - tenant_id: UUID of user's organization/tenant
// - email: User's email address
// - role: User's role (admin, user, etc.)
// - claims: Full claims object (all token data)
//
// Handler access example:
//
// userID := c.Get("user_id").(uuid.UUID)
// email := c.Get("email").(string)
// role := c.Get("role").(string)
//
// Error handling:
// - Missing token: "missing authentication token"
// - Invalid format: "invalid authorization header format"
// - Expired token: "token has expired" (client should refresh)
// - Invalid token: "invalid token" (signature/tampering)
//
// Parameters:
// - next: The next handler in the chain (the actual route handler)
//
// Returns:
// - HandlerFunc that wraps the next handler with authentication
//
// Usage:
//
// // Protect single route
// e.GET("/profile", profileHandler, authMiddleware.Authenticate)
//
// // Protect route group
// protected := e.Group("/api", authMiddleware.Authenticate)
// protected.GET("/users", listUsers)
// protected.POST("/posts", createPost)
func (m *AuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
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 <token>"
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)
}
}

View File

@ -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,
})
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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

View File

@ -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[:])
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 <token> (for mobile/API clients)
//
// How authentication works:
// 1. authMiddleware.Authenticate extracts token
// 2. Validates token signature and expiration
// 3. Stores user claims in context (user_id, email, role, etc.)
// 4. Proceeds to handler if valid
// 5. Returns 401 if invalid/expired
//
// What handlers can access:
// userID := c.Get("user_id").(uuid.UUID)
// email := c.Get("email").(string)
// role := c.Get("role").(string)
// tenantID := c.Get("tenant_id").(uuid.UUID)
// claims := c.Get("claims").(*auth.AccessTokenClaims)
//
// Base path: /api/v1
// All routes in this group require authentication
protected := api.Group("")
// Apply authentication middleware to all routes in this group
// This means every route added to 'protected' will:
// 1. Check for access token
// 2. Validate token
// 3. Block request if invalid
// 4. Store user info in context if valid
protected.Use(authMiddleware.Authenticate)
// GET /api/v1/health
// Health check endpoint that returns server status and user info
//
// What it does:
// 1. Checks that server is running
// 2. Verifies authentication middleware works
// 3. Returns authenticated user's email
//
// Why a protected health check?
// - Verifies entire auth pipeline works
// - Tests token validation
// - Confirms middleware is properly applied
// - Useful for monitoring authenticated endpoints
//
// Request:
// GET /api/v1/health
// Cookie: access_token=eyJhbGci...
// OR
// Authorization: Bearer eyJhbGci...
//
// Response (Success - 200):
// Body: {
// "status": "ok",
// "user": "user@example.com" // Email of authenticated user
// }
//
// Response (Error - 401):
// Body: { "message": "missing authentication token" }
// OR: { "message": "token has expired" }
// OR: { "message": "invalid token" }
//
// Note: This is a simple inline handler for demonstration
// Production endpoints should use dedicated handler functions
//
// Type assertion: c.Get("email").(string)
// - c.Get() returns interface{} (any type)
// - .(string) asserts that value is a string
// - Safe because middleware guarantees email is set as string
// - Will panic if middleware didn't set email (which shouldn't happen)
protected.GET("/health", func(c echo.Context) error {
return c.JSON(200, map[string]interface{}{
"status": "ok",
"user": c.Get("email").(string),
})
})
// ============================================================================
// FUTURE ROUTE GROUPS
// ============================================================================
//
// As the application grows, add more route groups here:
//
// // User management routes (protected)
// users := protected.Group("/users")
// users.GET("", userHandler.List) // GET /api/v1/users
// users.GET("/:id", userHandler.GetByID) // GET /api/v1/users/:id
// users.PUT("/:id", userHandler.Update) // PUT /api/v1/users/:id
// users.DELETE("/:id", userHandler.Delete) // DELETE /api/v1/users/:id
//
// // Project routes (protected)
// projects := protected.Group("/projects")
// projects.GET("", projectHandler.List) // GET /api/v1/projects
// projects.POST("", projectHandler.Create) // POST /api/v1/projects
// projects.GET("/:id", projectHandler.GetByID) // GET /api/v1/projects/:id
//
// // Admin routes (protected + role check)
// admin := protected.Group("/admin")
// admin.Use(middleware.RequireRole("admin")) // Extra middleware for role check
// admin.GET("/users", adminHandler.ListAllUsers)
// admin.POST("/users/:id/suspend", adminHandler.SuspendUser)
//
// // Public routes (optional auth - shows different content if logged in)
// public := api.Group("")
// public.Use(authMiddleware.OptionalAuth)
// public.GET("/posts", postHandler.List) // Shows public + user's private posts if authenticated
//
// // Webhook routes (API key auth instead of JWT)
// webhooks := api.Group("/webhooks")
// webhooks.Use(webhookMiddleware.ValidateSignature)
// webhooks.POST("/github", webhookHandler.HandleGitHub)
tenants := protected.Group("/tenants")
tenants.GET("/mine", tenantHandler.GetMyTenant)
tenants.GET("/:id", tenantHandler.GetTenant)
}

File diff suppressed because it is too large Load Diff

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

101
backend/pkg/auth/claims.go Normal file
View File

@ -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
}

128
backend/pkg/slug/slug.go Normal file
View File

@ -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
}

View File

@ -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 <<EOF
{
"email": "$TEST_EMAIL",
"password": "$TEST_PASSWORD",
"first_name": "$TEST_FIRST_NAME",
"last_name": "$TEST_LAST_NAME",
"tenant_name": "$TEST_TENANT_NAME"
}
EOF
)
echo "Request: POST /api/v1/auth/register"
echo "Payload:"
echo "$REGISTER_PAYLOAD" | jq '.' 2>/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 <<EOF
{
"email": "weak-$(date +%s)@example.com",
"password": "weak",
"first_name": "Test",
"last_name": "User",
"tenant_name": "Test Tenant"
}
EOF
)
echo "Request: POST /api/v1/auth/register"
echo "Password: 'weak' (should fail validation)"
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/register" \
-H "Content-Type: application/json" \
-d "$WEAK_PASSWORD_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" = "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 <<EOF
{
"email": "$UNIQUE_EMAIL",
"password": "$TEST_PASSWORD",
"first_name": "Rate",
"last_name": "Test$i",
"tenant_name": "Rate Test $i"
}
EOF
)
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/register" \
-H "Content-Type: application/json" \
-d "$RATE_LIMIT_PAYLOAD")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
echo "Request $i: HTTP $HTTP_CODE"
if [ "$HTTP_CODE" = "429" ]; then
print_success "Rate limit triggered on request $i"
RATE_LIMITED=true
break
fi
sleep 0.5
done
if [ "$RATE_LIMITED" = false ]; then
print_warning "Rate limit not triggered (check rate limiter middleware)"
fi
echo ""
echo "Waiting 60 seconds for rate limit to reset..."
sleep 5 # Shortened for testing, normally would be 60
# ==============================================================================
# TEST 6: User Login
# ==============================================================================
print_test "6. User Login"
LOGIN_PAYLOAD=$(cat <<EOF
{
"email": "$TEST_EMAIL",
"password": "$TEST_PASSWORD"
}
EOF
)
echo "Request: POST /api/v1/auth/login"
echo "Credentials: $TEST_EMAIL / $TEST_PASSWORD"
echo ""
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/login" \
-H "Content-Type: application/json" \
-d "$LOGIN_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" = "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 <<EOF
{
"email": "$TEST_EMAIL",
"password": "WrongPassword123!"
}
EOF
)
echo "Request: POST /api/v1/auth/login"
echo "Using wrong password..."
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/login" \
-H "Content-Type: application/json" \
-d "$WRONG_PASSWORD_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" = "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 "=========================================="

View File

@ -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";
-- =============================================================================
-- 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
-- =============================================================================

File diff suppressed because it is too large Load Diff

View File

@ -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
-- =============================================================================

View File

@ -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
-- =============================================================================

View File

@ -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

View File

@ -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 ''