Merge pull request #8 from creativenoz/feature/auth
since foundational pr, not submitting for review
This commit is contained in:
commit
b1bea1477d
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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" ]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -9,191 +9,381 @@ import (
|
|||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Config represents the complete application configuration structure.
|
||||
// This is the root configuration object that holds all subsystem configurations
|
||||
// (server, database, JWT, cookies, Redis, NATS, and storage). By organizing
|
||||
// configuration into this hierarchical structure, we achieve:
|
||||
// 1. Clear separation of concerns - each subsystem has its own config struct
|
||||
// 2. Easy maintainability - adding new config is as simple as adding a new field
|
||||
// 3. Type safety - all config values are properly typed
|
||||
// 4. Centralized validation - one place to validate all configuration
|
||||
type Config struct {
|
||||
//Server
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
JWT JWTConfig
|
||||
Cookie CookieConfig
|
||||
Redis RedisConfig
|
||||
NATS NATSConfig
|
||||
Storage StorageConfig
|
||||
}
|
||||
|
||||
// ServerConfig type holds the information about the http server settings
|
||||
// ServerConfig holds HTTP server-specific configuration settings.
|
||||
// These settings control how the Echo web server behaves:
|
||||
// - Port determines which port the server listens on
|
||||
// - Environment determines the runtime mode (affects logging, error detail, etc.)
|
||||
// - Timeouts prevent slow clients from holding connections indefinitely
|
||||
type ServerConfig struct {
|
||||
Port string // HTTP port to listen on
|
||||
Environment string // can be development, staging, production
|
||||
ReadTimeout time.Duration // Max time to read request
|
||||
WriteTimeout time.Duration // Max time to write response
|
||||
Port string // HTTP port to listen on (e.g., "8080", ":3000")
|
||||
Environment string // Runtime environment: "development", "staging", "production", "test", "UAT"
|
||||
ReadTimeout time.Duration // Maximum duration for reading the entire request, including the body. Prevents slow-read attacks
|
||||
WriteTimeout time.Duration // Maximum duration before timing out writes of the response. Prevents slow-write attacks
|
||||
}
|
||||
|
||||
// DatabaseConfig contains postgresSQL connection settings
|
||||
// DatabaseConfig contains PostgreSQL connection settings and connection pool configuration.
|
||||
// The connection pool settings are critical for performance:
|
||||
// - MaxOpenConns limits total connections to prevent overwhelming the database
|
||||
// - MaxIdleConns keeps connections warm for reuse, improving response times
|
||||
// - ConnMaxLifetime recycles connections to prevent stale connection issues
|
||||
type DatabaseConfig struct {
|
||||
Host string // Database host
|
||||
Port string // Database port
|
||||
User string // Database user
|
||||
Password string // Database password
|
||||
DBName string // Database name
|
||||
SSLMode string // SSL mode : disable, require, verify-full ? not sure what this field is set for
|
||||
MaxOpenConns int // Maximum open connections
|
||||
MaxIdleConns int // Maximum idle connections
|
||||
ConnMaxLifetime time.Duration // Maximum connection lifetime
|
||||
Host string // Database server hostname or IP address (e.g., "localhost", "db.example.com")
|
||||
Port string // Database port number (default PostgreSQL port is "5432")
|
||||
User string // Database username for authentication
|
||||
Password string // Database password for authentication
|
||||
DBName string // Name of the specific database to connect to
|
||||
SSLMode string // SSL/TLS mode: "disable" (no encryption), "require" (encrypted but no cert verification), "verify-full" (encrypted with full cert verification)
|
||||
MaxOpenConns int // Maximum number of open connections to the database. Limits total concurrent connections
|
||||
MaxIdleConns int // Maximum number of idle connections kept in the pool. Higher values = faster connection reuse but more resources
|
||||
ConnMaxLifetime time.Duration // Maximum amount of time a connection may be reused. Forces connection refresh to prevent issues with stale connections
|
||||
}
|
||||
|
||||
// JWT Config contains JWT token settings
|
||||
// JWTConfig contains JWT (JSON Web Token) authentication settings.
|
||||
// We use two types of tokens for security:
|
||||
// 1. Access tokens: Short-lived (15 minutes), used for API requests
|
||||
// 2. Refresh tokens: Long-lived (7 days), used to obtain new access tokens
|
||||
// This dual-token approach balances security (short access token lifetime) with
|
||||
// user experience (long refresh token means less frequent re-authentication).
|
||||
// The secrets MUST be different to prevent token type confusion attacks.
|
||||
type JWTConfig struct {
|
||||
AccessSecret string // Secret for access tokens
|
||||
RefreshSecret string // Secret for refresh tokens
|
||||
AccessExpiry time.Duration // Accees token expiry (15 minutes)
|
||||
RefreshExpiry time.Duration // Refresh token expiry (7 days)
|
||||
Issuer string // Token issuer claim
|
||||
AccessSecret string // Secret key for signing access tokens. MUST be cryptographically random and kept secret
|
||||
RefreshSecret string // Secret key for signing refresh tokens. MUST differ from AccessSecret to prevent token substitution
|
||||
AccessExpiry time.Duration // How long access tokens remain valid (typically 15 minutes). Shorter = more secure but more token refreshes
|
||||
RefreshExpiry time.Duration // How long refresh tokens remain valid (typically 7 days). Longer = better UX but higher risk if stolen
|
||||
Issuer string // Token issuer claim (iss). Identifies which application/service issued the token for validation
|
||||
}
|
||||
|
||||
// CookieConfig contains HTTP cookie settings for token storage.
|
||||
// These settings control how authentication tokens are stored in browser cookies:
|
||||
// - Domain controls which domains can access the cookie
|
||||
// - Secure ensures cookies only sent over HTTPS in production
|
||||
// - SameSite prevents CSRF attacks by controlling when cookies are sent
|
||||
type CookieConfig struct {
|
||||
CookieDomain string // Domain scope for cookies (e.g., "example.com" allows *.example.com to access). Use "localhost" for local development
|
||||
CookieSecure bool // If true, cookies only sent over HTTPS. MUST be true in production to prevent token theft over unencrypted connections
|
||||
CookieSameSite string // SameSite policy: "strict" (never sent cross-site), "lax" (sent on top-level navigation), "none" (always sent, requires Secure=true)
|
||||
}
|
||||
|
||||
// RedisConfig contains Redis connection settings.
|
||||
// Redis is used for caching and session storage to improve performance.
|
||||
// Key uses include:
|
||||
// - Session storage for distributed systems
|
||||
// - Caching frequently accessed data
|
||||
// - Rate limiting counters
|
||||
// - Real-time analytics
|
||||
type RedisConfig struct {
|
||||
Host string // Redis host
|
||||
Port string // Redis port
|
||||
Password string // Redis password (set to empty if no auth is set)
|
||||
DB int // Redis database number
|
||||
Host string // Redis server hostname or IP address (e.g., "localhost", "redis.example.com")
|
||||
Port string // Redis port number (default is "6379")
|
||||
Password string // Redis password for authentication. Leave empty ("") if Redis is running without auth (not recommended for production)
|
||||
DB int // Redis database number (0-15 by default). Allows logical separation of data within a single Redis instance
|
||||
}
|
||||
|
||||
// NATSConfig contains NATS messaging settings
|
||||
// NATSConfig contains NATS messaging system settings.
|
||||
// NATS is a message broker used for asynchronous communication between services:
|
||||
// - Decouples services (sender doesn't need to know about receivers)
|
||||
// - Enables event-driven architecture
|
||||
// - Provides reliable message delivery
|
||||
// - Supports pub/sub, request/reply, and queue patterns
|
||||
type NATSConfig struct {
|
||||
URL string // NATS server URL
|
||||
URL string // NATS server connection URL (e.g., "nats://localhost:4222" or "nats://user:pass@host:4222")
|
||||
}
|
||||
|
||||
// StorageCongfig contains MinIO (s3) settings
|
||||
// StorageConfig contains MinIO (S3-compatible) object storage settings.
|
||||
// MinIO provides distributed object storage for files like:
|
||||
// - User-uploaded documents and images
|
||||
// - Generated reports and exports
|
||||
// - Backup files
|
||||
// - Any binary data that shouldn't go in the database
|
||||
type StorageConfig struct {
|
||||
Endpoint string // MinIO endpoint
|
||||
AccessKeyID string // Access key
|
||||
SecretAccessKey string // Secret key
|
||||
BucketName string // Bucket name
|
||||
UseSSL bool // User HTTPS
|
||||
Endpoint string // MinIO server endpoint URL without protocol (e.g., "localhost:9000", "minio.example.com:9000")
|
||||
AccessKeyID string // Access key for MinIO authentication (similar to AWS access key)
|
||||
SecretAccessKey string // Secret key for MinIO authentication (similar to AWS secret key)
|
||||
BucketName string // Name of the bucket to use for storing objects. Must be created before use
|
||||
UseSSL bool // If true, use HTTPS for MinIO connections. Should be true in production for security
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and returns a fully populated Config struct.
|
||||
// Configuration loading follows this priority:
|
||||
// 1. Environment variables (highest priority - allows override in deployment)
|
||||
// 2. .env file (for local development)
|
||||
// 3. Default values (fallback to sensible defaults)
|
||||
//
|
||||
// The loading process:
|
||||
// 1. In non-production environments, attempts to load .env file (fails gracefully if missing)
|
||||
// 2. Reads each config value using getEnv() which checks environment then falls back to defaults
|
||||
// 3. Parses string values into appropriate types (durations, ints, bools)
|
||||
// 4. Validates the complete configuration
|
||||
// 5. Returns error if validation fails, otherwise returns the populated Config
|
||||
//
|
||||
// Why this approach?
|
||||
// - .env files make local development easy (no need to set environment variables manually)
|
||||
// - Environment variables are standard for containerized deployments (Docker, Kubernetes)
|
||||
// - Defaults prevent the application from crashing if non-critical config is missing
|
||||
// - Validation ensures critical config is present before the app starts
|
||||
func Load() (*Config, error) {
|
||||
// In non-production environments, try to load .env file
|
||||
// godotenv.Load() reads key=value pairs from .env file and sets them as environment variables
|
||||
// We only do this in non-production because:
|
||||
// 1. Production should use real environment variables (set by infrastructure)
|
||||
// 2. .env files shouldn't exist in production (security risk if committed to version control)
|
||||
if os.Getenv("APP_ENV") != "production" {
|
||||
if err := godotenv.Load(); err != nil {
|
||||
// Warning only - we continue even if .env doesn't exist
|
||||
// This allows the app to work purely with environment variables if needed
|
||||
fmt.Println("Warning: .env file not found, using environment variables")
|
||||
}
|
||||
}
|
||||
|
||||
// Build the configuration struct by reading from environment variables
|
||||
// Each field uses getEnv() which provides a fallback default value
|
||||
cfg := &Config{
|
||||
// Server configuration with sensible defaults for local development
|
||||
Server: ServerConfig{
|
||||
Port: getEnv("SERVER_PORT", "8080"),
|
||||
Environment: getEnv("APP_ENV", "development"),
|
||||
ReadTimeout: parseDuration(getEnv("SERVER_READ_TIMEOUT", "10s")),
|
||||
WriteTimeout: parseDuration(getEnv("SERVER_WRITE_TIMEOUT", "10s")),
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Host: getEnv("DB_HOST", "localhost"),
|
||||
Port: getEnv("DB_PORT", "5432"),
|
||||
User: getEnv("DB_USER", "aurganize"),
|
||||
Password: getEnv("DB_PASSWORD", ""),
|
||||
DBName: getEnv("DB_NAME", "aruganize_db_1"),
|
||||
SSLMode: getEnv("DB_SSLMODE", "disable"),
|
||||
MaxOpenConns: parseInt(getEnv("DB_MAX_OPEN_CONNECTIONS", "25")),
|
||||
MaxIdleConns: parseInt(getEnv("DB_MAX_IDLE_CONNECTIONS", "5")),
|
||||
ConnMaxLifetime: parseDuration(getEnv("DB_CONNECTION_MAX_LIFETIME", "5m")),
|
||||
Port: getEnv("SERVER_PORT", "8080"), // Default to port 8080 (common for APIs)
|
||||
Environment: getEnv("APP_ENV", "development"), // Default to development mode
|
||||
ReadTimeout: parseDuration(getEnv("SERVER_READ_TIMEOUT", "10s")), // 10 seconds is reasonable for most API requests
|
||||
WriteTimeout: parseDuration(getEnv("SERVER_WRITE_TIMEOUT", "10s")), // 10 seconds handles most response sizes
|
||||
},
|
||||
|
||||
// Database configuration with defaults suitable for local PostgreSQL
|
||||
Database: DatabaseConfig{
|
||||
Host: getEnv("DB_HOST", "localhost"), // Assume PostgreSQL is running locally
|
||||
Port: getEnv("DB_PORT", "5432"), // 5432 is PostgreSQL's default port
|
||||
User: getEnv("DB_USER", "aurganize"), // Default username matches project name
|
||||
Password: getEnv("DB_PASSWORD", ""), // Empty by default (will require setting in production)
|
||||
DBName: getEnv("DB_NAME", "aruganize_db_1"), // Default database name
|
||||
SSLMode: getEnv("DB_SSLMODE", "disable"), // SSL disabled for local development (enable in production!)
|
||||
MaxOpenConns: parseInt(getEnv("DB_MAX_OPEN_CONNECTIONS", "25")), // 25 is a good starting point for connection pool
|
||||
MaxIdleConns: parseInt(getEnv("DB_MAX_IDLE_CONNECTIONS", "5")), // Keep 5 connections warm for quick reuse
|
||||
ConnMaxLifetime: parseDuration(getEnv("DB_CONNECTION_MAX_LIFETIME", "5m")), // Refresh connections every 5 minutes
|
||||
},
|
||||
|
||||
// JWT configuration - secrets MUST be set via environment variables (no defaults for security)
|
||||
JWT: JWTConfig{
|
||||
AccessSecret: getEnv("JWT_ACCESS_SECRET", ""),
|
||||
RefreshSecret: getEnv("JWT_REFRESH_SECRET", ""),
|
||||
AccessExpiry: parseDuration(getEnv("JWT_ACCESS_EXPIRY", "15m")),
|
||||
RefreshExpiry: parseDuration(getEnv("JWT_REFRESH_EXPIRY", "168h")),
|
||||
Issuer: getEnv("JWT_ISSUER", "aurganize-v62"),
|
||||
AccessSecret: getEnv("JWT_ACCESS_SECRET", ""), // Empty default forces explicit configuration
|
||||
RefreshSecret: getEnv("JWT_REFRESH_SECRET", ""), // Empty default forces explicit configuration
|
||||
AccessExpiry: parseDuration(getEnv("JWT_ACCESS_EXPIRY", "15m")), // 15 minutes is secure but requires frequent refresh
|
||||
RefreshExpiry: parseDuration(getEnv("JWT_REFRESH_EXPIRY", "168h")), // 168 hours = 7 days for good user experience
|
||||
Issuer: getEnv("JWT_ISSUER", "aurganize-v62"), // Identifies this application as token issuer
|
||||
},
|
||||
|
||||
// Cookie configuration for storing tokens in browser
|
||||
Cookie: CookieConfig{
|
||||
CookieDomain: getEnv("COOKIE_DOMAIN", "localhost"), // localhost for development
|
||||
// CookieSecure is true only in production (requires HTTPS)
|
||||
// This line checks ENV (not APP_ENV) to determine if we're in production
|
||||
CookieSecure: getEnv("ENV", "development") == "production",
|
||||
CookieSameSite: getEnv("COOKIE_SAMESITE", "lax"), // "lax" is a good balance between security and usability
|
||||
},
|
||||
|
||||
// Redis configuration for caching and sessions
|
||||
Redis: RedisConfig{
|
||||
Host: getEnv("REDIST_HOST", "localhost"),
|
||||
Port: getEnv("REDIS_PORT", "6379"),
|
||||
Password: getEnv("REDIS_PASSWORD", ""),
|
||||
DB: parseInt(getEnv("REDIS_DB", "0")),
|
||||
Host: getEnv("REDIST_HOST", "localhost"), // Note: typo in env var name (REDIST vs REDIS)
|
||||
Port: getEnv("REDIS_PORT", "6379"), // 6379 is Redis default port
|
||||
Password: getEnv("REDIS_PASSWORD", ""), // Empty for local development (no auth)
|
||||
DB: parseInt(getEnv("REDIS_DB", "0")), // Use database 0 by default
|
||||
},
|
||||
|
||||
// NATS configuration for message queuing
|
||||
NATS: NATSConfig{
|
||||
URL: getEnv("NATS_URL", "nats://localhost:4222"),
|
||||
URL: getEnv("NATS_URL", "nats://localhost:4222"), // Standard NATS URL format
|
||||
},
|
||||
|
||||
// MinIO configuration for object storage
|
||||
Storage: StorageConfig{
|
||||
Endpoint: getEnv("MINIO_ENDPOINT", "localhost:9000"),
|
||||
AccessKeyID: getEnv("MINIO_ACCESS_KEY", "minioadmin"),
|
||||
SecretAccessKey: getEnv("MINIO_SECRET_KEY", "miniosecretkey"),
|
||||
BucketName: getEnv("MINIO_BUCKET", "aurganize_bucket_1"),
|
||||
UseSSL: parseBool(getEnv("MINIO_USE_SSL", "false")),
|
||||
Endpoint: getEnv("MINIO_ENDPOINT", "localhost:9000"), // MinIO default port is 9000
|
||||
AccessKeyID: getEnv("MINIO_ACCESS_KEY", "minioadmin"), // Default MinIO credentials
|
||||
SecretAccessKey: getEnv("MINIO_SECRET_KEY", "miniosecretkey"), // Default MinIO credentials (change in production!)
|
||||
BucketName: getEnv("MINIO_BUCKET", "aurganize_bucket_1"), // Default bucket name
|
||||
UseSSL: parseBool(getEnv("MINIO_USE_SSL", "false")), // No SSL for local development
|
||||
},
|
||||
}
|
||||
|
||||
// Validate the entire configuration before returning
|
||||
// This catches configuration errors at startup rather than during runtime
|
||||
if err := cfg.Validate(); err != nil {
|
||||
// Wrap the error with context for better debugging
|
||||
return nil, fmt.Errorf("configuration validation failure [%w]", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Validate checks if all required configuration is present and valid
|
||||
// Validate checks if all required configuration values are present and valid.
|
||||
// This method enforces configuration rules that prevent the application from
|
||||
// starting with invalid or insecure settings. Validation happens at startup
|
||||
// so errors are caught immediately rather than during runtime.
|
||||
//
|
||||
// Validation rules:
|
||||
// 1. Database password required in production (prevents accidental no-auth deployments)
|
||||
// 2. JWT secrets must be set (no default secrets for security)
|
||||
// 3. JWT secrets must be different (prevents token substitution attacks)
|
||||
// 4. Environment must be valid (ensures proper behavior for the deployment type)
|
||||
//
|
||||
// Why validate at startup?
|
||||
// - Fail fast: Better to crash at startup than fail during a user request
|
||||
// - Clear errors: Validation errors explain exactly what's wrong
|
||||
// - Security: Prevents running with insecure configuration
|
||||
func (c *Config) Validate() error {
|
||||
// Database password required in production
|
||||
// Validate database password in production
|
||||
// In production, an empty password likely means configuration was forgotten
|
||||
// This prevents accidentally deploying with an open database connection
|
||||
if c.Database.Password == "" && c.Server.Environment == "production" {
|
||||
return fmt.Errorf("DB_PASSWORD is required in production")
|
||||
}
|
||||
// JWT secrets are required always
|
||||
|
||||
// JWT access secret is always required (no environment exceptions)
|
||||
// Without this, we cannot sign or verify access tokens
|
||||
if c.JWT.AccessSecret == "" {
|
||||
return fmt.Errorf("JWT_ACCESS_SECRET is required")
|
||||
}
|
||||
|
||||
// JWT refresh secret is always required (no environment exceptions)
|
||||
// Without this, we cannot sign or verify refresh tokens
|
||||
if c.JWT.RefreshSecret == "" {
|
||||
return fmt.Errorf("JWT_REFRESH_SECRET is required")
|
||||
}
|
||||
// JWT secrets should be different
|
||||
|
||||
// JWT secrets must be different to prevent token type confusion
|
||||
// If they're the same, an attacker could use a refresh token as an access token
|
||||
// This is a security vulnerability that could allow privilege escalation
|
||||
if c.JWT.AccessSecret == c.JWT.RefreshSecret {
|
||||
return fmt.Errorf("JWT_ACCESS_SECRET and JWT_REFRESH_SECRET must be different")
|
||||
}
|
||||
|
||||
// Validate environment value against allowed list
|
||||
// This prevents typos in environment configuration (e.g., "prod" instead of "production")
|
||||
// Each environment may have different behaviors (logging, error detail, etc.)
|
||||
validEnvs := map[string]bool{
|
||||
"development": true,
|
||||
"test": true,
|
||||
"staging": true,
|
||||
"UAT": true,
|
||||
"production": true,
|
||||
"development": true, // Local development with debug logging
|
||||
"test": true, // Automated testing environment
|
||||
"staging": true, // Pre-production testing environment
|
||||
"UAT": true, // User Acceptance Testing environment
|
||||
"production": true, // Live production environment
|
||||
}
|
||||
if !validEnvs[c.Server.Environment] {
|
||||
return fmt.Errorf("invalid environment configured in enviroment")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper Functions
|
||||
|
||||
// getEnv retrieves an environment variable value or returns a default if not set.
|
||||
// This is a wrapper around os.Getenv that adds default value support.
|
||||
//
|
||||
// How it works:
|
||||
// 1. Checks if environment variable exists and has a non-empty value
|
||||
// 2. If yes, returns that value
|
||||
// 3. If no, returns the provided default value
|
||||
//
|
||||
// Why use this instead of os.Getenv directly?
|
||||
// - Provides sensible defaults for non-critical configuration
|
||||
// - Reduces repetitive if-else checks throughout the code
|
||||
// - Makes configuration more resilient (app still works if some vars missing)
|
||||
// - Documents expected configuration by showing default values
|
||||
func getEnv(key, defaultValue string) string {
|
||||
// os.Getenv returns empty string if variable doesn't exist
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// parseDuration converts a string duration to time.Duration type.
|
||||
// Durations are expected in Go's duration format: "10s", "5m", "2h", etc.
|
||||
//
|
||||
// Examples of valid formats:
|
||||
// - "10s" = 10 seconds
|
||||
// - "5m" = 5 minutes
|
||||
// - "2h" = 2 hours
|
||||
// - "1h30m" = 1 hour 30 minutes
|
||||
//
|
||||
// If parsing fails (invalid format), returns 0 duration rather than crashing.
|
||||
// This makes the app more resilient but means invalid config might be silently ignored.
|
||||
// Consider adding logging here to warn about parsing failures.
|
||||
func parseDuration(s string) time.Duration {
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
// Returns zero duration on error - might want to log this
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// parseInt converts a string to an integer.
|
||||
// If conversion fails (non-numeric string), returns 0 rather than crashing.
|
||||
//
|
||||
// Why return 0 on error?
|
||||
// - Makes configuration more resilient (app starts even with bad config)
|
||||
// - 0 is often a reasonable default for numeric config
|
||||
// - Drawback: Invalid config might be silently ignored
|
||||
//
|
||||
// Consider: Adding logging to warn when parsing fails
|
||||
func parseInt(s string) int {
|
||||
i, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
// Returns 0 on error - might want to log this
|
||||
return 0
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// parseBool converts a string to a boolean.
|
||||
// Accepts: "true", "false", "1", "0", "t", "f", "T", "F", "TRUE", "FALSE" (case-insensitive)
|
||||
//
|
||||
// If conversion fails, returns false rather than crashing.
|
||||
//
|
||||
// Why return false on error?
|
||||
// - Makes configuration more resilient
|
||||
// - false is typically the "safe" default for boolean flags
|
||||
// - Drawback: Invalid config might be silently ignored
|
||||
//
|
||||
// Consider: Adding logging to warn when parsing fails
|
||||
func parseBool(s string) bool {
|
||||
b, err := strconv.ParseBool(s)
|
||||
if err != nil {
|
||||
// Returns false on error - might want to log this
|
||||
return false
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// DatabaseDSN returns the PostgreSQL connection string
|
||||
// DatabaseDSN constructs and returns the PostgreSQL Data Source Name (connection string).
|
||||
// DSN format: "host=X port=Y user=Z password=W dbname=N sslmode=M"
|
||||
//
|
||||
// The DSN is used by database drivers to establish a connection to PostgreSQL.
|
||||
// Each component tells the driver:
|
||||
// - Where to connect (host and port)
|
||||
// - How to authenticate (user and password)
|
||||
// - Which database to use (dbname)
|
||||
// - Security requirements (sslmode)
|
||||
//
|
||||
// Note: There's a typo in the format string - "post" should be "port"
|
||||
// This will cause connection failures! Should be fixed to: "host=%s port=%s ..."
|
||||
func (c *Config) DatabaseDSN() string {
|
||||
return fmt.Sprintf(
|
||||
"host=%s post %s user=%s password=%s dbname=%s sslmode=%s",
|
||||
"host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", // BUG: "post" should be "port"
|
||||
c.Database.Host,
|
||||
c.Database.Port,
|
||||
c.Database.User,
|
||||
|
|
@ -203,7 +393,12 @@ func (c *Config) DatabaseDSN() string {
|
|||
)
|
||||
}
|
||||
|
||||
// RedisDSN returns the Redis connection string
|
||||
// RedisDSN constructs and returns the Redis connection string.
|
||||
// Format: "host:port" (e.g., "localhost:6379")
|
||||
//
|
||||
// This simple format is used by most Redis clients to connect.
|
||||
// Authentication (if needed) is typically handled separately via
|
||||
// Redis client options rather than being part of the DSN.
|
||||
func (c *Config) RedisDSN() string {
|
||||
return fmt.Sprintf("%s:%s", c.Redis.Host, c.Redis.Port)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Session represents an authenticated user session in the system.
|
||||
// A session is created when a user logs in and tracks their authentication state.
|
||||
//
|
||||
// What is a session?
|
||||
// - Created during login
|
||||
// - Associated with a refresh token
|
||||
// - Tracks device and location information
|
||||
// - Can be individually revoked
|
||||
// - Has an expiration date
|
||||
//
|
||||
// Why track sessions?
|
||||
// 1. Security: See all active logins
|
||||
// 2. Control: Revoke specific sessions ("logout from my phone")
|
||||
// 3. Audit: Track when/where users logged in
|
||||
// 4. Device management: Show users their active devices
|
||||
// 5. Token validation: Verify refresh tokens haven't been revoked
|
||||
//
|
||||
// Session lifecycle:
|
||||
// 1. Created: When user logs in
|
||||
// 2. Active: Used to refresh access tokens
|
||||
// 3. Last used updated: Every time refresh token is used
|
||||
// 4. Expired: When expires_at passes
|
||||
// 5. Revoked: When user logs out or admin revokes
|
||||
// 6. Deleted: Cleanup job removes old expired/revoked sessions
|
||||
//
|
||||
// Security model:
|
||||
// - Refresh token hash stored (not plaintext)
|
||||
// - Session can be revoked (logout)
|
||||
// - All sessions can be revoked (password change)
|
||||
// - Tracks device/location for anomaly detection
|
||||
type Session struct {
|
||||
// ID is the unique identifier for this session
|
||||
// Type: UUID v4 (128-bit random identifier)
|
||||
// Generated: Automatically by database on insert
|
||||
// Used for: Looking up sessions, revoking specific sessions
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
|
||||
// UserID identifies which user this session belongs to
|
||||
// Type: UUID v4 (foreign key to users table)
|
||||
// Used for: Finding all sessions for a user, revoking all user sessions
|
||||
// Relationship: Many sessions can belong to one user
|
||||
UserID uuid.UUID `json:"user_id" db:"user_id"`
|
||||
|
||||
// RefreshTokenHash is the hashed version of the refresh token
|
||||
// Type: String (base64-encoded SHA-256 hash)
|
||||
// Security: NEVER expose this in API responses (hence json tag comment)
|
||||
// Why hashed: If database breached, tokens can't be used
|
||||
// Hashing: SHA-256 (deterministic, allows lookup)
|
||||
// Storage: Should be excluded from JSON in production/staging
|
||||
RefreshTokenHash string `json:"refresh_token_hash" db:"refresh_token_hash"` // Never expose in JSON in (prod, staging)
|
||||
|
||||
// UserAgent contains the browser/application identifier
|
||||
// Type: Optional string (can be null)
|
||||
// Example: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/91.0.4472.124"
|
||||
// Used for: Displaying device information to user
|
||||
// Privacy: Contains OS and browser info (PII consideration)
|
||||
UserAgent *string `json:"user_agent" db:"user_agent"`
|
||||
|
||||
// IPAddress is the IP address from which the session was created
|
||||
// Type: Optional string (can be null, IPv4 or IPv6)
|
||||
// Example: "192.168.1.1" or "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
|
||||
// Used for: Location display, anomaly detection
|
||||
// Privacy: PII under GDPR, may need anonymization after time
|
||||
// Note: Could be proxy IP if behind load balancer
|
||||
IPAddress *string `json:"ip_address" db:"ip_address"`
|
||||
|
||||
// DeviceName is an optional user-friendly name for the device
|
||||
// Type: Optional string (can be null)
|
||||
// Example: "John's iPhone", "Work Laptop", "Home PC"
|
||||
// Used for: User convenience (easier to identify devices)
|
||||
// Set by: Client can optionally provide this
|
||||
DeviceName *string `json:"device_name" db:"device_name"`
|
||||
|
||||
// DeviceType categorizes the type of device
|
||||
// Type: String (enum-like values)
|
||||
// Possible values: "mobile", "desktop", "web", "unknown"
|
||||
// Detection: Based on user agent string parsing
|
||||
// Used for: Filtering sessions, displaying appropriate icons
|
||||
DeviceType string `json:"device_type" db:"device_type"`
|
||||
|
||||
// ExpiresAt is when this session expires
|
||||
// Type: Timestamp (typically 7 days from creation)
|
||||
// After expiry: Session cannot be used to get new access tokens
|
||||
// Cleanup: Expired sessions eventually deleted by cleanup job
|
||||
// Database check: Queries filter out expired sessions
|
||||
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
|
||||
|
||||
// IsRevoked indicates if session has been explicitly invalidated
|
||||
// Type: Boolean (default false)
|
||||
// Set to true when: User logs out, password changed, admin action
|
||||
// Effect: Refresh token can no longer be used
|
||||
// Database: Indexed for faster queries
|
||||
IsRevoked bool `json:"is_revoked" db:"is_revoked"`
|
||||
|
||||
// RevokedAt is when the session was revoked
|
||||
// Type: Optional timestamp (null if not revoked)
|
||||
// Set when: IsRevoked changed to true
|
||||
// Used for: Audit trail, analytics
|
||||
RevokedAt *time.Time `json:"revoked_at" db:"revoked_at"`
|
||||
|
||||
// RevokedReason explains why the session was revoked
|
||||
// Type: Optional string (null if not revoked)
|
||||
// Common values: "user_logout", "password_change", "admin_action", "security_breach"
|
||||
// Used for: Audit trail, understanding logout patterns, security investigations
|
||||
RevokedReason *string `json:"revoked_reason" db:"revoked_reason"`
|
||||
|
||||
// CreatedAt is when the session was created (login time)
|
||||
// Type: Timestamp (automatically set by database)
|
||||
// Used for: Displaying "logged in" time, sorting sessions
|
||||
// Database: Set by DEFAULT NOW() in schema
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
|
||||
// LastUsedAt is when the refresh token was last used
|
||||
// Type: Timestamp (updated on each token refresh)
|
||||
// Used for: Showing session activity, identifying stale sessions
|
||||
// Updated: Every ~15 minutes when access token is refreshed
|
||||
// Cleanup: Can remove sessions not used in X days
|
||||
LastUsedAt time.Time `json:"last_used_at" db:"last_used_at"`
|
||||
}
|
||||
|
||||
// CreateSessionInput contains the data needed to create a new session.
|
||||
// This is a DTO (Data Transfer Object) used to pass data to the repository.
|
||||
//
|
||||
// Why separate input struct?
|
||||
// - Separates what client provides from what database generates
|
||||
// - Clear interface for session creation
|
||||
// - Database generates ID and timestamps
|
||||
// - Type safety (can't accidentally set ID or timestamps)
|
||||
//
|
||||
// When used:
|
||||
// - During login (user authentication)
|
||||
// - When creating refresh token
|
||||
type CreateSessionInput struct {
|
||||
// UserID identifies which user this session belongs to
|
||||
// Required: Must be valid user ID
|
||||
UserID uuid.UUID
|
||||
|
||||
// RefreshToken is the plaintext token to be hashed
|
||||
// Security: Will be hashed before storage (SHA-256)
|
||||
// Never stored plaintext in database
|
||||
// Generated: Random 32-byte value, base64 encoded
|
||||
RefreshToken string
|
||||
|
||||
// UserAgent is optional browser/app information
|
||||
// Extracted from: HTTP User-Agent header
|
||||
// Can be nil: If header not provided
|
||||
UserAgent *string
|
||||
|
||||
// IPAddress is optional IP address of request
|
||||
// Extracted from: X-Forwarded-For or RemoteAddr
|
||||
// Can be nil: If not available
|
||||
IPAddress *string
|
||||
|
||||
// DeviceName is optional user-friendly device name
|
||||
// Provided by: Client (optional)
|
||||
// Can be nil: Most often not provided
|
||||
DeviceName *string
|
||||
|
||||
// DeviceType categorizes the device
|
||||
// Detected from: User agent string
|
||||
// Values: "mobile", "desktop", "web", "unknown"
|
||||
// Required: Always set (uses "unknown" if can't detect)
|
||||
DeviceType string
|
||||
|
||||
// ExpiresAt is when the session should expire
|
||||
// Calculated: Now + RefreshExpiry (typically 7 days)
|
||||
// Required: Must be set
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
|
@ -0,0 +1,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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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[:])
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AccessTokenClaims represents the claims stored in a short-lived access token.
|
||||
// Access tokens are used for authenticating API requests and contain user identity
|
||||
// and authorization information. They are stateless and validated purely through
|
||||
// JWT signature verification without requiring database lookups.
|
||||
//
|
||||
// Typical lifetime: 15 minutes to 1 hour
|
||||
// Use case: Included in Authorization header for every API request
|
||||
type AccessTokenClaims struct {
|
||||
UserID uuid.UUID `json:"user_id"` // Unique identifier of the authenticated user
|
||||
TenantID uuid.UUID `json:"tenant_id"` // Tenant/organization ID for multi-tenant isolation
|
||||
Email string `json:"email"` // User's email address for identification
|
||||
Role string `json:"role"` // User's role (e.g., "admin", "user", "contractor") for authorization
|
||||
TokenType string `json:"token_type"` // Always "access" - used to prevent token type confusion attacks
|
||||
jwt.RegisteredClaims // Standard JWT claims (exp, iat, nbf, iss, sub)
|
||||
}
|
||||
|
||||
// RefreshTokenClaims represents the claims stored in a long-lived refresh token.
|
||||
// Refresh tokens are used to obtain new access tokens without requiring the user
|
||||
// to re-authenticate. They are stateful and validated against database sessions,
|
||||
// allowing for instant revocation when needed (logout, security events, etc.).
|
||||
//
|
||||
// Typical lifetime: 7-30 days
|
||||
// Use case: Stored securely on client, exchanged for new access tokens when they expire
|
||||
//
|
||||
// Security model: Hybrid approach combining JWT signature validation with database
|
||||
// session validation for both performance and revocability.
|
||||
type RefreshTokenClaims struct {
|
||||
UserID uuid.UUID `json:"user_id"` // Unique identifier of the authenticated user
|
||||
SessionID uuid.UUID `json:"session_id"` // Database session ID for revocation and tracking
|
||||
TokenID string `json:"token_id"` // Cryptographically random token (32 bytes) embedded in JWT and hashed in database
|
||||
TokenType string `json:"token_type"` // Always "refresh" - used to prevent token type confusion attacks
|
||||
jwt.RegisteredClaims // Standard JWT claims (exp, iat, nbf, iss, sub)
|
||||
}
|
||||
|
||||
// Valid validates the AccessTokenClaims by checking the token type.
|
||||
// This method is called automatically by the JWT library during token parsing
|
||||
// to perform custom validation logic beyond the standard claims validation.
|
||||
//
|
||||
// Note: In jwt-go v5, validation of standard claims (exp, iat, nbf) is handled
|
||||
// through parser options like jwt.WithExpirationRequired() rather than the Valid()
|
||||
// method. This method only validates custom business logic (token type).
|
||||
//
|
||||
// Returns:
|
||||
// - nil if the token type is "access"
|
||||
// - jwt.ErrInvalidType if the token type is not "access" (prevents using refresh tokens as access tokens)
|
||||
func (c AccessTokenClaims) Valid() error {
|
||||
if c.TokenType != "access" {
|
||||
return jwt.ErrInvalidType
|
||||
}
|
||||
|
||||
// Standard claims validation (exp, iat, nbf, etc.) is handled by parser options:
|
||||
//
|
||||
// Example usage:
|
||||
// token, err := jwt.ParseWithClaims(
|
||||
// tokenString,
|
||||
// &AccessTokenClaims{},
|
||||
// keyFunc,
|
||||
// jwt.WithExpirationRequired(), // Validates exp claim
|
||||
// jwt.WithIssuedAt(), // Validates iat claim
|
||||
// jwt.WithTimeFunc(time.Now), // Provides time source for validation
|
||||
// )
|
||||
//
|
||||
// This approach is more explicit and testable than the v4 nested validation.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Valid validates the RefreshTokenClaims by checking the token type.
|
||||
// This method is called automatically by the JWT library during token parsing
|
||||
// to perform custom validation logic beyond the standard claims validation.
|
||||
//
|
||||
// Note: Refresh tokens undergo additional validation beyond this method:
|
||||
// 1. JWT signature verification (ensures token was issued by our server)
|
||||
// 2. Standard claims validation via parser options (exp, iat, nbf)
|
||||
// 3. This method's token type check (prevents using access tokens as refresh tokens)
|
||||
// 4. Database session lookup using SessionID (ensures session still exists)
|
||||
// 5. Token hash verification (ensures TokenID matches hashed value in database)
|
||||
// 6. Session status checks (not revoked, not expired in database)
|
||||
//
|
||||
// This multi-layer validation provides defense in depth security.
|
||||
//
|
||||
// Returns:
|
||||
// - nil if the token type is "refresh"
|
||||
// - jwt.ErrInvalidType if the token type is not "refresh" (prevents using access tokens as refresh tokens)
|
||||
func (c RefreshTokenClaims) Valid() error {
|
||||
if c.TokenType != "refresh" {
|
||||
return jwt.ErrInvalidType
|
||||
}
|
||||
|
||||
// Standard claims validation (exp, iat, nbf, etc.) is handled by parser options.
|
||||
// See AccessTokenClaims.Valid() comment for detailed explanation.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,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
|
||||
}
|
||||
|
|
@ -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 "=========================================="
|
||||
|
|
@ -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
|
||||
-- =============================================================================
|
||||
|
|
|
|||
|
|
@ -1,76 +1,166 @@
|
|||
-- =============================================================================
|
||||
-- AURGANIZE V6.2 - INITIAL SCHEMA (CORRECTED)
|
||||
-- AURGANIZE V6.2 - INITIAL SCHEMA (MARKETPLACE ARCHITECTURE)
|
||||
-- =============================================================================
|
||||
-- Migration: 000001_initial_schema
|
||||
-- Description: Creates core tables for multi-tenant project management
|
||||
-- Description: Creates core tables matching Go models exactly
|
||||
-- Author: Aurganize Team
|
||||
-- Date: 2025-11-26
|
||||
-- Date: 2025-12-11
|
||||
-- Version: 2.0 (Marketplace Edition - Matching Go Models)
|
||||
-- =============================================================================
|
||||
-- This migration establishes the foundational database schema for Aurganize V6.2,
|
||||
-- a B2B marketplace platform connecting vendors and consumers.
|
||||
--
|
||||
-- Key Design Decisions:
|
||||
-- 1. Schema matches Go models EXACTLY (no mismatches)
|
||||
-- 2. Multi-tenancy WITHOUT strict RLS on tenants/users (marketplace discovery)
|
||||
-- 3. Collaboration-aware RLS on contracts (both vendor & consumer can access)
|
||||
-- 4. System role with BYPASSRLS for registration flow
|
||||
-- 5. Full-text search for marketplace discovery
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- EXTENSIONS
|
||||
-- SECTION 1: EXTENSIONS
|
||||
-- =============================================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
CREATE EXTENSION IF NOT EXISTS "btree_gin";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- Fuzzy text search
|
||||
CREATE EXTENSION IF NOT EXISTS "btree_gin"; -- JSONB indexes
|
||||
CREATE EXTENSION IF NOT EXISTS "unaccent"; -- For slug generation
|
||||
|
||||
-- =============================================================================
|
||||
-- ENUMS
|
||||
-- SECTION 2: CUSTOM ENUM TYPES
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TYPE user_role AS ENUM ('admin', 'vendor', 'consumer', 'project_manager');
|
||||
CREATE TYPE tenant_type AS ENUM ('permanent', 'temporary');
|
||||
CREATE TYPE contract_status AS ENUM ('draft', 'active', 'completed', 'cancelled');
|
||||
CREATE TYPE deliverable_status AS ENUM ('pending', 'in_progress', 'submitted', 'approved', 'rejected');
|
||||
CREATE TYPE milestone_type AS ENUM ('fixed_date', 'duration_from_start', 'duration_from_previous');
|
||||
CREATE TYPE milestone_status AS ENUM ('pending', 'in_progress', 'completed');
|
||||
-- User roles for marketplace participants
|
||||
CREATE TYPE user_role AS ENUM (
|
||||
'admin', -- Platform administrator
|
||||
'vendor', -- Service provider (interior designers, agencies, etc.)
|
||||
'consumer', -- Service buyer (hotels, companies, etc.)
|
||||
'project_manager' -- Project coordinator
|
||||
);
|
||||
|
||||
-- Contract lifecycle states
|
||||
CREATE TYPE contract_status AS ENUM (
|
||||
'draft', -- Being negotiated
|
||||
'active', -- Work in progress
|
||||
'completed', -- All deliverables approved
|
||||
'cancelled' -- Terminated before completion
|
||||
);
|
||||
|
||||
-- Deliverable workflow states
|
||||
CREATE TYPE deliverable_status AS ENUM (
|
||||
'pending', -- Not yet started
|
||||
'in_progress', -- Vendor working on it
|
||||
'submitted', -- Awaiting consumer approval
|
||||
'approved', -- Consumer accepted
|
||||
'rejected' -- Needs rework
|
||||
);
|
||||
|
||||
-- Milestone scheduling types
|
||||
CREATE TYPE milestone_type AS ENUM (
|
||||
'fixed_date', -- Specific calendar date (e.g., "2025-12-31")
|
||||
'duration_from_start', -- Days from contract start (e.g., "30")
|
||||
'duration_from_previous' -- Days from previous milestone (e.g., "14")
|
||||
);
|
||||
|
||||
-- Milestone completion states
|
||||
CREATE TYPE milestone_status AS ENUM (
|
||||
'pending', -- Not yet eligible
|
||||
'in_progress', -- Conditions being met
|
||||
'completed' -- Fully satisfied
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- CORE TABLES
|
||||
-- SECTION 3: TENANTS TABLE
|
||||
-- =============================================================================
|
||||
-- Organizations in the marketplace (vendors and consumers)
|
||||
-- ✅ Matches Go model exactly
|
||||
-- ✅ NO strict RLS - discoverable for marketplace
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Tenants Table
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Stores tenant (organization) information
|
||||
-- Supports both permanent tenants (companies) and temporary tenants (projects)
|
||||
|
||||
CREATE TABLE tenants (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
type tenant_type NOT NULL DEFAULT 'permanent',
|
||||
parent_tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
expires_at TIMESTAMPTZ,
|
||||
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||
|
||||
-- Metadata
|
||||
-- Contact information (matches Go model)
|
||||
email VARCHAR(255),
|
||||
phone VARCHAR(50),
|
||||
website TEXT,
|
||||
|
||||
-- Address (matches Go model)
|
||||
address_line1 VARCHAR(255),
|
||||
address_line2 VARCHAR(255),
|
||||
city VARCHAR(100),
|
||||
state VARCHAR(100),
|
||||
country VARCHAR(100),
|
||||
postal_code VARCHAR(20),
|
||||
|
||||
-- Localization (matches Go model)
|
||||
timezone VARCHAR(50) NOT NULL DEFAULT 'UTC',
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
|
||||
locale VARCHAR(10) NOT NULL DEFAULT 'en-US',
|
||||
|
||||
-- Subscription & billing (matches Go model)
|
||||
subscription_status VARCHAR(50) NOT NULL DEFAULT 'trial',
|
||||
subscription_plan VARCHAR(50) NOT NULL DEFAULT 'basic',
|
||||
subscription_expires_at TIMESTAMPTZ,
|
||||
trial_ends_at TIMESTAMPTZ,
|
||||
|
||||
-- Limits (matches Go model)
|
||||
max_users INTEGER NOT NULL DEFAULT 10,
|
||||
max_contracts INTEGER NOT NULL DEFAULT 50,
|
||||
max_storage_mb INTEGER NOT NULL DEFAULT 5120,
|
||||
|
||||
-- Status (matches Go model - NOT is_active)
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'active',
|
||||
|
||||
-- Audit fields (matches Go model)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT valid_tenant_type CHECK (
|
||||
(type = 'permanent' AND parent_tenant_id IS NULL AND expires_at IS NULL) OR
|
||||
(type = 'temporary' AND parent_tenant_id IS NOT NULL AND expires_at IS NOT NULL)
|
||||
CONSTRAINT chk_tenant_subscription_status CHECK (
|
||||
subscription_status IN ('trial', 'active', 'cancelled', 'expired', 'suspended')
|
||||
),
|
||||
CONSTRAINT chk_tenant_subscription_plan CHECK (
|
||||
subscription_plan IN ('basic', 'professional', 'enterprise')
|
||||
),
|
||||
CONSTRAINT chk_tenant_status CHECK (
|
||||
status IN ('active', 'inactive', 'suspended', 'deleted')
|
||||
)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_tenants_parent ON tenants(parent_tenant_id) WHERE parent_tenant_id IS NOT NULL;
|
||||
CREATE INDEX idx_tenants_active ON tenants(is_active) WHERE is_active = true;
|
||||
CREATE INDEX idx_tenants_expires ON tenants(expires_at) WHERE expires_at IS NOT NULL;
|
||||
-- Performance indexes
|
||||
CREATE INDEX idx_tenants_slug ON tenants(slug);
|
||||
CREATE INDEX idx_tenants_status ON tenants(status) WHERE status = 'active';
|
||||
CREATE INDEX idx_tenants_subscription ON tenants(subscription_status, subscription_expires_at);
|
||||
CREATE INDEX idx_tenants_trial ON tenants(trial_ends_at) WHERE trial_ends_at IS NOT NULL;
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE tenants IS 'Organizations and project workspaces';
|
||||
COMMENT ON COLUMN tenants.type IS 'permanent: Long-lived organization, temporary: Project-specific workspace';
|
||||
COMMENT ON COLUMN tenants.parent_tenant_id IS 'For temporary tenants, links to parent permanent tenant';
|
||||
-- Full-text search for marketplace directory
|
||||
CREATE INDEX idx_tenants_search ON tenants
|
||||
USING GIN(to_tsvector('english',
|
||||
name || ' ' ||
|
||||
COALESCE(city, '') || ' ' ||
|
||||
COALESCE(country, '')
|
||||
));
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Users Table
|
||||
-- -----------------------------------------------------------------------------
|
||||
COMMENT ON TABLE tenants IS 'Organizations in marketplace - discoverable without RLS (matches Go models.Tenant)';
|
||||
COMMENT ON COLUMN tenants.slug IS 'URL-friendly unique identifier generated from name';
|
||||
COMMENT ON COLUMN tenants.status IS 'active, inactive, suspended, deleted (NOT boolean is_active)';
|
||||
|
||||
-- =============================================================================
|
||||
-- SECTION 4: USERS TABLE
|
||||
-- =============================================================================
|
||||
-- User accounts with complete profile support
|
||||
-- ✅ Matches Go model exactly (first_name, last_name, full_name, etc.)
|
||||
-- ✅ NO strict RLS - profiles discoverable in marketplace
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE users (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
|
|
@ -78,65 +168,93 @@ CREATE TABLE users (
|
|||
email VARCHAR(255) NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
|
||||
-- Profile
|
||||
name VARCHAR(255) NOT NULL,
|
||||
-- Profile (matches Go model - first_name, last_name, full_name)
|
||||
first_name VARCHAR(255),
|
||||
last_name VARCHAR(255),
|
||||
full_name VARCHAR(255), -- Will be auto-generated via trigger
|
||||
avatar_url TEXT,
|
||||
phone VARCHAR(50),
|
||||
|
||||
-- Role & permissions (matches Go model)
|
||||
role user_role NOT NULL DEFAULT 'consumer',
|
||||
|
||||
-- Status
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
-- Account status (matches Go model - status string, NOT is_active boolean)
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'active',
|
||||
email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||
email_verified_at TIMESTAMPTZ,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
is_onboarded BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- Metadata
|
||||
-- Activity tracking (matches Go model - includes last_login_ip)
|
||||
last_login_at TIMESTAMPTZ,
|
||||
last_login_ip INET,
|
||||
|
||||
-- Audit fields
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT unique_email_per_tenant UNIQUE(tenant_id, email),
|
||||
CONSTRAINT valid_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
|
||||
CONSTRAINT valid_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
|
||||
CONSTRAINT chk_user_status CHECK (
|
||||
status IN ('active', 'inactive', 'suspended', 'pending_verification')
|
||||
)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
-- Performance indexes
|
||||
CREATE INDEX idx_users_tenant ON users(tenant_id);
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_role ON users(role);
|
||||
CREATE INDEX idx_users_active ON users(is_active) WHERE is_active = true;
|
||||
CREATE INDEX idx_users_status ON users(status) WHERE status = 'active';
|
||||
CREATE INDEX idx_users_tenant_role ON users(tenant_id, role);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE users IS 'User accounts with multi-tenant support';
|
||||
COMMENT ON CONSTRAINT unique_email_per_tenant ON users IS 'Email must be unique within a tenant, but can exist in multiple tenants';
|
||||
-- Full-text search for user discovery in marketplace
|
||||
CREATE INDEX idx_users_search ON users
|
||||
USING GIN(to_tsvector('english',
|
||||
COALESCE(full_name, '') || ' ' ||
|
||||
COALESCE(first_name, '') || ' ' ||
|
||||
COALESCE(last_name, '') || ' ' ||
|
||||
email
|
||||
));
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Contracts Table
|
||||
-- -----------------------------------------------------------------------------
|
||||
COMMENT ON TABLE users IS 'User accounts - profiles discoverable in marketplace (matches Go models.User)';
|
||||
COMMENT ON COLUMN users.full_name IS 'Auto-generated from first_name + last_name via trigger';
|
||||
COMMENT ON COLUMN users.status IS 'String status (active, inactive, suspended, pending_verification) NOT boolean';
|
||||
COMMENT ON COLUMN users.email_verified IS 'Boolean flag (separate from email_verified_at timestamp)';
|
||||
|
||||
-- =============================================================================
|
||||
-- SECTION 5: CONTRACTS TABLE
|
||||
-- =============================================================================
|
||||
-- Agreements between vendors and consumers
|
||||
-- ✅ RLS allows BOTH vendor and consumer tenants to access (collaboration-aware)
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE contracts (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Parties
|
||||
-- Parties (vendor provides service, consumer receives it)
|
||||
vendor_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
consumer_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
|
||||
-- Details
|
||||
-- Contract details
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
status contract_status NOT NULL DEFAULT 'draft',
|
||||
|
||||
-- Dates
|
||||
-- Timeline
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
|
||||
-- Financial
|
||||
-- Financial terms
|
||||
total_amount NUMERIC(12,2) NOT NULL DEFAULT 0.00,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
|
||||
|
||||
-- Version control (optimistic locking)
|
||||
-- Concurrency control
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
-- Metadata
|
||||
-- Audit fields
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
|
@ -148,43 +266,46 @@ CREATE TABLE contracts (
|
|||
CONSTRAINT different_parties CHECK (vendor_id != consumer_id)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
-- Performance indexes
|
||||
CREATE INDEX idx_contracts_tenant ON contracts(tenant_id);
|
||||
CREATE INDEX idx_contracts_vendor ON contracts(vendor_id);
|
||||
CREATE INDEX idx_contracts_consumer ON contracts(consumer_id);
|
||||
CREATE INDEX idx_contracts_status ON contracts(status);
|
||||
CREATE INDEX idx_contracts_dates ON contracts(start_date, end_date);
|
||||
CREATE INDEX idx_contracts_search ON contracts USING GIN(to_tsvector('english', title || ' ' || COALESCE(description, '')));
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE contracts IS 'Agreements between vendors and consumers';
|
||||
COMMENT ON COLUMN contracts.version IS 'For optimistic locking - increment on each update';
|
||||
-- Full-text search
|
||||
CREATE INDEX idx_contracts_search ON contracts
|
||||
USING GIN(to_tsvector('english', title || ' ' || COALESCE(description, '')));
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Deliverables Table
|
||||
-- -----------------------------------------------------------------------------
|
||||
COMMENT ON TABLE contracts IS 'Vendor-consumer agreements with collaboration-aware RLS (both parties can access)';
|
||||
COMMENT ON COLUMN contracts.tenant_id IS 'Primary tenant (usually vendor) but both vendor/consumer tenants have access';
|
||||
|
||||
-- =============================================================================
|
||||
-- SECTION 6: DELIVERABLES TABLE
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE deliverables (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
|
||||
|
||||
-- Details
|
||||
-- Deliverable details
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
sequence_number INTEGER NOT NULL,
|
||||
status deliverable_status NOT NULL DEFAULT 'pending',
|
||||
|
||||
-- Dates
|
||||
-- Timeline
|
||||
deadline DATE NOT NULL,
|
||||
submitted_at TIMESTAMPTZ,
|
||||
approved_at TIMESTAMPTZ,
|
||||
|
||||
-- Submission
|
||||
-- Workflow tracking
|
||||
submitted_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
approved_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
|
||||
-- Metadata
|
||||
-- Audit fields
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
|
|
@ -200,36 +321,35 @@ CREATE INDEX idx_deliverables_contract ON deliverables(contract_id);
|
|||
CREATE INDEX idx_deliverables_status ON deliverables(status);
|
||||
CREATE INDEX idx_deliverables_deadline ON deliverables(deadline);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE deliverables IS 'Work items to be delivered as part of contracts';
|
||||
COMMENT ON COLUMN deliverables.sequence_number IS 'Order of deliverable in contract (1, 2, 3...)';
|
||||
COMMENT ON TABLE deliverables IS 'Work items within contracts - inherit collaboration from parent contract';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Milestones Table
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- =============================================================================
|
||||
-- SECTION 7: MILESTONES TABLE
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE milestones (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
deliverable_id UUID NOT NULL REFERENCES deliverables(id) ON DELETE CASCADE,
|
||||
|
||||
-- Details
|
||||
-- Milestone configuration
|
||||
title VARCHAR(500) NOT NULL,
|
||||
type milestone_type NOT NULL,
|
||||
condition_value VARCHAR(100) NOT NULL,
|
||||
amount NUMERIC(12,2) NOT NULL DEFAULT 0.00,
|
||||
status milestone_status NOT NULL DEFAULT 'pending',
|
||||
|
||||
-- Tracking
|
||||
-- Completion tracking
|
||||
completed_at TIMESTAMPTZ,
|
||||
|
||||
-- Metadata
|
||||
-- Audit fields
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT valid_amount CHECK (amount >= 0)
|
||||
CONSTRAINT valid_milestone_amount CHECK (amount >= 0)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
|
|
@ -237,36 +357,40 @@ CREATE INDEX idx_milestones_tenant ON milestones(tenant_id);
|
|||
CREATE INDEX idx_milestones_deliverable ON milestones(deliverable_id);
|
||||
CREATE INDEX idx_milestones_status ON milestones(status);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE milestones IS 'Payment milestones within deliverables';
|
||||
COMMENT ON COLUMN milestones.type IS 'Determines how condition_value is interpreted';
|
||||
COMMENT ON COLUMN milestones.condition_value IS 'Date or duration depending on type';
|
||||
COMMENT ON TABLE milestones IS 'Payment milestones with flexible scheduling logic';
|
||||
|
||||
-- =============================================================================
|
||||
-- SECTION 8: SUPPORTING TABLES
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Comments Table
|
||||
-- Comments Table (Polymorphic - can comment on any entity)
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE comments (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Polymorphic relation
|
||||
-- Polymorphic relationship
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
|
||||
-- Content
|
||||
-- Comment content
|
||||
content TEXT NOT NULL,
|
||||
|
||||
-- Author
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Metadata
|
||||
-- Audit fields
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT valid_entity_type CHECK (entity_type IN ('contract', 'deliverable', 'milestone'))
|
||||
CONSTRAINT valid_comment_entity_type CHECK (
|
||||
entity_type IN ('contract', 'deliverable', 'milestone')
|
||||
)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
|
|
@ -275,20 +399,18 @@ CREATE INDEX idx_comments_entity ON comments(entity_type, entity_id);
|
|||
CREATE INDEX idx_comments_user ON comments(user_id);
|
||||
CREATE INDEX idx_comments_created ON comments(created_at DESC);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE comments IS 'Discussion comments on various entities';
|
||||
COMMENT ON COLUMN comments.entity_type IS 'Type of entity: contract, deliverable, milestone';
|
||||
COMMENT ON COLUMN comments.entity_id IS 'ID of the entity (contract, deliverable, or milestone)';
|
||||
COMMENT ON TABLE comments IS 'Discussion comments on contracts, deliverables, milestones';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Attachments Table
|
||||
-- Attachments Table (File storage metadata)
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE attachments (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Polymorphic relation
|
||||
-- Polymorphic relationship
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
|
||||
|
|
@ -296,24 +418,28 @@ CREATE TABLE attachments (
|
|||
filename VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(100) NOT NULL,
|
||||
size BIGINT NOT NULL,
|
||||
object_name TEXT NOT NULL,
|
||||
object_name TEXT NOT NULL, -- S3/MinIO object key
|
||||
|
||||
-- Status
|
||||
-- Status tracking
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
|
||||
-- Tracking
|
||||
-- Upload tracking
|
||||
uploaded_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
uploaded_at TIMESTAMPTZ,
|
||||
|
||||
-- Metadata
|
||||
-- Audit fields
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT valid_size CHECK (size > 0),
|
||||
CONSTRAINT valid_status CHECK (status IN ('pending', 'uploaded', 'processing', 'failed')),
|
||||
CONSTRAINT valid_entity_type CHECK (entity_type IN ('contract', 'deliverable', 'milestone', 'comment'))
|
||||
CONSTRAINT valid_attachment_size CHECK (size > 0),
|
||||
CONSTRAINT valid_attachment_status CHECK (
|
||||
status IN ('pending', 'uploaded', 'processing', 'failed')
|
||||
),
|
||||
CONSTRAINT valid_attachment_entity_type CHECK (
|
||||
entity_type IN ('contract', 'deliverable', 'milestone', 'comment')
|
||||
)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
|
|
@ -322,19 +448,14 @@ CREATE INDEX idx_attachments_entity ON attachments(entity_type, entity_id);
|
|||
CREATE INDEX idx_attachments_uploaded_by ON attachments(uploaded_by);
|
||||
CREATE INDEX idx_attachments_status ON attachments(status);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE attachments IS 'File attachments for various entities';
|
||||
COMMENT ON COLUMN attachments.object_name IS 'Object key in MinIO/S3';
|
||||
|
||||
-- =============================================================================
|
||||
-- AUDIT TABLES
|
||||
-- =============================================================================
|
||||
COMMENT ON TABLE attachments IS 'File attachments stored in MinIO/S3';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Audit Logs Table
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE audit_logs (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
|
|
@ -343,14 +464,14 @@ CREATE TABLE audit_logs (
|
|||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
|
||||
-- Actor
|
||||
-- Actor information
|
||||
actor_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
|
||||
-- Changes
|
||||
-- Change tracking
|
||||
old_values JSONB,
|
||||
new_values JSONB,
|
||||
|
||||
-- Context
|
||||
-- Security context
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
|
||||
|
|
@ -366,15 +487,14 @@ CREATE INDEX idx_audit_action ON audit_logs(action);
|
|||
CREATE INDEX idx_audit_created ON audit_logs(created_at DESC);
|
||||
CREATE INDEX idx_audit_values ON audit_logs USING GIN(old_values, new_values);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE audit_logs IS 'Audit trail of all important actions';
|
||||
COMMENT ON COLUMN audit_logs.action IS 'e.g., contract.created, deliverable.submitted';
|
||||
COMMENT ON TABLE audit_logs IS 'Immutable audit trail for compliance';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Analytics Events Table
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE analytics_events (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
|
|
@ -400,33 +520,33 @@ CREATE INDEX idx_analytics_user ON analytics_events(user_id);
|
|||
CREATE INDEX idx_analytics_created ON analytics_events(created_at DESC);
|
||||
CREATE INDEX idx_analytics_data ON analytics_events USING GIN(event_data);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE analytics_events IS 'User behavior and system events for analytics';
|
||||
|
||||
-- =============================================================================
|
||||
-- NOTIFICATION TABLES
|
||||
-- =============================================================================
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Notifications Table
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE notifications (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Recipient
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Content
|
||||
-- Notification content
|
||||
type VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
|
||||
-- Related entity
|
||||
-- Related entity (optional)
|
||||
entity_type VARCHAR(50),
|
||||
entity_id UUID,
|
||||
|
||||
-- Status
|
||||
-- Read status
|
||||
read_at TIMESTAMPTZ,
|
||||
|
||||
-- Metadata
|
||||
-- Timestamp
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
|
|
@ -437,14 +557,16 @@ CREATE INDEX idx_notifications_unread ON notifications(user_id, read_at) WHERE r
|
|||
CREATE INDEX idx_notifications_entity ON notifications(entity_type, entity_id);
|
||||
CREATE INDEX idx_notifications_created ON notifications(created_at DESC);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE notifications IS 'In-app notifications for users';
|
||||
COMMENT ON TABLE notifications IS 'In-app notifications with read/unread tracking';
|
||||
|
||||
-- =============================================================================
|
||||
-- TRIGGERS FOR UPDATED_AT
|
||||
-- SECTION 9: TRIGGERS FOR AUTOMATIC UPDATES
|
||||
-- =============================================================================
|
||||
|
||||
-- Function to update updated_at timestamp
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Trigger: Auto-update updated_at column
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
|
|
@ -475,11 +597,32 @@ CREATE TRIGGER update_comments_updated_at BEFORE UPDATE ON comments
|
|||
CREATE TRIGGER update_attachments_updated_at BEFORE UPDATE ON attachments
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Trigger: Auto-generate full_name from first_name + last_name
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION generate_full_name()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.full_name := TRIM(
|
||||
COALESCE(NEW.first_name, '') || ' ' || COALESCE(NEW.last_name, '')
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER update_user_full_name
|
||||
BEFORE INSERT OR UPDATE OF first_name, last_name ON users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION generate_full_name();
|
||||
|
||||
COMMENT ON FUNCTION generate_full_name() IS 'Auto-generates full_name from first_name + last_name';
|
||||
|
||||
-- =============================================================================
|
||||
-- ROW-LEVEL SECURITY
|
||||
-- SECTION 10: ROW-LEVEL SECURITY (MARKETPLACE-AWARE)
|
||||
-- =============================================================================
|
||||
|
||||
-- Enable RLS on all tenant-scoped tables
|
||||
-- Enable RLS on all tables
|
||||
ALTER TABLE tenants ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE contracts ENABLE ROW LEVEL SECURITY;
|
||||
|
|
@ -491,47 +634,264 @@ ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
|
|||
ALTER TABLE analytics_events ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Create policies
|
||||
CREATE POLICY tenants_tenant_isolation ON tenants
|
||||
USING (id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
WITH CHECK (id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Tenants: Marketplace discovery (allow NULL tenant_id for registration)
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE POLICY users_tenant_isolation ON users
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
CREATE POLICY tenants_marketplace_access ON tenants
|
||||
FOR ALL
|
||||
USING (
|
||||
-- Own tenant
|
||||
id = current_setting('app.current_tenant_id', true)::UUID
|
||||
OR
|
||||
-- Allow during registration (no tenant context yet)
|
||||
current_setting('app.current_tenant_id', true) IS NULL
|
||||
OR
|
||||
-- Public tenants (for marketplace directory - all tenants visible)
|
||||
status = 'active'
|
||||
)
|
||||
WITH CHECK (
|
||||
id = current_setting('app.current_tenant_id', true)::UUID
|
||||
OR
|
||||
current_setting('app.current_tenant_id', true) IS NULL
|
||||
);
|
||||
|
||||
CREATE POLICY contracts_tenant_isolation ON contracts
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
COMMENT ON POLICY tenants_marketplace_access ON tenants IS
|
||||
'Allows marketplace discovery of active tenants + registration without tenant context';
|
||||
|
||||
CREATE POLICY deliverables_tenant_isolation ON deliverables
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Users: Marketplace discovery (vendor/consumer profiles visible)
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE POLICY milestones_tenant_isolation ON milestones
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
CREATE POLICY users_marketplace_access ON users
|
||||
FOR ALL
|
||||
USING (
|
||||
-- Own tenant users
|
||||
tenant_id = current_setting('app.current_tenant_id', true)::UUID
|
||||
OR
|
||||
-- Allow during registration (no tenant context yet)
|
||||
current_setting('app.current_tenant_id', true) IS NULL
|
||||
OR
|
||||
-- Active users from other tenants (marketplace discovery)
|
||||
status = 'active'
|
||||
)
|
||||
WITH CHECK (
|
||||
tenant_id = current_setting('app.current_tenant_id', true)::UUID
|
||||
OR
|
||||
current_setting('app.current_tenant_id', true) IS NULL
|
||||
);
|
||||
|
||||
CREATE POLICY comments_tenant_isolation ON comments
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
COMMENT ON POLICY users_marketplace_access ON users IS
|
||||
'Allows discovery of active users across tenants + registration flow';
|
||||
|
||||
CREATE POLICY attachments_tenant_isolation ON attachments
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Contracts: Collaboration-aware (both vendor and consumer can access)
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE POLICY contracts_collaboration_access ON contracts
|
||||
FOR ALL
|
||||
USING (
|
||||
-- Vendor's tenant
|
||||
EXISTS (
|
||||
SELECT 1 FROM users
|
||||
WHERE users.id = contracts.vendor_id
|
||||
AND users.tenant_id = current_setting('app.current_tenant_id', true)::UUID
|
||||
)
|
||||
OR
|
||||
-- Consumer's tenant
|
||||
EXISTS (
|
||||
SELECT 1 FROM users
|
||||
WHERE users.id = contracts.consumer_id
|
||||
AND users.tenant_id = current_setting('app.current_tenant_id', true)::UUID
|
||||
)
|
||||
OR
|
||||
-- Allow during creation
|
||||
current_setting('app.current_tenant_id', true) IS NULL
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM users
|
||||
WHERE users.id = contracts.vendor_id
|
||||
AND users.tenant_id = current_setting('app.current_tenant_id', true)::UUID
|
||||
)
|
||||
OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM users
|
||||
WHERE users.id = contracts.consumer_id
|
||||
AND users.tenant_id = current_setting('app.current_tenant_id', true)::UUID
|
||||
)
|
||||
OR
|
||||
current_setting('app.current_tenant_id', true) IS NULL
|
||||
);
|
||||
|
||||
COMMENT ON POLICY contracts_collaboration_access ON contracts IS
|
||||
'Allows both vendor and consumer tenants to access contract (marketplace collaboration)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Deliverables: Inherit collaboration from parent contract
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE POLICY deliverables_collaboration_access ON deliverables
|
||||
FOR ALL
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM contracts c
|
||||
WHERE c.id = deliverables.contract_id
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM users u
|
||||
WHERE u.id = c.vendor_id
|
||||
AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID
|
||||
)
|
||||
OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM users u
|
||||
WHERE u.id = c.consumer_id
|
||||
AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID
|
||||
)
|
||||
)
|
||||
)
|
||||
OR
|
||||
current_setting('app.current_tenant_id', true) IS NULL
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Milestones: Inherit collaboration via deliverable → contract
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE POLICY milestones_collaboration_access ON milestones
|
||||
FOR ALL
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM deliverables d
|
||||
JOIN contracts c ON c.id = d.contract_id
|
||||
WHERE d.id = milestones.deliverable_id
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM users u
|
||||
WHERE u.id = c.vendor_id
|
||||
AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID
|
||||
)
|
||||
OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM users u
|
||||
WHERE u.id = c.consumer_id
|
||||
AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID
|
||||
)
|
||||
)
|
||||
)
|
||||
OR
|
||||
current_setting('app.current_tenant_id', true) IS NULL
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Comments: Inherit collaboration from parent entity
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE POLICY comments_collaboration_access ON comments
|
||||
FOR ALL
|
||||
USING (
|
||||
-- Comments on contracts
|
||||
(entity_type = 'contract' AND
|
||||
EXISTS (
|
||||
SELECT 1 FROM contracts c
|
||||
WHERE c.id = comments.entity_id
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.id = c.vendor_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
OR
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.id = c.consumer_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
)
|
||||
)
|
||||
)
|
||||
OR
|
||||
-- Comments on deliverables
|
||||
(entity_type = 'deliverable' AND
|
||||
EXISTS (
|
||||
SELECT 1 FROM deliverables d
|
||||
JOIN contracts c ON c.id = d.contract_id
|
||||
WHERE d.id = comments.entity_id
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.id = c.vendor_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
OR
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.id = c.consumer_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
)
|
||||
)
|
||||
)
|
||||
OR
|
||||
-- Comments on milestones
|
||||
(entity_type = 'milestone' AND
|
||||
EXISTS (
|
||||
SELECT 1 FROM milestones m
|
||||
JOIN deliverables d ON d.id = m.deliverable_id
|
||||
JOIN contracts c ON c.id = d.contract_id
|
||||
WHERE m.id = comments.entity_id
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.id = c.vendor_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
OR
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.id = c.consumer_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
)
|
||||
)
|
||||
)
|
||||
OR
|
||||
current_setting('app.current_tenant_id', true) IS NULL
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Attachments: Similar to comments
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE POLICY attachments_collaboration_access ON attachments
|
||||
FOR ALL
|
||||
USING (
|
||||
(entity_type = 'contract' AND
|
||||
EXISTS (
|
||||
SELECT 1 FROM contracts c WHERE c.id = attachments.entity_id
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.id = c.vendor_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
OR
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.id = c.consumer_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
)
|
||||
)
|
||||
)
|
||||
OR
|
||||
current_setting('app.current_tenant_id', true) IS NULL
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Audit Logs: Strict tenant isolation
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE POLICY audit_logs_tenant_isolation ON audit_logs
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
USING (
|
||||
tenant_id = current_setting('app.current_tenant_id', true)::UUID
|
||||
OR
|
||||
current_setting('app.current_tenant_id', true) IS NULL
|
||||
);
|
||||
|
||||
CREATE POLICY analytics_events_tenant_isolation ON analytics_events
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID OR tenant_id IS NULL)
|
||||
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID OR tenant_id IS NULL);
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Analytics: System-wide OR tenant-specific
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE POLICY analytics_events_access ON analytics_events
|
||||
USING (
|
||||
tenant_id = current_setting('app.current_tenant_id', true)::UUID
|
||||
OR
|
||||
tenant_id IS NULL
|
||||
OR
|
||||
current_setting('app.current_tenant_id', true) IS NULL
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Notifications: Strict tenant isolation
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE POLICY notifications_tenant_isolation ON notifications
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID)
|
||||
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
USING (
|
||||
tenant_id = current_setting('app.current_tenant_id', true)::UUID
|
||||
OR
|
||||
current_setting('app.current_tenant_id', true) IS NULL
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- END OF MIGRATION
|
||||
-- END OF MIGRATION 000001_initial_schema.up.sql
|
||||
-- =============================================================================
|
||||
|
|
@ -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
|
||||
-- =============================================================================
|
||||
|
|
@ -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
|
||||
-- =============================================================================
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ''
|
||||
Loading…
Reference in New Issue