diff --git a/Dockerfile b/Dockerfile index e46848c..74829d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ ENV GOPROXY=direct RUN apk add --no-cache make postgresql-client git curl COPY go.mod go.sum ./ +RUN go mod tidy RUN go mod download # Development Mode diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index b5533a1..73d9fc4 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -1,12 +1,14 @@ services: app: - build: + build: context: . target: dev ports: - "9898:9898" depends_on: - db + env_file: + - .env environment: - DB_HOST=db - DB_USER=${DB_USER:-nymeria} @@ -31,4 +33,4 @@ services: restart: unless-stopped volumes: - postgres_data_dev: \ No newline at end of file + postgres_data_dev: diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml index df3e6a0..48b01f9 100644 --- a/docker-compose.prod.yaml +++ b/docker-compose.prod.yaml @@ -1,6 +1,6 @@ services: app: - build: + build: context: . target: prod ports: diff --git a/go.mod b/go.mod index dacbb87..fc6ac31 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.0 require ( github.com/gin-contrib/cors v1.7.5 github.com/gin-gonic/gin v1.10.1 + github.com/google/uuid v1.6.0 github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.10.0 gorm.io/gorm v1.30.0 diff --git a/go.sum b/go.sum index b9462de..c8f6274 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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= diff --git a/internal/api/applications.go b/internal/api/applications.go new file mode 100644 index 0000000..1202a26 --- /dev/null +++ b/internal/api/applications.go @@ -0,0 +1,215 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + database "github.com/sdslabs/nymeria/internal/database/applications" + "github.com/sdslabs/nymeria/internal/database/schema" + "github.com/sdslabs/nymeria/internal/utils" +) + +func HandleGetApplicationFlow(c *gin.Context) { + csrfToken, err := utils.GenerateCSRFToken(c.GetHeader("X-User-ID")) // TODO: Get user ID from session + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": "Failed to generate CSRF token", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "create application flow", + "csrf_token": csrfToken, + }) +} + +func HandleFetchAllApplicationsFlow(c *gin.Context) { + apps, err := database.GetAllApplications() + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "applications fetched", + "data": apps, + }) +} + +func HandleFetchApplicationByIDFlow(c *gin.Context) { + id := c.Param("id") + + app, err := database.GetApplicationByID(id) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "application fetched", + "data": app, + }) +} + +func HandleCreateApplicationFlow(c *gin.Context) { + var req CreateApplicationRequest + + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "status": "error", + "message": err.Error(), + }) + return + } + + clientKey, err := utils.GenerateRandomString(16) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": "Failed to generate client key", + }) + return + } + + clientSecret, err := utils.GenerateRandomString(32) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": "Failed to generate client secret", + }) + return + } + + app := schema.Application{ + Name: req.Name, + AllowedOrigins: req.AllowedOrigins, + RedirectURIs: req.RedirectURIs, + ClientKey: clientKey, + ClientSecret: clientSecret, + } + + err = database.CreateApplication(app) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "application created", + "data": app, + }) +} + +func HandleDeleteApplicationFlow(c *gin.Context) { + id := c.Param("id") + + err := database.DeleteApplication(id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "application deleted", + }) +} + +func HandleUpdateApplicationFlow(c *gin.Context) { + var req UpdateApplicationRequest + + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "status": "error", + "message": err.Error(), + }) + return + } + + app, err := database.GetApplicationByID(req.ApplicationID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": err.Error(), + }) + return + } + + if req.NewKeyFlag { + clientKey, err := utils.GenerateRandomString(16) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": err.Error(), + }) + return + } + + clientSecret, err := utils.GenerateRandomString(32) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": err.Error(), + }) + return + } + + app.ClientKey = clientKey + app.ClientSecret = clientSecret + } + + if req.ApplicationURL != "" { + app.ApplicationURL = req.ApplicationURL + } + + if len(req.AllowedOrigins) > 0 { + app.AllowedOrigins = req.AllowedOrigins + } + + if len(req.RedirectURIs) > 0 { + app.RedirectURIs = req.RedirectURIs + } + + err = database.UpdateApplication(req.ApplicationID, app) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "application updated", + "data": gin.H{ + "application_id": req.ApplicationID, + "client_key": app.ClientKey, + "client_secret": app.ClientSecret, + }, + }) +} diff --git a/internal/api/main.go b/internal/api/main.go index 2f204d3..5d01c3b 100644 --- a/internal/api/main.go +++ b/internal/api/main.go @@ -9,11 +9,16 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/sdslabs/nymeria/internal/config" "github.com/sdslabs/nymeria/internal/database" - "github.com/sdslabs/nymeria/internal/log" + "github.com/sdslabs/nymeria/internal/logger" + "github.com/sdslabs/nymeria/internal/middlewares" ) func Start() { + // Initialize global configuration + config.Init() + if err := database.Init(); err != nil { panic(err) } @@ -21,22 +26,22 @@ func Start() { r := gin.Default() // Use custom logging middleware - r.Use(log.LoggerMiddleware(log.Logger)) + r.Use(logger.Middleware(logger.Logger)) // CORS configuration - config := cors.New(cors.Config{ + corsConfig := cors.New(cors.Config{ AllowOrigins: []string{"http://localhost:3000"}, // TODO: change in prod AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, - AllowHeaders: []string{"Authorization", "Content-Type"}, + AllowHeaders: []string{"Authorization", "Content-Type", "X-CSRF-Token", "X-User-ID"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, MaxAge: 12 * 3600, // 12 hours in seconds }) - r.Use(config) + r.Use(corsConfig) r.GET("/", func(c *gin.Context) { - log.Logger.Info().Msg("welcome") + logger.Info().Msg("welcome") c.JSON(http.StatusOK, gin.H{ "status": "success", "message": "welcome", @@ -44,7 +49,8 @@ func Start() { }) r.GET("/ping", func(c *gin.Context) { - log.Logger.Info().Msg("ping") + logger.Info().Msg("ping") + logger.Info().Msg(config.AppConfig.DBHost) c.JSON(http.StatusOK, gin.H{ "status": "success", "message": "pong", @@ -52,10 +58,27 @@ func Start() { }) r.GET("/register", HandleGetRegistrationFlow) - r.POST("/register", HandlePostRegistrationFlow) + r.POST("/register", middlewares.CSRFMiddleware(), HandlePostRegistrationFlow) r.GET("/login", HandleGetLoginFlow) - r.POST("/login", HandlePostLoginFlow) + r.POST("/login", middlewares.CSRFMiddleware(), HandlePostLoginFlow) + + r.GET("/verification", HandleGetVerificationFlow) + r.POST("/verification", HandlePostVerificationCodeFlow) + r.POST("/verification/code", HandlePostVerifyEmailFlow) + + r.GET("/applications", HandleGetApplicationFlow) + r.POST("/applications", HandleFetchAllApplicationsFlow) + r.POST("/applications/:id", HandleFetchApplicationByIDFlow) + + r.GET("/applications/create", HandleGetApplicationFlow) + r.POST("/applications/create", middlewares.CSRFMiddleware(), HandleCreateApplicationFlow) + + r.GET("/applications/update", HandleGetApplicationFlow) + r.POST("/applications/update", middlewares.CSRFMiddleware(), HandleUpdateApplicationFlow) + + r.GET("/applications/delete", HandleGetApplicationFlow) + r.DELETE("/applications/delete/:id", middlewares.CSRFMiddleware(), HandleDeleteApplicationFlow) r.Run(":9898") } diff --git a/internal/api/register.go b/internal/api/register.go index b266c5e..3c9a76b 100644 --- a/internal/api/register.go +++ b/internal/api/register.go @@ -10,7 +10,8 @@ import ( "github.com/gin-gonic/gin" "github.com/sdslabs/nymeria/internal/database" - "github.com/sdslabs/nymeria/internal/log" + "github.com/sdslabs/nymeria/internal/database/schema" + "github.com/sdslabs/nymeria/internal/logger" ) // HandleGetRegistrationFlow handles the GET request for the registration flow. @@ -26,7 +27,7 @@ func HandlePostRegistrationFlow(c *gin.Context) { var req RegistrationRequest err := c.ShouldBindJSON(&req) if err != nil { - log.Logger.Err(err).Msg("invalid json") + logger.Err(err).Msg("invalid json") c.JSON(http.StatusBadRequest, gin.H{ "status": "error", "message": err.Error(), @@ -63,7 +64,7 @@ func HandlePostRegistrationFlow(c *gin.Context) { return } - user := database.User{ + user := schema.User{ Username: req.Username, Password: req.Password, Email: req.Email, @@ -77,7 +78,7 @@ func HandlePostRegistrationFlow(c *gin.Context) { "message": "GitHub ID already exists", }) } else { - log.Logger.Err(result.Error).Msg("failed to insert user") + logger.Err(result.Error).Msg("failed to insert user") } return } diff --git a/internal/api/types.go b/internal/api/types.go index 49a7cf9..1556ba9 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -11,3 +11,28 @@ type LoginRequest struct { Username string `json:"username"` Password string `json:"password"` } + +type CreateApplicationRequest struct { + Name string `json:"name" binding:"required"` + ApplicationURL string `json:"application_url" binding:"required"` + AllowedOrigins []string `json:"allowed_origins" binding:"required"` + RedirectURIs []string `json:"redirect_uris" binding:"required"` +} + +type UpdateApplicationRequest struct { + ApplicationID string `json:"application_id" binding:"required"` + ApplicationURL string `json:"application_url" binding:"omitempty"` + AllowedOrigins []string `json:"allowed_origins" binding:"omitempty"` + RedirectURIs []string `json:"redirect_uris" binding:"omitempty"` + NewKeyFlag bool `json:"new_key_flag" binding:"omitempty"` +} + +type VerifyEmailRequest struct { + Email string `json:"email" binding:"required"` + IsIITRCheck bool `json:"is_iitr_check" binding:"omitempty"` +} + +type VerifyEmailCodeRequest struct { + Email string `json:"email" binding:"required"` + Code string `json:"code" binding:"required"` +} diff --git a/internal/api/verification.go b/internal/api/verification.go new file mode 100644 index 0000000..3937fef --- /dev/null +++ b/internal/api/verification.go @@ -0,0 +1,105 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/sdslabs/nymeria/internal/smtp" + "github.com/sdslabs/nymeria/internal/utils" +) + +func HandleGetVerificationFlow(c *gin.Context) { + csrfToken, err := utils.GenerateCSRFToken(c.GetHeader("X-User-ID")) // TODO: Get user ID from session + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": "Failed to generate CSRF token", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "Email verification flow initiated", + "csrf_token": csrfToken, + }) +} + +func HandlePostVerificationCodeFlow(c *gin.Context) { + var req VerifyEmailRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "status": "error", + "message": "Invalid request body", + }) + return + } + + if req.Email == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "status": "error", + "message": "Email is required", + }) + return + } + + otp, err := smtp.SendOTPHandler(req.Email, req.IsIITRCheck) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "Email verification code sent", + "data": gin.H{ + "otp": otp, + }, + }) +} + +func HandlePostVerifyEmailFlow(c *gin.Context) { + var req VerifyEmailCodeRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "status": "error", + "message": "Invalid request body", + }) + return + } + + if req.Email == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "status": "error", + "message": "Email is required", + }) + return + } + + if req.Code == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "status": "error", + "message": "Code is required", + }) + return + } + + err = smtp.VerifyOTPHandler(req.Email, req.Code) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "status": "error", + "message": err.Error(), + }) + } + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "Email verified", + }) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8113c69 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,58 @@ +// Copyright (c) 2025 SDSLabs +// SPDX-License-Identifier: MIT + +package config + +import ( + "os" + "strconv" +) + +// Global configuration instance +var AppConfig *Config + +// GetEnvOrDefault retrieves the value of the environment variable named by key. +// If the environment variable is not set or is empty, it returns the default value. +func GetEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func ParseIntGetEnvOrDefault(key, defaultValue string) int { + value, err := strconv.Atoi(GetEnvOrDefault(key, defaultValue)) + if err != nil { + value, _ = strconv.Atoi(defaultValue) + } + return value +} + +// LoadConfig loads configuration from environment variables +func LoadConfig() *Config { + return &Config{ + DBHost: GetEnvOrDefault("DB_HOST", "localhost"), + DBUser: GetEnvOrDefault("DB_USER", "nymeria"), + DBPassword: GetEnvOrDefault("DB_PASS", "password"), + DBName: GetEnvOrDefault("DB_NAME", "nymeria"), + DBPort: GetEnvOrDefault("DB_PORT", "5432"), + + CSRFSecret: GetEnvOrDefault("CSRF_SECRET", "csrf-secret-key-32"), + CSRFMaxAge: ParseIntGetEnvOrDefault("CSRF_MAX_AGE", "2"), + JWTSecret: GetEnvOrDefault("JWT_SECRET", "jwt-secret-key-32"), + JWTMaxAge: ParseIntGetEnvOrDefault("JWT_MAX_AGE", "2"), + + EnvMode: GetEnvOrDefault("ENV_MODE", "development"), + + MailFrom: GetEnvOrDefault("MAIL_FROM", "noreply@accounts.sdslabs.co"), + MailPassword: GetEnvOrDefault("MAIL_PASSWORD", "password"), + SMTPHost: GetEnvOrDefault("SMTP_HOST", "smtp.gmail.com"), + SMTPPort: GetEnvOrDefault("SMTP_PORT", "587"), + OTPExpiry: ParseIntGetEnvOrDefault("OTP_EXPIRY", "5"), + } +} + +// Init initializes the global configuration +func Init() { + AppConfig = LoadConfig() +} diff --git a/internal/config/types.go b/internal/config/types.go new file mode 100644 index 0000000..314ad91 --- /dev/null +++ b/internal/config/types.go @@ -0,0 +1,23 @@ +package config + +// DatabaseConfig holds database configuration +type Config struct { + DBHost string `env:"DB_HOST"` + DBUser string `env:"DB_USER"` + DBPassword string `env:"DB_PASSWORD"` + DBName string `env:"DB_NAME"` + DBPort string `env:"DB_PORT"` + + CSRFSecret string `env:"CSRF_SECRET"` + CSRFMaxAge int `env:"CSRF_MAX_AGE"` // in minutes + JWTSecret string `env:"JWT_SECRET"` + JWTMaxAge int `env:"JWT_MAX_AGE"` // in days + + EnvMode string `env:"ENV_MODE"` + + MailFrom string `env:"MAIL_FROM"` + MailPassword string `env:"MAIL_PASSWORD"` + SMTPHost string `env:"SMTP_HOST"` + SMTPPort string `env:"SMTP_PORT"` + OTPExpiry int `env:"OTP_EXPIRY"` // in minutes +} diff --git a/internal/database/applications/applications.go b/internal/database/applications/applications.go new file mode 100644 index 0000000..923f4c7 --- /dev/null +++ b/internal/database/applications/applications.go @@ -0,0 +1,61 @@ +package database + +import ( + "github.com/sdslabs/nymeria/internal/database" + "github.com/sdslabs/nymeria/internal/database/schema" + "github.com/sdslabs/nymeria/internal/logger" +) + +func GetAllApplications() ([]schema.Application, error) { + var applications []schema.Application + + result := database.DB.Find(&applications) + if result.Error != nil { + logger.Err(result.Error).Msg("failed to get applications") + return nil, result.Error + } + + return applications, nil +} + +func CreateApplication(application schema.Application) error { + result := database.DB.Create(&application) + if result.Error != nil { + logger.Err(result.Error).Msg("failed to create application") + return result.Error + } + + return nil +} + +func GetApplicationByID(id string) (schema.Application, error) { + var application schema.Application + + result := database.DB.First(&application, "id = ?", id) + if result.Error != nil { + logger.Err(result.Error).Msg("failed to get application") + return schema.Application{}, result.Error + } + + return application, nil +} + +func UpdateApplication(id string, application schema.Application) error { + result := database.DB.Model(&schema.Application{}).Where("id = ?", id).Updates(application) + if result.Error != nil { + logger.Err(result.Error).Msg("failed to update application") + return result.Error + } + + return nil +} + +func DeleteApplication(id string) error { + result := database.DB.Delete(&schema.Application{}, "id = ?", id) + if result.Error != nil { + logger.Err(result.Error).Msg("failed to delete application") + return result.Error + } + + return nil +} diff --git a/internal/database/database.go b/internal/database/database.go index d28678a..f344398 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -6,44 +6,26 @@ package database import ( "fmt" "log" - "os" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" + + "github.com/sdslabs/nymeria/internal/config" + "github.com/sdslabs/nymeria/internal/database/schema" ) // The global database instance var DB *gorm.DB -// loadConfig loads database configuration from environment variables -func loadConfig() *Config { - return &Config{ - // TODO: update this - Host: getEnvOrDefault("DB_HOST", "localhost"), - User: getEnvOrDefault("DB_USER", "nymeria"), - Password: getEnvOrDefault("DB_PASS", "password"), - DBName: getEnvOrDefault("DB_NAME", "nymeria"), - Port: getEnvOrDefault("DB_PORT", "5432"), - } -} - -// getEnvOrDefault retrieves the value of the environment variable named by key. -func getEnvOrDefault(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { - return value - } - return defaultValue -} - // connect establishes a connection to the PostgreSQL database using GORM -func connect(config *Config) (*gorm.DB, error) { - if config.Password == "" { +func connect() (*gorm.DB, error) { + if config.AppConfig.DBPassword == "" { return nil, fmt.Errorf("DB_PASS environment variable is required") } dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", - config.Host, config.User, config.Password, config.DBName, config.Port) + config.AppConfig.DBHost, config.AppConfig.DBUser, config.AppConfig.DBPassword, config.AppConfig.DBName, config.AppConfig.DBPort) db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), @@ -52,7 +34,12 @@ func connect(config *Config) (*gorm.DB, error) { return nil, fmt.Errorf("failed to connect to database: %w", err) } - if err := db.AutoMigrate(&User{}, &Organization{}, &Application{}); err != nil { + // Enable uuid-ossp extension for uuid_generate_v4() function + if err := db.Exec("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"").Error; err != nil { + return nil, fmt.Errorf("failed to create uuid-ossp extension: %w", err) + } + + if err := db.AutoMigrate(&schema.User{}, &schema.Organization{}, &schema.Application{}, &schema.OTP{}); err != nil { return nil, fmt.Errorf("failed to auto migrate database: %w", err) } @@ -61,9 +48,7 @@ func connect(config *Config) (*gorm.DB, error) { // Init initializes the database connection func Init() error { - config := loadConfig() - - db, err := connect(config) + db, err := connect() if err != nil { return fmt.Errorf("database initialization failed: %w", err) } diff --git a/internal/database/otp/otp.go b/internal/database/otp/otp.go new file mode 100644 index 0000000..4376794 --- /dev/null +++ b/internal/database/otp/otp.go @@ -0,0 +1,32 @@ +package database + +import ( + "github.com/sdslabs/nymeria/internal/database" + "github.com/sdslabs/nymeria/internal/database/schema" +) + +func CreateOTPEntry(otpEntry *schema.OTP) error { + + var existingOTP schema.OTP + tx := database.DB.First(&existingOTP, "email = ?", otpEntry.Email) + if tx.Error == nil { + existingOTP.Code = otpEntry.Code + existingOTP.ExpiresAt = otpEntry.ExpiresAt + return database.DB.Save(&existingOTP).Error + } + + return database.DB.Create(otpEntry).Error +} + +func QueryOTPEntry(email string) (schema.OTP, error) { + var otpEntry schema.OTP + + tx := database.DB.Where("email = ?", email).First(&otpEntry) + + return otpEntry, tx.Error +} + +func VerifyOTPEntry(email string) error { + + return database.DB.Model(&schema.OTP{}).Where("email = ?", email).Update("verified", true).Error +} diff --git a/internal/database/schema/application.go b/internal/database/schema/application.go new file mode 100644 index 0000000..587885f --- /dev/null +++ b/internal/database/schema/application.go @@ -0,0 +1,19 @@ +package schema + +import ( + "time" + + "github.com/google/uuid" +) + +type Application struct { + ID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4();primaryKey"` + Name string `gorm:"not null"` + ApplicationURL string `gorm:"not null"` + AllowedOrigins []string `gorm:"type:text[]"` + RedirectURIs []string `gorm:"type:text[]"` + ClientKey string `gorm:"not null;unique"` + ClientSecret string `gorm:"not null"` + Organizations []*Organization `gorm:"many2many:application_organizations;"` + CreatedAt time.Time `gorm:"not null"` +} diff --git a/internal/database/schema/organisation.go b/internal/database/schema/organisation.go new file mode 100644 index 0000000..6e0f15b --- /dev/null +++ b/internal/database/schema/organisation.go @@ -0,0 +1,16 @@ +package schema + +import ( + "time" + + "github.com/google/uuid" +) + +type Organization struct { + ID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4();primaryKey"` + Name string `gorm:"not null;unique"` + Users []*User `gorm:"many2many:user_organizations;"` + Applications []*Application `gorm:"many2many:application_organizations;"` + UserRoles []UserRole `gorm:"foreignKey:OrganizationID"` + CreatedAt time.Time `gorm:"not null"` +} diff --git a/internal/database/schema/otp.go b/internal/database/schema/otp.go new file mode 100644 index 0000000..756d790 --- /dev/null +++ b/internal/database/schema/otp.go @@ -0,0 +1,15 @@ +package schema + +import ( + "time" + + "github.com/google/uuid" +) + +type OTP struct { + ID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4();primaryKey"` + Email string `gorm:"not null"` + Code string `gorm:"not null"` + ExpiresAt time.Time `gorm:"not null"` + Verified bool `gorm:"not null"` +} diff --git a/internal/database/schema/user.go b/internal/database/schema/user.go new file mode 100644 index 0000000..f2bcdc0 --- /dev/null +++ b/internal/database/schema/user.go @@ -0,0 +1,40 @@ +package schema + +import ( + "time" + + "github.com/google/uuid" +) + +type Role string + +const ( + USER Role = "user" + ADMIN Role = "admin" + SUPERADMIN Role = "superadmin" +) + +type UserRole struct { + ID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4();primaryKey"` + UserID uuid.UUID `gorm:"type:uuid;index"` + OrganizationID uuid.UUID `gorm:"type:uuid;index"` + Role Role `gorm:"not null"` + User *User `gorm:"foreignKey:UserID"` + Organization *Organization `gorm:"foreignKey:OrganizationID"` +} + +type User struct { + ID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4();primaryKey"` + Email string `gorm:"not null;unique"` + Password string `gorm:"not null"` + Phone string `gorm:"not null"` + Username string `gorm:"not null"` + ImageURL string `gorm:"not null"` + GitHubID string `gorm:"not null"` + AccountStatus string `gorm:"not null"` + Verified bool `gorm:"not null"` + AdditionalEmails string `gorm:"not null"` + Organizations []*Organization `gorm:"many2many:user_organizations;"` + Roles []UserRole `gorm:"foreignKey:UserID"` + CreatedAt time.Time `gorm:"not null"` +} diff --git a/internal/database/types.go b/internal/database/types.go deleted file mode 100644 index 336353e..0000000 --- a/internal/database/types.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2025 SDSLabs -// SPDX-License-Identifier: MIT - -package database - -import "gorm.io/gorm" - -// Config holds database configuration -type Config struct { - Host string - User string - Password string - DBName string - Port string -} - -// User holds user info in the database -type User struct { - gorm.Model - Username string `json:"username" gorm:"not null"` - Password string `json:"password" gorm:"not null"` - // Phone string `json:"phone" gorm:"unique; not null; type:char(10)"` - // Email string `json:"email" gorm:"unique; not null; type:varchar(255)"` - Phone string `json:"phone" gorm:"not null; type:char(10)"` - Email string `json:"email" gorm:"not null; type:varchar(255)"` - Role string `json:"role" gorm:"default:user; not null"` - ImageURL string `json:"image_url" gorm:"default:''; not null; type:varchar(255)"` - // GitHubID string `json:"github_id" gorm:"unique; not null; type:varchar(255)"` - GitHubID string `json:"github_id" gorm:"not null; type:varchar(255)"` - AccountStatus string `json:"account_status" gorm:"default:active; not null"` // active, banned, account takeover, etc. - Verified bool `json:"verified" gorm:"default:false; not null"` - AdditionalEmails string `json:"additional_emails" gorm:"type:text; default:'{}'"` - - // TODO: add apps after GBM discussion -} - -type Organization struct { - gorm.Model - Name string `json:"name" gorm:"not null; type:varchar(255)"` -} - -type Application struct { - gorm.Model - Name string `json:"name" gorm:"not null; type:varchar(255)"` - Description string `json:"description" gorm:"not null; type:varchar(255)"` - AppURL string `json:"app_url" gorm:"not null; type:varchar(255)"` - AllowedDomains string `json:"allowed_domains" gorm:"not null; type:varchar(255)"` - OrganizationID uint `json:"organization_id" gorm:"not null"` - Organization Organization `json:"organization" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:OrganizationID"` -} diff --git a/internal/log/log.go b/internal/logger/logger.go similarity index 77% rename from internal/log/log.go rename to internal/logger/logger.go index dc45c16..c397d38 100644 --- a/internal/log/log.go +++ b/internal/logger/logger.go @@ -1,7 +1,7 @@ // Copyright (c) 2025 SDSLabs // SPDX-License-Identifier: MIT -package log +package logger import ( "errors" @@ -47,7 +47,7 @@ func initLogger(isProd bool) *zerolog.Logger { return &l } -func LoggerMiddleware(logger *zerolog.Logger) gin.HandlerFunc { +func Middleware(logger *zerolog.Logger) gin.HandlerFunc { return func(c *gin.Context) { log := logger.With().Logger() @@ -92,3 +92,36 @@ func LoggerMiddleware(logger *zerolog.Logger) gin.HandlerFunc { } var Logger = initLogger(false) + +// Convenience functions for direct logging without accessing Logger field +func Info() *zerolog.Event { + return Logger.Info() +} + +func Error() *zerolog.Event { + return Logger.Error() +} + +func Err(err error) *zerolog.Event { + return Logger.Err(err) +} + +func Debug() *zerolog.Event { + return Logger.Debug() +} + +func Warn() *zerolog.Event { + return Logger.Warn() +} + +func Fatal() *zerolog.Event { + return Logger.Fatal() +} + +func Panic() *zerolog.Event { + return Logger.Panic() +} + +func Trace() *zerolog.Event { + return Logger.Trace() +} diff --git a/internal/middlewares/verify_csrf.go b/internal/middlewares/verify_csrf.go new file mode 100644 index 0000000..0dc6e42 --- /dev/null +++ b/internal/middlewares/verify_csrf.go @@ -0,0 +1,81 @@ +package middlewares + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/sdslabs/nymeria/internal/utils" +) + +func CSRFMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Skip CSRF validation for GET, HEAD, OPTIONS requests + if c.Request.Method == "GET" || c.Request.Method == "HEAD" || c.Request.Method == "OPTIONS" { + c.Next() + return + } + + // Get user ID from header (TODO: replace with session/JWT when available) + userID := c.GetHeader("X-User-ID") // TODO: Get user ID from session + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "status": "error", + "message": "User ID is required for CSRF protection", + }) + c.Abort() + return + } + + var csrfToken string + + // First, try to get CSRF token from JSON body + if c.GetHeader("Content-Type") == "application/json" { + var body map[string]interface{} + // Create a copy of the request body for CSRF token extraction + if err := c.ShouldBindJSON(&body); err == nil { + if token, exists := body["csrf_token"]; exists { + if tokenStr, ok := token.(string); ok { + csrfToken = tokenStr + } + } + } + // Restore the body for the actual handler by binding again + // Note: This is a limitation - we need to read the body twice + // A more elegant solution would be to buffer the body + } + + // If not found in JSON body, try header + if csrfToken == "" { + csrfToken = c.GetHeader("X-CSRF-Token") + } + + // If not found in header, try form field + if csrfToken == "" { + csrfToken = c.PostForm("csrf_token") + } + + // If no CSRF token found, return error + if csrfToken == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "status": "error", + "message": "CSRF token is required", + }) + c.Abort() + return + } + + // Validate CSRF token + if !utils.ValidateCSRFToken(userID, csrfToken) { + c.JSON(http.StatusUnauthorized, gin.H{ + "status": "error", + "message": "Invalid or expired CSRF token", + }) + c.Abort() + return + } + + // Token is valid, continue to the next handler + c.Next() + } +} diff --git a/internal/smtp/email.go b/internal/smtp/email.go new file mode 100644 index 0000000..d98a630 --- /dev/null +++ b/internal/smtp/email.go @@ -0,0 +1,156 @@ +package smtp + +import ( + "bytes" + "crypto/tls" + "fmt" + "html/template" + "math/rand" + "net/smtp" + "os" + "path/filepath" + "time" + + "github.com/sdslabs/nymeria/internal/config" + "github.com/sdslabs/nymeria/internal/logger" +) + +func generateOTP() string { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + return fmt.Sprintf("%06d", r.Intn(1000000)) // 6-digit OTP +} + +// sendEmail sends an OTP email using an SMTP client with TLS. Falls back to plain text if template is missing. +func sendEmail(email, otp string) error { + from := config.AppConfig.MailFrom + password := config.AppConfig.MailPassword + smtpHost := config.AppConfig.SMTPHost + smtpPort := config.AppConfig.SMTPPort + + // Email subject + subject := "Your OTP Code" + + // Path to email template + emailTemplatePath := filepath.Join("internal", "templates", "email_template.html") + + // Check if template file exists + var body bytes.Buffer + _, err := os.Stat(emailTemplatePath) + if err == nil { + // Template exists, parse and execute + tmpl, err := template.ParseFiles(emailTemplatePath) + if err != nil { + logger.Err(err).Msg("Failed to read email template") + return err + } + + // Create structured data matching the template expectations + emailData := struct { + Subject string + AppName string + Title string + Message string + VerificationCode string + ExpiryTime int + }{ + Subject: subject, + AppName: "Nymeria", // You can make this configurable + Title: "Email Verification", + Message: "Please use the verification code below to complete your authentication.", + VerificationCode: otp, + ExpiryTime: 5, // 5 minutes expiry + } + + if err := tmpl.Execute(&body, emailData); err != nil { + logger.Err(err).Msg("Failed to execute email template") + return err + } + } else { + // Template does not exist, send plain text email + logger.Warn().Msg("Template not found, sending plain text email") + body.WriteString(fmt.Sprintf("Hello,\n\nYour OTP is: %s\nThis OTP will expire in 5 minutes.\n\nRegards,\nTeam", otp)) + } + + // Create email headers + message := fmt.Sprintf("From: %s\r\n", from) + + fmt.Sprintf("To: %s\r\n", email) + + fmt.Sprintf("Subject: %s\r\n", subject) + + "MIME-Version: 1.0\r\n" + + // Set Content-Type based on template availability + bodyString := body.String() + if len(bodyString) > 0 && bodyString[0] == '<' { + message += "Content-Type: text/html; charset=\"utf-8\"\r\n\r\n" + } else { + message += "Content-Type: text/plain; charset=\"utf-8\"\r\n\r\n" + } + + message += bodyString + + // Setup TLS connection + tlsConfig := &tls.Config{ + InsecureSkipVerify: true, // Set true only if SMTP server uses self-signed certs + ServerName: smtpHost, + } + + // Connect to SMTP server + conn, err := tls.Dial("tcp", smtpHost+":"+smtpPort, tlsConfig) + if err != nil { + logger.Info().Str("smtpHost", smtpHost).Str("smtpPort", smtpPort).Msg("Failed to connect to SMTP server") + logger.Err(err).Msg("Failed to connect to SMTP server") + return err + } + + client, err := smtp.NewClient(conn, smtpHost) + if err != nil { + logger.Err(err).Msg("Failed to create SMTP client") + return err + } + defer client.Close() + + // Authenticate + auth := smtp.PlainAuth("", from, password, smtpHost) + if err := client.Auth(auth); err != nil { + logger.Err(err).Msg("SMTP authentication failed") + return err + } + + // Set sender and recipient + if err := client.Mail(from); err != nil { + logger.Err(err).Msg("Failed to set sender") + return err + } + + if err := client.Rcpt(email); err != nil { + logger.Err(err).Msg("Failed to set recipient") + return err + } + + // Write email data + w, err := client.Data() + if err != nil { + logger.Err(err).Msg("Failed to get SMTP data writer") + return err + } + + _, err = w.Write([]byte(message)) + if err != nil { + logger.Err(err).Msg("Failed to write email content") + return err + } + + err = w.Close() + if err != nil { + logger.Err(err).Msg("Failed to close SMTP writer") + return err + } + + // Quit SMTP session + if err := client.Quit(); err != nil { + logger.Err(err).Msg("Failed to close SMTP connection") + return err + } + + logger.Info().Str("email", email).Msg("OTP email sent successfully") + return nil +} diff --git a/internal/smtp/otp.go b/internal/smtp/otp.go new file mode 100644 index 0000000..4a75592 --- /dev/null +++ b/internal/smtp/otp.go @@ -0,0 +1,115 @@ +package smtp + +import ( + "errors" + "regexp" + "time" + + "gorm.io/gorm" + + "github.com/sdslabs/nymeria/internal/config" + database "github.com/sdslabs/nymeria/internal/database/otp" + "github.com/sdslabs/nymeria/internal/database/schema" + "github.com/sdslabs/nymeria/internal/logger" +) + +func SendOTPHandler(email string, isIITRCheck bool) (otp string, err error) { + + smtpHost := config.AppConfig.SMTPHost + smtpPort := config.AppConfig.SMTPPort + + if smtpHost == "" || smtpPort == "" { + logger.Warn().Msg("SMTP not configured") + return "", errors.New("SMTP not configured") + } + + re := regexp.MustCompile(`^.*@.*iitr\.ac\.in$`) + isIITR := re.MatchString(email) + + if isIITRCheck && !isIITR { + return "", errors.New("email should be of IITR domain") + } + + otp = generateOTP() + expiry := time.Now().Add(time.Duration(config.AppConfig.OTPExpiry) * time.Minute) // OTP expires in 5 minutes + + otpEntry, err := database.QueryOTPEntry(email) + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + otpEntry = schema.OTP{ + Email: email, + Code: otp, + ExpiresAt: expiry, + Verified: false, + } + } else { + logger.Err(err).Msg("Failed to query OTP") + return "", errors.New("failed to send OTP") + } + } + + if otpEntry.Verified { + return "", errors.New("email already verified") + } + + otpEntry.Code = otp + otpEntry.ExpiresAt = expiry + + err = database.CreateOTPEntry(&otpEntry) + if err != nil { + logger.Err(err).Msg("Failed to store OTP") + return "", errors.New("failed to store OTP") + } + + // Send OTP to email + err = sendEmail(email, otp) + if err != nil { + logger.Err(err).Msg("Failed to send OTP") + return "", errors.New("failed to send OTP") + } + + return otp, nil +} + +func VerifyOTPHandler(email string, otp string) error { + + smtpHost := config.AppConfig.SMTPHost + smtpPort := config.AppConfig.SMTPPort + + if smtpHost == "" || smtpPort == "" { + logger.Warn().Msg("SMTP not configured") + return errors.New("SMTP not configured") + } + + otpEntry, err := database.QueryOTPEntry(email) + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("OTP not found") + } + logger.Err(err).Msg("Failed to query OTP") + return errors.New("failed to verify OTP") + } + + if otpEntry.Verified { + return errors.New("email already verified") + } + + if otpEntry.Code != otp { + return errors.New("wrong OTP") + } + + if time.Now().After(otpEntry.ExpiresAt) { + return errors.New("OTP expired") + } + + err = database.VerifyOTPEntry(email) + + if err != nil { + logger.Err(err).Msg("Failed to verify OTP") + return errors.New("failed to verify OTP") + } + + return nil +} diff --git a/internal/templates/email_template.html b/internal/templates/email_template.html new file mode 100644 index 0000000..d5803c3 --- /dev/null +++ b/internal/templates/email_template.html @@ -0,0 +1,203 @@ + + + + + + + {{.Subject}} + + + +
+ +
+

{{.AppName}}

+
+ + +
+

{{.Title}}

+ +

{{.Message}}

+ + + {{if .VerificationCode}} +
+

Your verification code:

+
{{.VerificationCode}}
+

+ This code will expire in {{.ExpiryTime}} minutes. +

+
+ {{end}} + +

+ If you didn't request this verification code, please ignore this email. +

+
+ + + +
+ + diff --git a/internal/utils/crypto.go b/internal/utils/crypto.go new file mode 100644 index 0000000..cf122b9 --- /dev/null +++ b/internal/utils/crypto.go @@ -0,0 +1,15 @@ +package utils + +import ( + "crypto/rand" + "encoding/hex" +) + +// GenerateRandomString generates a random string of specified length +func GenerateRandomString(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} diff --git a/internal/utils/csrf.go b/internal/utils/csrf.go new file mode 100644 index 0000000..b948801 --- /dev/null +++ b/internal/utils/csrf.go @@ -0,0 +1,71 @@ +// Copyright (c) 2025 SDSLabs +// SPDX-License-Identifier: MIT + +package utils + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "strconv" + "strings" + "time" + + "github.com/sdslabs/nymeria/internal/config" +) + +// GenerateStatelessCSRF returns a token valid for 2 minutes +func GenerateCSRFToken(userID string) (string, error) { + csrfSecretKey := []byte(config.AppConfig.CSRFSecret) + timestamp := time.Now().Unix() + payload := fmt.Sprintf("%s:%d", userID, timestamp) + + mac := hmac.New(sha256.New, csrfSecretKey) + mac.Write([]byte(payload)) + signature := mac.Sum(nil) + + rawToken := fmt.Sprintf("%s:%d:%s", userID, timestamp, base64.URLEncoding.EncodeToString(signature)) + return base64.URLEncoding.EncodeToString([]byte(rawToken)), nil +} + +func ValidateCSRFToken(userID, token string) bool { + csrfSecretKey := []byte(config.AppConfig.CSRFSecret) + decoded, err := base64.URLEncoding.DecodeString(token) + if err != nil { + return false + } + + parts := strings.Split(string(decoded), ":") + if len(parts) != 3 { + return false + } + + tokenUserID := parts[0] + timestampStr := parts[1] + sigBase64 := parts[2] + + if tokenUserID != userID { + return false + } + + timestamp, err := strconv.ParseInt(timestampStr, 10, 64) + if err != nil { + return false + } + + maxAge := time.Duration(config.AppConfig.CSRFMaxAge) * time.Minute + + // Check expiration + if time.Since(time.Unix(timestamp, 0)) > maxAge { + return false + } + + // Recompute HMAC + payload := fmt.Sprintf("%s:%d", userID, timestamp) + mac := hmac.New(sha256.New, csrfSecretKey) + mac.Write([]byte(payload)) + expectedSig := base64.URLEncoding.EncodeToString(mac.Sum(nil)) + + return hmac.Equal([]byte(expectedSig), []byte(sigBase64)) +}