commit 346100feb65ebfecb7b6556ece65003a0e7e5254 Author: Björn Benouarets Date: Wed Jan 21 06:36:38 2026 +0100 init: Initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..206ff25 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.25.3-alpine AS builder + +WORKDIR /app + +COPY ./app/go.mod ./app/go.sum ./ + +RUN go mod download + +RUN go mod verify + +COPY ./app ./. + +RUN go build -o app . + +FROM alpine:latest AS runner + +WORKDIR /app + +COPY --from=builder /app/app /app/app + +CMD ["./app"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..38bd5ff --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# SecNex OAuth2 API + +## Configuration + +### Environment Variables + +| Variable | Description | Default Value | +|----------|-------------|---------------| +| ENV | Environment | development | +| UNPROTECTED_ENDPOINTS | Unprotected endpoints | | +| DEBUG | Debug mode | false | +| FIBER_SHOW_STARTUP_MESSAGE | Show startup message | false | +| CORS_ALLOW_ORIGINS | CORS allow origins | * | +| CORS_ALLOW_HEADERS | CORS allow headers | Origin, Content-Type, Accept | +| CORS_ALLOW_METHODS | CORS allow methods | GET, POST, PUT, DELETE | +| ADDRESS | Address | :3000 | +| DATABASE_HOST | Database host | localhost | +| DATABASE_PORT | Database port | 5432 | +| DATABASE_USER | Database user | postgres | +| DATABASE_PASSWORD | Database password | postgres | +| DATABASE_NAME | Database name | secnex | +| REDIS_HOST | Redis host | localhost | +| REDIS_PORT | Redis port | 6379 | +| REDIS_PASSWORD | Redis password | | +| JWT_SECRET | JWT secret | your-256-bit-secret | + +### Development Environment Variables + +| Variable | Description | Default Value | +|----------|-------------|---------------| +| UNPROTECTED_ENDPOINTS | Unprotected endpoints | /api_keys | + +## API Endpoints + +### Create API Key + +```bash +curl -X GET http://localhost:3000/api_keys +``` + +### Login + +```bash +curl -X POST http://localhost:3000/login -d '{"username": "admin", "password": "admin"}' +``` + +### Register + +```bash +curl -X POST http://localhost:3000/register -d '{"username": "admin", "password": "admin"}' +``` + +### Session Info + +```bash +curl -X POST http://localhost:3000/session/info -d '{"token": "your-token"}' +``` + +### Create API Key + +```bash +curl -X GET http://localhost:3000/api_keys +``` + +***Note:*** The API key can be created only in development environment without authentication. Use the header `Authorization: Bearer ` to authenticate the requests in other environments. \ No newline at end of file diff --git a/app/cache/redis.go b/app/cache/redis.go new file mode 100644 index 0000000..c7601da --- /dev/null +++ b/app/cache/redis.go @@ -0,0 +1,52 @@ +package cache + +import ( + "context" + "fmt" + + "git.secnex.io/secnex/oauth2-api/config" + "github.com/valkey-io/valkey-go" +) + +type RedisConfiguration struct { + Host string + Port string + Password string +} + +type RedisCache struct { + Client valkey.Client + Context context.Context +} + +var Cache RedisCache + +func NewRedisConfiguration(host, port, password string) *RedisConfiguration { + return &RedisConfiguration{ + Host: host, + Port: port, + Password: password, + } +} + +func NewRedisConfigurationFromConfig(config *config.Config) *RedisConfiguration { + return &RedisConfiguration{ + Host: config.RedisHost, + Port: config.RedisPort, + Password: config.RedisPassword, + } +} + +func Connect(config *config.Config) error { + client, err := valkey.NewClient(valkey.ClientOption{InitAddress: []string{fmt.Sprintf("%s:%s", config.RedisHost, config.RedisPort)}}) + if err != nil { + return err + } + ctx := context.Background() + cache := &RedisCache{ + Client: client, + Context: ctx, + } + Cache = *cache + return nil +} diff --git a/app/config/config.go b/app/config/config.go new file mode 100644 index 0000000..a1697aa --- /dev/null +++ b/app/config/config.go @@ -0,0 +1,64 @@ +package config + +import ( + "strings" + + "git.secnex.io/secnex/oauth2-api/utils" +) + +type Config struct { + Debug bool + FiberShowStartupMessage bool + CorsAllowOrigins string + CorsAllowHeaders string + CorsAllowMethods string + Address string + DatabaseHost string + DatabasePort string + DatabaseUser string + DatabasePassword string + DatabaseName string + RedisHost string + RedisPort string + RedisPassword string + JwtSecret string + Environment string + UnprotectedEndpoints []string +} + +var CONFIG *Config + +func generateSecret() string { + return utils.GenerateRandomString(32) +} + +func NewConfig() *Config { + Environment := utils.GetEnv("ENV", "development") + UnprotectedEndpoints := strings.Split(utils.GetEnv("UNPROTECTED_ENDPOINTS", ""), ",") + + if Environment == "development" { + UnprotectedEndpoints = append(UnprotectedEndpoints, "/api_keys") + } + + c := &Config{ + Debug: utils.GetEnvBool("DEBUG", false), + FiberShowStartupMessage: utils.GetEnvBool("FIBER_SHOW_STARTUP_MESSAGE", false), + CorsAllowOrigins: utils.GetEnv("CORS_ALLOW_ORIGINS", "*"), + CorsAllowHeaders: utils.GetEnv("CORS_ALLOW_HEADERS", "Origin, Content-Type, Accept"), + CorsAllowMethods: utils.GetEnv("CORS_ALLOW_METHODS", "GET, POST, PUT, DELETE"), + Address: utils.GetEnv("ADDRESS", ":3000"), + DatabaseHost: utils.GetEnv("DATABASE_HOST", "localhost"), + DatabasePort: utils.GetEnv("DATABASE_PORT", "5432"), + DatabaseUser: utils.GetEnv("DATABASE_USER", "postgres"), + DatabasePassword: utils.GetEnv("DATABASE_PASSWORD", "postgres"), + DatabaseName: utils.GetEnv("DATABASE_NAME", "secnex"), + JwtSecret: utils.GetEnv("JWT_SECRET", "your-256-bit-secret"), + RedisHost: utils.GetEnv("REDIS_HOST", "localhost"), + RedisPort: utils.GetEnv("REDIS_PORT", "6379"), + RedisPassword: utils.GetEnv("REDIS_PASSWORD", ""), + Environment: Environment, + UnprotectedEndpoints: UnprotectedEndpoints, + } + CONFIG = c + return c +} diff --git a/app/controllers/authorize.go b/app/controllers/authorize.go new file mode 100644 index 0000000..ad0ffea --- /dev/null +++ b/app/controllers/authorize.go @@ -0,0 +1,29 @@ +package controllers + +import ( + "git.secnex.io/secnex/oauth2-api/services" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" +) + +type AuthorizeRequest struct { + ClientID string `json:"client_id" validate:"required"` + RedirectURI string `json:"redirect_uri" validate:"required"` + ResponseType string `json:"response_type" validate:"required"` + Scope string `json:"scope" validate:"required"` + State string `json:"state" validate:"required"` +} + +func AuthorizeController(c *fiber.Ctx) error { + var request AuthorizeRequest + if err := c.BodyParser(&request); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + if err := validator.New().Struct(request); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + response := services.Authorize(c.Locals("user").(string), request.ClientID, request.RedirectURI, request.ResponseType, request.Scope, request.State) + return response.Send(c) +} diff --git a/app/database/conn.go b/app/database/conn.go new file mode 100644 index 0000000..2b7045c --- /dev/null +++ b/app/database/conn.go @@ -0,0 +1,102 @@ +package database + +import ( + "fmt" + "strings" + + "git.secnex.io/secnex/oauth2-api/config" + "git.secnex.io/secnex/oauth2-api/utils" + + "git.secnex.io/secnex/masterlog" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type DatabaseConfiguration struct { + Host string + Port string + User string + Password string + Database string +} + +var DB *gorm.DB + +func NewDatabaseConfiguration(host, port, user, password, database string) *DatabaseConfiguration { + return &DatabaseConfiguration{ + Host: host, + Port: port, + User: user, + Password: password, + Database: database, + } +} + +func NewDatabaseConfigurationFromConfig(config *config.Config) *DatabaseConfiguration { + return &DatabaseConfiguration{ + Host: config.DatabaseHost, + Port: config.DatabasePort, + User: config.DatabaseUser, + Password: config.DatabasePassword, + Database: config.DatabaseName, + } +} + +func NewDatabaseConfigurationFromEnv() *DatabaseConfiguration { + return &DatabaseConfiguration{ + Host: utils.GetEnv("DB_HOST", "localhost"), + Port: utils.GetEnv("DB_PORT", "5432"), + User: utils.GetEnv("DB_USER", "postgres"), + Password: utils.GetEnv("DB_PASSWORD", "postgres"), + Database: utils.GetEnv("DB_DATABASE", "secnex"), + } +} + +func (c *DatabaseConfiguration) String() string { + return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable client_encoding=UTF8", c.Host, c.Port, c.User, c.Password, c.Database) +} + +func (c *DatabaseConfiguration) Connect(config *config.Config, models ...interface{}) error { + logLevel := logger.Silent + if config.Debug { + logLevel = logger.Info + } + db, err := gorm.Open(postgres.Open(c.String()), &gorm.Config{ + Logger: logger.Default.LogMode(logLevel), + }) + if err != nil { + return err + } + + if err := AutoMigrate(db, models...); err != nil { + return err + } + + DB = db + + return nil +} + +type SchemaProvider interface { + Schema() string +} + +func AutoMigrate(conn *gorm.DB, models ...interface{}) error { + masterlog.Debug("Starting database auto-migration", map[string]interface{}{"model_count": len(models)}) + err := conn.AutoMigrate(models...) + if err != nil { + // Check if error is about constraint that doesn't exist + // This can happen when GORM tries to drop old constraints during migration + errMsg := strings.ToLower(err.Error()) + if strings.Contains(errMsg, "does not exist") && strings.Contains(errMsg, "constraint") { + masterlog.Debug("Ignoring constraint drop error", map[string]interface{}{"error": err.Error()}) + // Return nil to continue despite the constraint error + return nil + } + masterlog.Debug("Auto-migration failed", map[string]interface{}{"error": err.Error()}) + return err + } + masterlog.Debug("Database auto-migration completed successfully", map[string]interface{}{"model_count": len(models)}) + return nil +} diff --git a/app/database/exec.go b/app/database/exec.go new file mode 100644 index 0000000..aa29b08 --- /dev/null +++ b/app/database/exec.go @@ -0,0 +1,13 @@ +package database + +import "git.secnex.io/secnex/masterlog" + +func Execute(query string) error { + masterlog.Debug("Executing database query", map[string]interface{}{"query": query}) + if err := DB.Exec(query).Error; err != nil { + masterlog.Debug("Database query execution failed", map[string]interface{}{"error": err.Error(), "query": query}) + return err + } + masterlog.Debug("Database query executed successfully", map[string]interface{}{"query": query}) + return nil +} diff --git a/app/go.mod b/app/go.mod new file mode 100644 index 0000000..c9332d1 --- /dev/null +++ b/app/go.mod @@ -0,0 +1,39 @@ +module git.secnex.io/secnex/oauth2-api + +go 1.25.3 + +require ( + git.secnex.io/secnex/masterlog v0.1.0 + github.com/go-playground/validator/v10 v10.30.1 + github.com/gofiber/fiber/v2 v2.52.10 + github.com/google/uuid v1.6.0 + github.com/valkey-io/valkey-go v1.0.70 + golang.org/x/crypto v0.46.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // 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.6.0 // 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/klauspost/compress v1.17.9 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect +) diff --git a/app/go.sum b/app/go.sum new file mode 100644 index 0000000..94fb696 --- /dev/null +++ b/app/go.sum @@ -0,0 +1,87 @@ +git.secnex.io/secnex/masterlog v0.1.0 h1:74j9CATpfeK0lxpWIQC9ag9083akwG8khi5BwLedD8E= +git.secnex.io/secnex/masterlog v0.1.0/go.mod h1:OnDlwEzdkKMnqY+G5O9kHdhoJ6fH1llbVdXpgSc5SdM= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY= +github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +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/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +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/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +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/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= +github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +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.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/valkey-io/valkey-go v1.0.70 h1:mjYNT8qiazxDAJ0QNQ8twWT/YFOkOoRd40ERV2mB49Y= +github.com/valkey-io/valkey-go v1.0.70/go.mod h1:VGhZ6fs68Qrn2+OhH+6waZH27bjpgQOiLyUQyXuYK5k= +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/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/app/main.go b/app/main.go new file mode 100644 index 0000000..7c01e7d --- /dev/null +++ b/app/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "git.secnex.io/secnex/masterlog" + "git.secnex.io/secnex/oauth2-api/cache" + "git.secnex.io/secnex/oauth2-api/config" + "git.secnex.io/secnex/oauth2-api/controllers" + "git.secnex.io/secnex/oauth2-api/database" + "git.secnex.io/secnex/oauth2-api/middlewares" + "git.secnex.io/secnex/oauth2-api/models" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" +) + +func main() { + config := config.NewConfig() + + pseudonymizer := masterlog.NewPseudonymizerFromString("1234567890") + + masterlog.SetPseudonymizer(pseudonymizer) + masterlog.AddSensitiveFields("password", "token", "email", "token_value", "key_value") + + if config.Debug { + masterlog.SetLevel(masterlog.LevelDebug) + } else { + masterlog.SetLevel(masterlog.LevelInfo) + } + + masterlog.AddEncoder(&masterlog.JSONEncoder{}) + + allModels := []interface{}{ + &models.User{}, + &models.Tenant{}, + } + + dbConfig := database.NewDatabaseConfigurationFromConfig(config) + masterlog.Info("Connecting to database", map[string]interface{}{"host": config.DatabaseHost, "port": config.DatabasePort, "database": config.DatabaseName}) + if err := dbConfig.Connect(config, allModels...); err != nil { + masterlog.Error("failed to connect to database", map[string]interface{}{"error": err.Error()}) + return + } + masterlog.Info("Connected to database!") + + masterlog.Info("Connecting to Redis", map[string]interface{}{"host": config.RedisHost, "port": config.RedisPort}) + if err := cache.Connect(config); err != nil { + masterlog.Error("failed to connect to Redis", map[string]interface{}{"error": err.Error()}) + return + } + masterlog.Info("Connected to Redis!") + + app := fiber.New(fiber.Config{ + DisableStartupMessage: !config.FiberShowStartupMessage, + }) + + app.Use(middlewares.RequestLogger()) + app.Use(middlewares.AuthMiddleware()) + app.Use(cors.New(cors.Config{ + AllowOrigins: config.CorsAllowOrigins, + AllowHeaders: config.CorsAllowHeaders, + AllowMethods: config.CorsAllowMethods, + })) + + // Controllers + app.Post("/authorize", controllers.AuthorizeController) + + masterlog.Info("Starting server", map[string]interface{}{"address": config.Address}) + if err := app.Listen(config.Address); err != nil { + masterlog.Error("failed to start server", map[string]interface{}{"error": err.Error()}) + return + } +} diff --git a/app/middlewares/auth.go b/app/middlewares/auth.go new file mode 100644 index 0000000..dd8b440 --- /dev/null +++ b/app/middlewares/auth.go @@ -0,0 +1,38 @@ +package middlewares + +import ( + "slices" + "strings" + + "git.secnex.io/secnex/masterlog" + "git.secnex.io/secnex/oauth2-api/config" + "github.com/gofiber/fiber/v2" +) + +func AuthMiddleware() fiber.Handler { + return func(c *fiber.Ctx) error { + if slices.Contains(config.CONFIG.UnprotectedEndpoints, c.Path()) { + masterlog.Debug("Unprotected endpoint", map[string]interface{}{"path": c.Path()}) + return c.Next() + } + token := c.Get("Authorization") + if token == "" { + masterlog.Debug("No token provided", map[string]interface{}{"path": c.Path(), "authorization": c.Get("Authorization")}) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"}) + } + + tokenParts := strings.Split(token, " ") + if len(tokenParts) != 2 { + masterlog.Debug("Invalid token parts", map[string]interface{}{"token_parts": tokenParts}) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"}) + } + + tokenPartType, _ := tokenParts[0], tokenParts[1] + if tokenPartType != "Bearer" { + masterlog.Debug("Invalid token type", map[string]interface{}{"token_type": tokenPartType}) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"}) + } + + return c.Next() + } +} diff --git a/app/middlewares/log.go b/app/middlewares/log.go new file mode 100644 index 0000000..01eaf15 --- /dev/null +++ b/app/middlewares/log.go @@ -0,0 +1,49 @@ +package middlewares + +import ( + "time" + + "git.secnex.io/secnex/masterlog" + "github.com/gofiber/fiber/v2" +) + +// RequestLogger logs incoming HTTP requests via masterlog. +func RequestLogger() fiber.Handler { + return func(c *fiber.Ctx) error { + start := time.Now() + err := c.Next() + duration := time.Since(start) + + entry := map[string]interface{}{ + "method": c.Method(), + "path": c.OriginalURL(), + "ip": c.IP(), + "duration": duration.String(), + "status": c.Response().StatusCode(), + "user_agent": c.Get("User-Agent"), + } + + if err != nil { + entry["error"] = err.Error() + masterlog.Error( + "HTTP request failed", + entry, + ) + return err + } + + if status := c.Response().StatusCode(); status >= fiber.StatusInternalServerError { + masterlog.Error( + "HTTP request failed", + entry, + ) + } else { + masterlog.Info( + "HTTP request successful", + entry, + ) + } + + return nil + } +} diff --git a/app/models/application.go b/app/models/application.go new file mode 100644 index 0000000..9cdea48 --- /dev/null +++ b/app/models/application.go @@ -0,0 +1,41 @@ +package models + +import ( + "time" + + "git.secnex.io/secnex/oauth2-api/utils" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Application struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Name string `gorm:"not null" json:"name"` + Secret string `gorm:"not null" json:"secret"` + TenantID uuid.UUID `gorm:"type:uuid" json:"tenant_id"` + ExpiresAt *time.Time `gorm:"not null" json:"expires_at"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + + Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant"` + + Authorizations []Authorization `gorm:"foreignKey:ClientID" json:"authorizations"` +} + +func (Application) TableName() string { + return "applications" +} + +func (application *Application) BeforeCreate(tx *gorm.DB) (err error) { + secretHash, err := utils.Hash(application.Secret) + if err != nil { + return err + } + application.Secret = secretHash + if application.ExpiresAt == nil { + expiresAt := time.Now().Add(time.Hour * 24 * 365) + application.ExpiresAt = &expiresAt + } + return nil +} diff --git a/app/models/authorization.go b/app/models/authorization.go new file mode 100644 index 0000000..f7b99c9 --- /dev/null +++ b/app/models/authorization.go @@ -0,0 +1,40 @@ +package models + +import ( + "time" + + "git.secnex.io/secnex/oauth2-api/utils" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Authorization struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"` + ClientID uuid.UUID `gorm:"type:uuid;not null" json:"client_id"` + Code string `gorm:"not null" json:"code"` + ExpiresAt *time.Time `gorm:"not null" json:"expires_at"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + + User *User `gorm:"foreignKey:UserID" json:"user"` + Application *Application `gorm:"foreignKey:ClientID" json:"application"` +} + +func (Authorization) TableName() string { + return "authorizations" +} + +func (authorization *Authorization) BeforeCreate(tx *gorm.DB) (err error) { + codeHash, err := utils.Hash(authorization.Code) + if err != nil { + return err + } + authorization.Code = codeHash + if authorization.ExpiresAt == nil { + expiresAt := time.Now().Add(time.Minute * 10) + authorization.ExpiresAt = &expiresAt + } + return nil +} diff --git a/app/models/tenant.go b/app/models/tenant.go new file mode 100644 index 0000000..6d887b0 --- /dev/null +++ b/app/models/tenant.go @@ -0,0 +1,26 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Tenant struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Name string `gorm:"not null" json:"name"` + Enabled bool `gorm:"not null;default:true" json:"enabled"` + AllowSelfRegistration bool `gorm:"not null;default:false" json:"allow_self_registration"` + AllowSelfRegistrationDomains []string `gorm:"type:jsonb;not null" json:"allow_self_registration_domains"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + + Users []User `gorm:"foreignKey:TenantID" json:"users"` + Applications []Application `gorm:"foreignKey:TenantID" json:"applications"` +} + +func (Tenant) TableName() string { + return "tenants" +} diff --git a/app/models/users.go b/app/models/users.go new file mode 100644 index 0000000..d7d3795 --- /dev/null +++ b/app/models/users.go @@ -0,0 +1,39 @@ +package models + +import ( + "time" + + "git.secnex.io/secnex/oauth2-api/utils" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type User struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + FirstName string `gorm:"not null" json:"first_name"` + LastName string `gorm:"not null" json:"last_name"` + Username string `gorm:"not null;unique" json:"username"` + Password string `gorm:"not null" json:"password"` + Email string `gorm:"not null;unique" json:"email"` + Verified bool `gorm:"not null;default:false" json:"verified"` + TenantID *uuid.UUID `gorm:"type:uuid" json:"tenant_id"` + ExternalID *uuid.UUID `gorm:"type:uuid" json:"external_id"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + + Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant"` +} + +func (User) TableName() string { + return "users" +} + +func (user *User) BeforeCreate(tx *gorm.DB) (err error) { + passwordHash, err := utils.Hash(user.Password) + if err != nil { + return err + } + user.Password = passwordHash + return nil +} diff --git a/app/repositories/applications.go b/app/repositories/applications.go new file mode 100644 index 0000000..b56f2e9 --- /dev/null +++ b/app/repositories/applications.go @@ -0,0 +1,16 @@ +package repositories + +import ( + "time" + + "git.secnex.io/secnex/oauth2-api/database" + "git.secnex.io/secnex/oauth2-api/models" +) + +func GetApplicationByClientID(clientID string) (*models.Application, error) { + var application *models.Application + if err := database.DB.Where("client_id = ? AND expires_at > ?", clientID, time.Now().UTC()).First(&application).Error; err != nil { + return nil, err + } + return application, nil +} diff --git a/app/repositories/authorization.go b/app/repositories/authorization.go new file mode 100644 index 0000000..29b86b4 --- /dev/null +++ b/app/repositories/authorization.go @@ -0,0 +1,10 @@ +package repositories + +import ( + "git.secnex.io/secnex/oauth2-api/database" + "git.secnex.io/secnex/oauth2-api/models" +) + +func CreateAuthorization(authorization *models.Authorization) error { + return database.DB.Create(authorization).Error +} diff --git a/app/repositories/users.go b/app/repositories/users.go new file mode 100644 index 0000000..9edb73a --- /dev/null +++ b/app/repositories/users.go @@ -0,0 +1,40 @@ +package repositories + +import ( + "git.secnex.io/secnex/masterlog" + "git.secnex.io/secnex/oauth2-api/database" + "git.secnex.io/secnex/oauth2-api/models" +) + +func GetUserByUsername(username string) (*models.User, error) { + var user *models.User + if err := database.DB.Where("username = ?", username).First(&user).Error; err != nil { + return nil, err + } + return user, nil +} + +func GetUserByUniqueFields(username, email string) (*models.User, error) { + var user *models.User + if err := database.DB.Where("username = ? OR email = ?", username, email).First(&user).Error; err != nil { + return nil, nil + } + return user, nil +} + +func CreateUser(firstName, lastName, username, password, email string) error { + user := &models.User{ + FirstName: firstName, + LastName: lastName, + Username: username, + Password: password, + Email: email, + } + + if err := database.DB.Create(user).Error; err != nil { + masterlog.Debug("Failed to create user in database", map[string]interface{}{"error": err.Error(), "username": username, "email": email}) + return err + } + masterlog.Debug("User created successfully", map[string]interface{}{"username": username, "email": email}) + return nil +} diff --git a/app/services/authorize.go b/app/services/authorize.go new file mode 100644 index 0000000..f46ba49 --- /dev/null +++ b/app/services/authorize.go @@ -0,0 +1,50 @@ +package services + +import ( + "encoding/base64" + "fmt" + "net/http" + "time" + + "git.secnex.io/secnex/oauth2-api/models" + "git.secnex.io/secnex/oauth2-api/repositories" + "git.secnex.io/secnex/oauth2-api/utils" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +type AuthorizeResponse struct { + Code string `json:"code"` + State string `json:"state"` +} + +func Authorize(userID, clientID, redirectURI, responseType, scope, state string) *utils.HTTPResponse { + application, err := repositories.GetApplicationByClientID(clientID) + if err != nil { + return utils.NewHTTPResponse(http.StatusUnauthorized, &fiber.Map{"error": "Application not found"}, "", nil, nil) + } + if application.ExpiresAt.Before(time.Now().UTC()) { + return utils.NewHTTPResponse(http.StatusUnauthorized, &fiber.Map{"error": "Application expired"}, "", nil, nil) + } + authorizationID := uuid.New() + authorizationCode := utils.GenerateRandomString(32) + authorization := &models.Authorization{ + ID: authorizationID, + Code: authorizationCode, + ClientID: application.ID, + UserID: uuid.MustParse(userID), + } + if err := repositories.CreateAuthorization(authorization); err != nil { + return utils.NewHTTPResponse(http.StatusInternalServerError, &fiber.Map{"error": "Failed to create authorization"}, "", nil, nil) + } + + authorizationCodeString := fmt.Sprintf("%s:%s", authorizationID.String(), authorizationCode) + authorizationCodeBase64 := base64.StdEncoding.EncodeToString([]byte(authorizationCodeString)) + + response := AuthorizeResponse{ + Code: authorizationCodeBase64, + State: state, + } + + return utils.NewHTTPResponse(http.StatusOK, &fiber.Map{"response": response}, "", nil, nil) +} diff --git a/app/utils/env.go b/app/utils/env.go new file mode 100644 index 0000000..c2e46da --- /dev/null +++ b/app/utils/env.go @@ -0,0 +1,22 @@ +package utils + +import ( + "os" + "strings" +) + +func GetEnv(key, defaultValue string) string { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + return value +} + +func GetEnvBool(key string, defaultValue bool) bool { + value := strings.ToLower(GetEnv(key, "")) + if value == "" { + return defaultValue + } + return value == "true" || value == "1" +} diff --git a/app/utils/hash.go b/app/utils/hash.go new file mode 100644 index 0000000..3a10d88 --- /dev/null +++ b/app/utils/hash.go @@ -0,0 +1,81 @@ +package utils + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + argon2Time = 3 + argon2Memory = 64 * 1024 + argon2Threads = 4 + argon2KeyLen = 32 + saltLength = 16 +) + +func Hash(password string) (string, error) { + salt := make([]byte, saltLength) + if _, err := rand.Read(salt); err != nil { + return "", err + } + + hash := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen) + + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + b64Hash := base64.RawStdEncoding.EncodeToString(hash) + + encodedHash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, argon2Memory, argon2Time, argon2Threads, b64Salt, b64Hash) + + return encodedHash, nil +} + +func Verify(password, encodedHash string) (bool, error) { + parts := strings.Split(encodedHash, "$") + if len(parts) != 6 { + return false, fmt.Errorf("invalid hash format") + } + + if parts[1] != "argon2id" { + return false, fmt.Errorf("unsupported hash algorithm") + } + + var version int + _, err := fmt.Sscanf(parts[2], "v=%d", &version) + if err != nil { + return false, err + } + if version != argon2.Version { + return false, fmt.Errorf("incompatible version") + } + + var memory, time uint32 + var threads uint8 + _, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads) + if err != nil { + return false, err + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false, err + } + + hash, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false, err + } + + otherHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(hash))) + + if subtle.ConstantTimeCompare(hash, otherHash) == 1 { + return true, nil + } + + return false, nil +} diff --git a/app/utils/res/http.go b/app/utils/res/http.go new file mode 100644 index 0000000..a5c0574 --- /dev/null +++ b/app/utils/res/http.go @@ -0,0 +1,11 @@ +package res + +import "github.com/gofiber/fiber/v2" + +type HTTPResponse struct { + Code int `json:"code"` + Body *fiber.Map `json:"body"` + ODataContext string `json:"@odata.context"` + ODataCount int `json:"@odata.count"` + ODataNextLink *string `json:"@odata.nextLink"` +} diff --git a/app/utils/response.go b/app/utils/response.go new file mode 100644 index 0000000..18ef826 --- /dev/null +++ b/app/utils/response.go @@ -0,0 +1,77 @@ +package utils + +import ( + "github.com/gofiber/fiber/v2" +) + +type Response interface { + JSON() map[string]interface{} + Send(c *fiber.Ctx) error +} + +type HTTPResponse struct { + Code int `json:"code"` + Body *fiber.Map `json:"body"` + ODataContext string `json:"@odata.context"` + ODataCount *int `json:"@odata.count"` + ODataNextLink *string `json:"@odata.nextLink"` +} + +type ErrorResponse struct { + Code int `json:"code"` + Body *fiber.Map `json:"body"` +} + +func NewHTTPResponse(code int, body *fiber.Map, oDataContext string, oDataCount *int, oDataNextLink *string) *HTTPResponse { + return &HTTPResponse{ + Code: code, + Body: body, + ODataContext: oDataContext, + ODataCount: oDataCount, + ODataNextLink: oDataNextLink, + } +} + +func NewErrorResponse(code int, body *fiber.Map) *ErrorResponse { + return &ErrorResponse{ + Code: code, + Body: body, + } +} + +func (res *HTTPResponse) JSON() map[string]interface{} { + result := map[string]interface{}{ + "code": res.Code, + } + if res.Body != nil { + result["body"] = res.Body + } + if res.ODataContext != "" { + result["@odata.context"] = res.ODataContext + } + if res.ODataCount != nil { + result["@odata.count"] = *res.ODataCount + } + if res.ODataNextLink != nil { + result["@odata.nextLink"] = res.ODataNextLink + } + return result +} + +func (res *ErrorResponse) JSON() map[string]interface{} { + result := map[string]interface{}{ + "code": res.Code, + } + if res.Body != nil { + result["body"] = res.Body + } + return result +} + +func (res *ErrorResponse) Send(c *fiber.Ctx) error { + return c.Status(res.Code).JSON(res.JSON()) +} + +func (res *HTTPResponse) Send(c *fiber.Ctx) error { + return c.Status(res.Code).JSON(res.JSON()) +} diff --git a/app/utils/sql.go b/app/utils/sql.go new file mode 100644 index 0000000..90fd87c --- /dev/null +++ b/app/utils/sql.go @@ -0,0 +1,25 @@ +package utils + +import ( + "errors" + "strings" + + "gorm.io/gorm" +) + +// IsDuplicateKeyError checks if an error is a duplicate key constraint violation +func IsDuplicateKeyError(err error) bool { + if err == nil { + return false + } + + // Check for GORM duplicate key error + if errors.Is(err, gorm.ErrDuplicatedKey) { + return true + } + + // Check for PostgreSQL duplicate key error (SQLSTATE 23505) + errMsg := strings.ToLower(err.Error()) + return strings.Contains(errMsg, "duplicate key value violates unique constraint") || + strings.Contains(errMsg, "sqlstate 23505") +} diff --git a/app/utils/token.go b/app/utils/token.go new file mode 100644 index 0000000..7e42032 --- /dev/null +++ b/app/utils/token.go @@ -0,0 +1,18 @@ +package utils + +import ( + "crypto/rand" +) + +func GenerateRandomString(length int) string { + charset := "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, length) + _, err := rand.Read(b) + if err != nil { + return "" + } + for i := range b { + b[i] = charset[b[i]%byte(len(charset))] + } + return string(b) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e69de29