diff --git a/_examples/example.config.toml b/_examples/example.config.toml index fb6d97d5..0279d65e 100644 --- a/_examples/example.config.toml +++ b/_examples/example.config.toml @@ -117,6 +117,19 @@ host = "localhost" port = "5432" sslmode = "prefer" +# Configuration related to plugin management in Beast +# This section lists all plugins that should be initialized at runtime +[plugins_config] + +# List of plugin names to be enabled. Only plugins listed here will be initialized +# For reference, Beast currently supports: "DummyPlugin", "EmailVerifyPlugin" +enabled_plugins = ["DummyPlugin", "EmailVerifyPlugin"] + +#Allowed email ids for the Email Verify plugin +[emailverify] +allowed_domains = ["example.com", "organization.example.com"] + + # The following fields are required only while hosting a competition on beast # This section contains information about the competition to be hosted # Structure of the sections with the acceptable fields are: diff --git a/api/main.go b/api/main.go index 1ca6c93e..d0e487df 100644 --- a/api/main.go +++ b/api/main.go @@ -17,6 +17,8 @@ import ( "github.com/sdslabs/beastv4/pkg/remoteManager" "github.com/sdslabs/beastv4/pkg/scheduler" wpool "github.com/sdslabs/beastv4/pkg/workerpool" + _ "github.com/sdslabs/beastv4/plugins/dummy" + _ "github.com/sdslabs/beastv4/plugins/email_verify" ) const ( @@ -66,7 +68,7 @@ func RunBeastApiServer(port, defaultauthorpassword string, autoDeploy, healthPro auth.Init(core.ITERATIONS, core.HASH_LENGTH, core.TIMEPERIOD, core.ISSUER, config.Cfg.JWTSecret, []string{core.USER_ROLES["author"]}, []string{core.USER_ROLES["admin"]}, []string{core.USER_ROLES["contestant"]}) remoteManager.Init() database.Init() - + runBeastApiBootsteps(defaultauthorpassword) // Initialize Gin router. diff --git a/api/router.go b/api/router.go index cd98d4e8..f2b19ff4 100644 --- a/api/router.go +++ b/api/router.go @@ -9,6 +9,7 @@ import ( "github.com/gin-contrib/static" "github.com/gin-gonic/gin" "github.com/sdslabs/beastv4/core" + "github.com/sdslabs/beastv4/plugins" ) func dummyHandler(c *gin.Context) { @@ -29,6 +30,10 @@ func initGinRouter() *gin.Engine { } router.Use(cors.New(corsConfig)) router.GET("/dummy", dummyHandler) + + // Initialize all plugins + plugins.InitPlugins(router) + // Authorization routes group authGroup := router.Group("/auth") { diff --git a/core/config/config.go b/core/config/config.go index 85bd087a..271e1873 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -112,7 +112,7 @@ import ( // dbname = "beast" // host = "localhost" // port = "5432" -// sslmode = "prefer" +// sslmode = "prefer" // ``` type BeastConfig struct { AuthorizedKeysFile string `toml:"authorized_keys_file"` @@ -137,6 +137,12 @@ type BeastConfig struct { // For SMTP Configuration MailConfig MailConfig `toml:"mail_config"` + + // Plugins configuration + PluginsEnabled PluginsConfig `toml:"plugins_config"` + + // Email verification plugin configuration + EmailVerify EmailVerifyConfig `toml:"emailverify"` } func (config *BeastConfig) ValidateConfig() error { @@ -365,6 +371,14 @@ type MailConfig struct { SMTPPort string `toml:"smtpPort"` } +type PluginsConfig struct { + EnabledPlugins []string `toml:"enabled_plugins"` +} + +type EmailVerifyConfig struct { + AllowedDomains []string `toml:"allowed_domains"` +} + func UpdateCompetitionInfo(competitionInfo *CompetitionInfo) error { configPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME) var config BeastConfig diff --git a/plugins/base.go b/plugins/base.go new file mode 100644 index 00000000..c3a3fb30 --- /dev/null +++ b/plugins/base.go @@ -0,0 +1,49 @@ +package plugins + +import ( + "github.com/sdslabs/beastv4/core/config" + log "github.com/sirupsen/logrus" + + "github.com/gin-gonic/gin" +) + +type Plugin interface { + Name() string + Description() string + Init(*gin.Engine) error +} + +//All plugins will be initialized with the gin engine, at startup +//Logging twice, once when we register and once when we initialize + +var loadedPlugins []Plugin + +func Register(p Plugin) { + loadedPlugins = append(loadedPlugins, p) + log.Infof("Plugin registered: %s\n %s", p.Name(), p.Description()) + +} + +func isPluginEnabled(pluginName string) bool { + enabledPlugins := config.Cfg.PluginsEnabled.EnabledPlugins + for _, name := range enabledPlugins { + if name == pluginName { + return true + } + } + return false +} + +func InitPlugins(router *gin.Engine) { + for _, p := range loadedPlugins { + if !isPluginEnabled(p.Name()) { + log.Warnf("%s is not enabled, skipping initialization", p.Name()) + continue + } + log.Infof("Intializing plugin: %s", p.Name()) + err := p.Init(router) + if err != nil { + log.Errorf("Error in Plugin Initializing %s: %v", p.Name(), err) + } + } +} diff --git a/plugins/dummy/plugin.go b/plugins/dummy/plugin.go new file mode 100644 index 00000000..d2d8bf2c --- /dev/null +++ b/plugins/dummy/plugin.go @@ -0,0 +1,29 @@ +package dummy + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sdslabs/beastv4/plugins" +) + +type DummyPlugin struct{} + +func (p *DummyPlugin) Name() string { + return "DummyPlugin" +} + +func (p *DummyPlugin) Description() string { + return "A dummy plugin to do the testing of plugin system" +} + +func (p *DummyPlugin) Init(router *gin.Engine) error { + router.GET("api/plugins/dummy", func(context *gin.Context) { + context.JSON(http.StatusOK, gin.H{"plugin": "The dummy plugin works"}) + }) + return nil +} + +func init() { + plugins.Register(&DummyPlugin{}) +} diff --git a/plugins/email_verify/plugin.go b/plugins/email_verify/plugin.go new file mode 100644 index 00000000..b31688e2 --- /dev/null +++ b/plugins/email_verify/plugin.go @@ -0,0 +1,169 @@ +package emailverify + +import ( + "errors" + "net/http" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/gin-gonic/gin" + "github.com/jinzhu/gorm" + "github.com/sdslabs/beastv4/core" + "github.com/sdslabs/beastv4/core/config" + "github.com/sdslabs/beastv4/core/database" + "github.com/sdslabs/beastv4/pkg/auth" + "github.com/sdslabs/beastv4/plugins" +) + +type HTTPPlainResp struct { + Message string `json:"message" example:"Messsage in response to your request"` +} + +type HTTPPlainMapResp struct { + Messages map[string]string `json:"messages" example:"{\"name1\": \"message1\", \"name2\": \"message2\"}"` +} + +type HTTPErrorResp struct { + Error string `json:"error" example:"Error occured while veifying the challenge."` +} + +type EmailVerifyPlugin struct { + AllowedDomains []string +} + +func (p *EmailVerifyPlugin) Name() string { + return "EmailVerifyPlugin" +} + +func (p *EmailVerifyPlugin) Description() string { + return "A plugin to restrict registration to certain email domains" +} + +func (p *EmailVerifyPlugin) isAllowedEmail(email string) bool { + parts := strings.Split(email, "@") + if len(parts) != 2 { + return false + } + domain := parts[1] + for _, allowedDomain := range p.AllowedDomains { + log.Debugf("Checking domain: %s against allowed domain: %s", domain, allowedDomain) + if domain == allowedDomain { + log.Infof("Email domain allowed: %s", domain) + return true + } + } + log.Warnf("Email domain not allowed: %s", domain) + return false +} + +func (p *EmailVerifyPlugin) verifyEmailRegister(c *gin.Context, checkFlag bool) { + name := c.PostForm("name") + username := c.PostForm("username") + password := c.PostForm("password") + email := c.PostForm("email") + sshKey := c.PostForm("ssh-key") + + name = strings.TrimSpace(name) + username = strings.TrimSpace(strings.ToLower(username)) + password = strings.TrimSpace(password) + email = strings.TrimSpace(strings.ToLower(email)) + sshKey = strings.TrimSpace(sshKey) + + if username == "" || password == "" || email == "" { + + c.JSON(http.StatusBadRequest, HTTPPlainResp{ + Message: "Username, password and email can not be empty", + }) + return + } + + if len(username) > 12 { + c.JSON(http.StatusBadRequest, HTTPErrorResp{ + Error: "Username cannot be greater than 12 characters", + }) + return + } + if checkFlag { + if !p.isAllowedEmail(email) { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Registration restricted to organization emails only", + }) + return + } + } + + userEntry := database.User{ + Name: name, + AuthModel: auth.CreateModel(username, password, core.USER_ROLES["contestant"]), + Email: email, + SshKey: sshKey, + } + + if !config.SkipAuthorization { + smtpHost := config.Cfg.MailConfig.SMTPHost + smtpPort := config.Cfg.MailConfig.SMTPPort + + if smtpHost == "" || smtpPort == "" { + log.Errorf("WARNING: SMTP not configured") + } else { + otpEntry, err := database.QueryOTPEntry(email) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusUnauthorized, HTTPErrorResp{ + Error: "OTP not found, email not verified", + }) + return + } else { + log.Println("Failed to query OTP:", err) + c.JSON(http.StatusInternalServerError, HTTPErrorResp{ + Error: "Failed to send OTP", + }) + return + } + } + if !otpEntry.Verified { + c.JSON(http.StatusNotAcceptable, HTTPErrorResp{ + Error: "Email not verified, cannot register user", + }) + return + } + } + } + + err := database.CreateUserEntry(&userEntry) + if err != nil { + c.JSON(http.StatusNotAcceptable, HTTPErrorResp{ + Error: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, HTTPPlainResp{ + Message: "User created successfully", + }) +} + +func (p *EmailVerifyPlugin) Init(router *gin.Engine) error { + cfg := config.Cfg + checkFlag := false + if len(cfg.EmailVerify.AllowedDomains) > 0 { + p.AllowedDomains = cfg.EmailVerify.AllowedDomains + checkFlag = true + } + + router.Use(func(c *gin.Context) { + if c.Request.URL.Path == "/auth/register" && c.Request.Method == "POST" { + p.verifyEmailRegister(c, checkFlag) + c.Abort() + } else { + c.Next() + } + }) + + return nil +} + +func init() { + plugins.Register(&EmailVerifyPlugin{}) +} diff --git a/plugins/email_verify/test.sh b/plugins/email_verify/test.sh new file mode 100644 index 00000000..c5fb36ec --- /dev/null +++ b/plugins/email_verify/test.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +URL="http://localhost:5005/auth/register" + +echo "---- Testing Allowed Domain ----" +response_allowed=$(curl -s -o /dev/null -w "%{http_code}" -X POST $URL \ + -F "name=Neptune" \ + -F "username=neptune" \ + -F "password=romangod123" \ + -F "email=neptune@example.com") + +if [ "$response_allowed" -eq 200 ]; then + echo "✅ Test Passed-Allowed Domain" +else + echo "❌ Test Failed-Allowed Domain (Got HTTP $response_allowed)" +fi + +echo +echo "---- Testing Disallowed Domain ----" +response_disallowed=$(curl -s -o /dev/null -w "%{http_code}" -X POST $URL \ + -F "name=Poseidon" \ + -F "username=poseidon" \ + -F "password=greekgodsbetter" \ + -F "email=poseidon@gmail.com") + +if [ "$response_disallowed" -eq 403 ]; then + echo "✅ Test Passed - Disallowed Domain" +else + echo "❌ Test Failed - Disallowed Domain (Got HTTP $response_disallowed)" +fi