commit 13d908420a73d9dc31f3dbb8a266ac7179525519 Author: Björn Benouarets Date: Thu Jan 15 20:25:17 2026 +0100 feat(auth): Add login, register, session_info and api creation diff --git a/README.md b/README.md new file mode 100644 index 0000000..710cffa --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# SecNex Auth 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..6ef4800 --- /dev/null +++ b/app/cache/redis.go @@ -0,0 +1,52 @@ +package cache + +import ( + "context" + "fmt" + + "git.secnex.io/secnex/auth-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..40b8eae --- /dev/null +++ b/app/config/config.go @@ -0,0 +1,64 @@ +package config + +import ( + "strings" + + "git.secnex.io/secnex/auth-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 + ENV string + UNPROTECTED_ENDPOINTS []string +} + +var CONFIG *Config + +func generateSecret() string { + return utils.GenerateRandomString(32) +} + +func NewConfig() *Config { + ENV := utils.GetEnv("ENV", "development") + UNPROTECTED_ENDPOINTS := strings.Split(utils.GetEnv("UNPROTECTED_ENDPOINTS", ""), ",") + + if ENV == "development" { + UNPROTECTED_ENDPOINTS = append(UNPROTECTED_ENDPOINTS, "/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", ""), + ENV: ENV, + UNPROTECTED_ENDPOINTS: UNPROTECTED_ENDPOINTS, + } + CONFIG = c + return c +} diff --git a/app/controllers/api_key.go b/app/controllers/api_key.go new file mode 100644 index 0000000..8db53e9 --- /dev/null +++ b/app/controllers/api_key.go @@ -0,0 +1,11 @@ +package controllers + +import ( + "git.secnex.io/secnex/auth-api/services" + "github.com/gofiber/fiber/v2" +) + +func CreateApiKeyController(c *fiber.Ctx) error { + response := services.CreateApiKey() + return response.Send(c) +} diff --git a/app/controllers/login.go b/app/controllers/login.go new file mode 100644 index 0000000..a65390a --- /dev/null +++ b/app/controllers/login.go @@ -0,0 +1,35 @@ +package controllers + +import ( + "git.secnex.io/secnex/auth-api/services" + "git.secnex.io/secnex/auth-api/utils" + "git.secnex.io/secnex/masterlog" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" +) + +type LoginRequest struct { + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` +} + +func LoginController(c *fiber.Ctx) error { + var request LoginRequest + if err := c.BodyParser(&request); err != nil { + return utils.NewErrorResponse(fiber.StatusBadRequest, &fiber.Map{ + "message": "Invalid request body", + }).Send(c) + } + + masterlog.Debug("Processing login request", map[string]interface{}{"username": request.Username}) + + validate := validator.New() + if err := validate.Struct(request); err != nil { + return utils.NewErrorResponse(fiber.StatusBadRequest, &fiber.Map{ + "message": "Invalid request body", + }).Send(c) + } + + response := services.Login(request.Username, request.Password) + return response.Send(c) +} diff --git a/app/controllers/register.go b/app/controllers/register.go new file mode 100644 index 0000000..cf85f93 --- /dev/null +++ b/app/controllers/register.go @@ -0,0 +1,35 @@ +package controllers + +import ( + "git.secnex.io/secnex/auth-api/services" + "git.secnex.io/secnex/auth-api/utils" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" +) + +type RegisterRequest struct { + FirstName string `json:"first_name" validate:"required"` + LastName string `json:"last_name" validate:"required"` + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` + Email string `json:"email" validate:"required"` +} + +func RegisterController(c *fiber.Ctx) error { + var request RegisterRequest + if err := c.BodyParser(&request); err != nil { + return utils.NewErrorResponse(fiber.StatusBadRequest, &fiber.Map{ + "message": "Invalid request body", + }).Send(c) + } + + validate := validator.New() + if err := validate.Struct(request); err != nil { + return utils.NewErrorResponse(fiber.StatusBadRequest, &fiber.Map{ + "message": "Invalid request body", + }).Send(c) + } + + response := services.Register(request.FirstName, request.LastName, request.Username, request.Password, request.Email) + return response.Send(c) +} diff --git a/app/controllers/session_info.go b/app/controllers/session_info.go new file mode 100644 index 0000000..14251fe --- /dev/null +++ b/app/controllers/session_info.go @@ -0,0 +1,40 @@ +package controllers + +import ( + "git.secnex.io/secnex/auth-api/services" + "git.secnex.io/secnex/auth-api/utils" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" +) + +type SessionInfoRequest struct { + SessionToken string `json:"token" validate:"required"` +} + +func SessionInfoController(c *fiber.Ctx) error { + var request SessionInfoRequest + if err := c.BodyParser(&request); err != nil { + return utils.NewErrorResponse(fiber.StatusBadRequest, &fiber.Map{ + "message": "Invalid request body", + }).Send(c) + } + + validate := validator.New() + if err := validate.Struct(request); err != nil { + return utils.NewErrorResponse(fiber.StatusBadRequest, &fiber.Map{ + "message": "Invalid request body", + }).Send(c) + } + + sessionDetails, err := services.SessionInfo(request.SessionToken) + if err != nil { + return utils.NewErrorResponse(fiber.StatusUnauthorized, &fiber.Map{ + "message": "Invalid token", + }).Send(c) + } + + return utils.NewHTTPResponse(fiber.StatusOK, &fiber.Map{ + "message": "OK", + "session": sessionDetails, + }, "", nil, nil).Send(c) +} diff --git a/app/database/conn.go b/app/database/conn.go new file mode 100644 index 0000000..de92185 --- /dev/null +++ b/app/database/conn.go @@ -0,0 +1,102 @@ +package database + +import ( + "fmt" + "strings" + + "git.secnex.io/secnex/auth-api/config" + "git.secnex.io/secnex/auth-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..1cb9957 --- /dev/null +++ b/app/go.mod @@ -0,0 +1,40 @@ +module git.secnex.io/secnex/auth-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/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.0 + 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/valkey-io/valkey-go v1.0.70 // 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..3c7eaf4 --- /dev/null +++ b/app/go.sum @@ -0,0 +1,81 @@ +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/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.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/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= +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/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..fe63edd --- /dev/null +++ b/app/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "git.secnex.io/secnex/auth-api/cache" + "git.secnex.io/secnex/auth-api/config" + "git.secnex.io/secnex/auth-api/controllers" + "git.secnex.io/secnex/auth-api/database" + "git.secnex.io/secnex/auth-api/middlewares" + "git.secnex.io/secnex/auth-api/models" + "git.secnex.io/secnex/masterlog" + "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") + + if config.Debug { + masterlog.SetLevel(masterlog.LevelDebug) + } else { + masterlog.SetLevel(masterlog.LevelInfo) + } + + // resetAdminApiKey := utils.GetEnvBool("RESET_ADMIN_API_KEY", false) + + masterlog.AddEncoder(&masterlog.JSONEncoder{}) + + allModels := []interface{}{ + &models.User{}, + &models.Tenant{}, + &models.Session{}, + &models.ApiKey{}, + } + + 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("/login", controllers.LoginController) + app.Post("/register", controllers.RegisterController) + app.Get("/api_keys", controllers.CreateApiKeyController) + app.Post("/session/info", controllers.SessionInfoController) + + 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..0231e7b --- /dev/null +++ b/app/middlewares/auth.go @@ -0,0 +1,66 @@ +package middlewares + +import ( + "encoding/base64" + "slices" + "strings" + + "git.secnex.io/secnex/auth-api/config" + "git.secnex.io/secnex/auth-api/repositories" + "git.secnex.io/secnex/auth-api/utils" + "github.com/gofiber/fiber/v2" +) + +func AuthMiddleware() fiber.Handler { + return func(c *fiber.Ctx) error { + // check if the endpoint is in the unprotected endpoints + if slices.Contains(config.CONFIG.UNPROTECTED_ENDPOINTS, c.Path()) { + return c.Next() + } + token := c.Get("Authorization") + if token == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"}) + } + + tokenParts := strings.Split(token, " ") + if len(tokenParts) != 2 { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"}) + } + + tokenPartType, tokenPartValue := tokenParts[0], tokenParts[1] + if tokenPartType != "Bearer" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"}) + } + + // Decode the token from base64 to string + tokenValue, err := base64.StdEncoding.DecodeString(tokenPartValue) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"}) + } + tokenValueString := string(tokenValue) + + tokenValueParts := strings.Split(tokenValueString, ":") + if len(tokenValueParts) != 2 { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"}) + } + keyId, keyValue := tokenValueParts[0], tokenValueParts[1] + apiKey, err := repositories.GetApiKey(keyId) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"}) + } + if apiKey == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"}) + } + + valid, err := utils.Verify(keyValue, apiKey.Key) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"}) + } + if !valid { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"}) + } + + c.Locals("key", keyId) + 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/key.go b/app/models/key.go new file mode 100644 index 0000000..eeeac05 --- /dev/null +++ b/app/models/key.go @@ -0,0 +1,31 @@ +package models + +import ( + "time" + + "git.secnex.io/secnex/auth-api/utils" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ApiKey struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Key string `gorm:"not null" json:"key"` + Enabled bool `gorm:"not null;default:true" json:"enabled"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` +} + +func (ApiKey) TableName() string { + return "api_keys" +} + +func (apiKey *ApiKey) BeforeCreate(tx *gorm.DB) (err error) { + apiKeyHash, err := utils.Hash(apiKey.Key) + if err != nil { + return err + } + apiKey.Key = apiKeyHash + return nil +} diff --git a/app/models/session.go b/app/models/session.go new file mode 100644 index 0000000..da2075d --- /dev/null +++ b/app/models/session.go @@ -0,0 +1,22 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Session 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"` + 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"` +} + +func (Session) TableName() string { + return "sessions" +} diff --git a/app/models/tenant.go b/app/models/tenant.go new file mode 100644 index 0000000..1b712da --- /dev/null +++ b/app/models/tenant.go @@ -0,0 +1,25 @@ +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"` +} + +func (Tenant) TableName() string { + return "tenants" +} diff --git a/app/models/users.go b/app/models/users.go new file mode 100644 index 0000000..1cd05f3 --- /dev/null +++ b/app/models/users.go @@ -0,0 +1,38 @@ +package models + +import ( + "time" + + "git.secnex.io/secnex/auth-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"` + 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/key.go b/app/repositories/key.go new file mode 100644 index 0000000..115440c --- /dev/null +++ b/app/repositories/key.go @@ -0,0 +1,14 @@ +package repositories + +import ( + "git.secnex.io/secnex/auth-api/database" + "git.secnex.io/secnex/auth-api/models" +) + +func GetApiKey(id string) (*models.ApiKey, error) { + var apiKey *models.ApiKey + if err := database.DB.Where("id = ? AND deleted_at IS NULL AND enabled = true", id).First(&apiKey).Error; err != nil { + return nil, err + } + return apiKey, nil +} diff --git a/app/repositories/sessions.go b/app/repositories/sessions.go new file mode 100644 index 0000000..b070725 --- /dev/null +++ b/app/repositories/sessions.go @@ -0,0 +1,99 @@ +package repositories + +import ( + "encoding/json" + "time" + + "git.secnex.io/secnex/auth-api/cache" + "git.secnex.io/secnex/auth-api/database" + "git.secnex.io/secnex/auth-api/models" + "git.secnex.io/secnex/masterlog" + "github.com/google/uuid" +) + +type SessionDetails struct { + UserID uuid.UUID `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` +} + +func CreateSession(user *models.User) *models.Session { + session := &models.Session{ + ID: uuid.New(), + UserID: user.ID, + } + if err := database.DB.Create(session).Error; err != nil { + masterlog.Debug("Failed to create session in database", map[string]interface{}{"error": err.Error(), "user_id": user.ID}) + return nil + } + sessionDetails := SessionDetails{ + UserID: user.ID, + Username: user.Username, + Email: user.Email, + FirstName: user.FirstName, + LastName: user.LastName, + } + jsonData, err := json.Marshal(sessionDetails) + if err != nil { + masterlog.Debug("Failed to marshal session details", map[string]interface{}{"error": err.Error(), "session_id": session.ID}) + return nil + } + ttl := time.Hour * 24 + if cache.Cache.Client == nil { + masterlog.Debug("Redis client not initialized, skipping cache storage", map[string]interface{}{"session_id": session.ID}) + return session + } + if err := cache.Cache.Client.Do(cache.Cache.Context, cache.Cache.Client.B().Set().Key(session.ID.String()).Value(string(jsonData)).Ex(ttl).Build()).Error(); err != nil { + masterlog.Debug("Failed to store session in cache", map[string]interface{}{"error": err.Error(), "session_id": session.ID}) + return session + } + masterlog.Debug("Session stored in cache", map[string]interface{}{"session_id": session.ID}) + return session +} + +func GetSessionCache(sessionID string) *SessionDetails { + masterlog.Debug("Retrieving session from cache", map[string]interface{}{"session_id": sessionID}) + if cache.Cache.Client == nil { + masterlog.Debug("Redis client not initialized", map[string]interface{}{"session_id": sessionID}) + return nil + } + + res := cache.Cache.Client.Do(cache.Cache.Context, cache.Cache.Client.B().Get().Key(sessionID).Build()) + if res.Error() != nil { + masterlog.Debug("Failed to get session from cache", map[string]interface{}{"error": res.Error(), "session_id": sessionID}) + return nil + } + + rawStr := res.String() + if rawStr == "" { + masterlog.Debug("Session not found in cache", map[string]interface{}{"session_id": sessionID}) + return nil + } + + // Parse the valkey response structure to extract the actual JSON string + var valkeyResponse struct { + Message struct { + Value string `json:"Value"` + Type string `json:"Type"` + } `json:"Message"` + } + if err := json.Unmarshal([]byte(rawStr), &valkeyResponse); err != nil { + // If it's not the wrapped format, use it directly + masterlog.Debug("Cache response not in wrapped format, using directly", map[string]interface{}{"session_id": sessionID}) + } else { + // Extract the actual JSON string from Message.Value + rawStr = valkeyResponse.Message.Value + masterlog.Debug("Extracted JSON from cache response", map[string]interface{}{"session_id": sessionID}) + } + + var sessionDetails SessionDetails + if err := json.Unmarshal([]byte(rawStr), &sessionDetails); err != nil { + masterlog.Debug("Failed to unmarshal session details", map[string]interface{}{"error": err.Error(), "session_id": sessionID}) + return nil + } + + masterlog.Debug("Session retrieved from cache", map[string]interface{}{"session_id": sessionID, "user_id": sessionDetails.UserID}) + return &sessionDetails +} diff --git a/app/repositories/users.go b/app/repositories/users.go new file mode 100644 index 0000000..7f4d196 --- /dev/null +++ b/app/repositories/users.go @@ -0,0 +1,40 @@ +package repositories + +import ( + "git.secnex.io/secnex/auth-api/database" + "git.secnex.io/secnex/auth-api/models" + "git.secnex.io/secnex/masterlog" +) + +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/api_key.go b/app/services/api_key.go new file mode 100644 index 0000000..e8d1368 --- /dev/null +++ b/app/services/api_key.go @@ -0,0 +1,34 @@ +package services + +import ( + "encoding/base64" + "fmt" + + "git.secnex.io/secnex/auth-api/database" + "git.secnex.io/secnex/auth-api/models" + "git.secnex.io/secnex/auth-api/utils" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +func CreateApiKey() *utils.HTTPResponse { + keyID := uuid.New() + key := utils.GenerateRandomString(32) + createApiKey := &models.ApiKey{ + ID: keyID, + Key: key, + } + + if err := database.DB.Create(createApiKey).Error; err != nil { + return utils.NewHTTPResponse(fiber.StatusInternalServerError, &fiber.Map{ + "message": "Error creating API key", + }, "", nil, nil) + } + + apiKeyPlain := fmt.Sprintf("%s:%s", keyID.String(), key) + apiKey := base64.StdEncoding.EncodeToString([]byte(apiKeyPlain)) + return utils.NewHTTPResponse(fiber.StatusOK, &fiber.Map{ + "message": "API key created successfully", + "key": apiKey, + }, "", nil, nil) +} diff --git a/app/services/login.go b/app/services/login.go new file mode 100644 index 0000000..6a46453 --- /dev/null +++ b/app/services/login.go @@ -0,0 +1,67 @@ +package services + +import ( + "time" + + "git.secnex.io/secnex/auth-api/config" + "git.secnex.io/secnex/auth-api/repositories" + "git.secnex.io/secnex/auth-api/utils" + "git.secnex.io/secnex/masterlog" + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" +) + +func Login(username, password string) *utils.HTTPResponse { + // Get user by username + user, err := repositories.GetUserByUsername(username) + if err != nil { + return utils.NewHTTPResponse(fiber.StatusNotFound, &fiber.Map{ + "message": "User not found", + }, "", nil, nil) + } + if user == nil { + return utils.NewHTTPResponse(fiber.StatusNotFound, &fiber.Map{ + "message": "User not found", + }, "", nil, nil) + } + + hashedPassword := user.Password + valid, err := utils.Verify(password, hashedPassword) + if err != nil { + return utils.NewHTTPResponse(fiber.StatusInternalServerError, &fiber.Map{ + "message": "Error verifying password", + }, "", nil, nil) + } + if !valid { + return utils.NewHTTPResponse(fiber.StatusUnauthorized, &fiber.Map{ + "message": "Invalid password", + }, "", nil, nil) + } + + session := repositories.CreateSession(user) + if session == nil { + return utils.NewHTTPResponse(fiber.StatusInternalServerError, &fiber.Map{ + "message": "Error creating session", + }, "", nil, nil) + } + + masterlog.Debug("Session created successfully", map[string]interface{}{"session_id": session.ID, "user_id": session.UserID}) + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": session.ID.String(), + "exp": time.Now().Add(time.Hour * 24).Unix(), + }) + + secret := config.CONFIG.JwtSecret + tokenString, err := token.SignedString([]byte(secret)) + if err != nil { + return utils.NewHTTPResponse(fiber.StatusInternalServerError, &fiber.Map{ + "message": "Error generating token", + }, "", nil, nil) + } + + return utils.NewHTTPResponse(fiber.StatusOK, &fiber.Map{ + "message": "Login successful", + "token": tokenString, + }, "", nil, nil) +} diff --git a/app/services/register.go b/app/services/register.go new file mode 100644 index 0000000..d548e60 --- /dev/null +++ b/app/services/register.go @@ -0,0 +1,37 @@ +package services + +import ( + "git.secnex.io/secnex/auth-api/repositories" + "git.secnex.io/secnex/auth-api/utils" + "github.com/gofiber/fiber/v2" +) + +func Register(firstName, lastName, username, password, email string) *utils.HTTPResponse { + user, err := repositories.GetUserByUniqueFields(username, email) + if err != nil { + return utils.NewHTTPResponse(fiber.StatusInternalServerError, &fiber.Map{ + "message": "Error getting user", + }, "", nil, nil) + } + if user != nil { + return utils.NewHTTPResponse(fiber.StatusBadRequest, &fiber.Map{ + "message": "User already exists", + }, "", nil, nil) + } + + err = repositories.CreateUser(firstName, lastName, username, password, email) + if err != nil { + if utils.IsDuplicateKeyError(err) { + return utils.NewHTTPResponse(fiber.StatusBadRequest, &fiber.Map{ + "message": "User already exists", + }, "", nil, nil) + } + return utils.NewHTTPResponse(fiber.StatusInternalServerError, &fiber.Map{ + "message": "Error creating user", + }, "", nil, nil) + } + + return utils.NewHTTPResponse(fiber.StatusOK, &fiber.Map{ + "message": "Your account has been created successfully. Please check your email for verification. If you don't see it, please check your spam folder.", + }, "", nil, nil) +} diff --git a/app/services/session_info.go b/app/services/session_info.go new file mode 100644 index 0000000..7674b0e --- /dev/null +++ b/app/services/session_info.go @@ -0,0 +1,49 @@ +package services + +import ( + "errors" + "fmt" + + "git.secnex.io/secnex/auth-api/config" + "git.secnex.io/secnex/auth-api/repositories" + "git.secnex.io/secnex/masterlog" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +func SessionInfo(token string) (*repositories.SessionDetails, error) { + claims, err := jwt.ParseWithClaims(token, &jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(config.CONFIG.JwtSecret), nil + }) + if err != nil { + return nil, err + } + if !claims.Valid { + return nil, errors.New("invalid token") + } + + mapClaims := claims.Claims.(*jwt.MapClaims) + subValue, ok := (*mapClaims)["sub"] + if !ok { + return nil, errors.New("sub claim not found") + } + + var sessionID string + switch v := subValue.(type) { + case string: + sessionID = v + case uuid.UUID: + sessionID = v.String() + default: + sessionID = fmt.Sprintf("%v", v) + } + + masterlog.Debug("Session ID extracted from token", map[string]interface{}{"session_id": sessionID, "sub_type": fmt.Sprintf("%T", subValue)}) + sessionDetails := repositories.GetSessionCache(sessionID) + if sessionDetails == nil { + masterlog.Debug("Session not found in cache", map[string]interface{}{"session_id": sessionID}) + return nil, errors.New("session not found") + } + masterlog.Debug("Session details retrieved successfully", map[string]interface{}{"session_id": sessionID, "user_id": sessionDetails.UserID}) + return sessionDetails, 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) +}