Skip to content
Open
13 changes: 13 additions & 0 deletions _examples/example.config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing space in comment. Should be "# Allowed email ids for the Email Verify plugin" (space after "#").

Suggested change
#Allowed email ids for the Email Verify plugin
# Allowed email ids for the Email Verify plugin

Copilot uses AI. Check for mistakes.
[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:
Expand Down
4 changes: 3 additions & 1 deletion api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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")
{
Expand Down
16 changes: 15 additions & 1 deletion core/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ import (
// dbname = "beast"
// host = "localhost"
// port = "5432"
// sslmode = "prefer"
// sslmode = "prefer"
// ```
type BeastConfig struct {
AuthorizedKeysFile string `toml:"authorized_keys_file"`
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions plugins/base.go
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +16 to +17
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment lacks proper spacing and formatting. Comments should start with a space after the "//" and use proper punctuation. This comment should be formatted as:

// All plugins will be initialized with the gin engine at startup.
// Logging twice: once when we register and once when we initialize.

Suggested change
//All plugins will be initialized with the gin engine, at startup
//Logging twice, once when we register and once when we initialize
// All plugins will be initialized with the gin engine at startup.
// Logging twice: once when we register and once when we initialize.

Copilot uses AI. Check for mistakes.

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())
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in comment: "Intializing" should be "Initializing".

Suggested change
log.Infof("Intializing plugin: %s", p.Name())
log.Infof("Initializing plugin: %s", p.Name())

Copilot uses AI. Check for mistakes.
err := p.Init(router)
if err != nil {
log.Errorf("Error in Plugin Initializing %s: %v", p.Name(), err)
}
}
}
29 changes: 29 additions & 0 deletions plugins/dummy/plugin.go
Original file line number Diff line number Diff line change
@@ -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) {
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The route path is missing a leading slash. It should be "/api/plugins/dummy" instead of "api/plugins/dummy". Without the leading slash, this route may not work as intended or could conflict with route groups.

Suggested change
router.GET("api/plugins/dummy", func(context *gin.Context) {
router.GET("/api/plugins/dummy", func(context *gin.Context) {

Copilot uses AI. Check for mistakes.
context.JSON(http.StatusOK, gin.H{"plugin": "The dummy plugin works"})
})
return nil
}

func init() {
plugins.Register(&DummyPlugin{})
}
169 changes: 169 additions & 0 deletions plugins/email_verify/plugin.go
Original file line number Diff line number Diff line change
@@ -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"`
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in the example string: "Messsage" should be "Message" (contains an extra 's').

Suggested change
Message string `json:"message" example:"Messsage in response to your request"`
Message string `json:"message" example:"Message in response to your request"`

Copilot uses AI. Check for mistakes.
}

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."`
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in the example string: "veifying" should be "verifying".

Suggested change
Error string `json:"error" example:"Error occured while veifying the challenge."`
Error string `json:"error" example:"Error occured while verifying the challenge."`

Copilot uses AI. Check for mistakes.
}
Comment on lines +19 to +29
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response types HTTPPlainResp, HTTPPlainMapResp, and HTTPErrorResp are duplicated from api/response.go (lines 7-17). This creates unnecessary code duplication and inconsistency risk.

Import and use the response types from the api package instead of redefining them.

Copilot uses AI. Check for mistakes.

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]
Comment on lines +43 to +48
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The email validation logic using strings.Split is too simplistic and doesn't properly validate email format. It will accept malformed emails like "user@@domain.com" or "user@" as long as there are exactly 2 parts when split by "@". Additionally, it doesn't handle edge cases like emails with plus addressing (e.g., "user+tag@domain.com") or multiple @ symbols correctly.

Consider using a more robust email validation approach, such as the net/mail package's ParseAddress function, before extracting the domain for validation.

Copilot uses AI. Check for mistakes.
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")
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using log.Errorf with "WARNING:" prefix is inconsistent. Since this is a warning, use log.Warnf instead to maintain proper log level semantics and consistency with the existing codebase (line 246 in api/auth.go uses log.Printf for the same warning).

Suggested change
log.Errorf("WARNING: SMTP not configured")
log.Warnf("SMTP not configured")

Copilot uses AI. Check for mistakes.
} 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",
})
}
Comment on lines +60 to +145
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entire registration handler logic (lines 60-145) is duplicated from api/auth.go. This creates a significant maintainability problem because any bug fixes or updates to the registration logic must be made in two places. Additionally, the response types (HTTPPlainResp, HTTPPlainMapResp, HTTPErrorResp) are redefined on lines 19-29, duplicating the types already defined in api/response.go.

Consider refactoring the original register function in api/auth.go to accept an optional email validation function as a parameter, then call that function from the plugin. This would allow the plugin to inject its domain validation logic without duplicating the entire registration flow.

Copilot uses AI. Check for mistakes.

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()
}
})
Comment on lines +155 to +162
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The middleware approach unconditionally intercepts and aborts all POST requests to /auth/register when the plugin is enabled, completely replacing the original registration handler. This is problematic because:

  1. It bypasses the normal route registration system
  2. Makes the original /auth/register route (line 40 in api/router.go) unreachable when the plugin is enabled
  3. Creates an all-or-nothing scenario where the original handler cannot execute

A better approach would be to add the email domain validation as a check within the middleware but then call c.Next() to allow the original handler to process the request, or use a pre-handler hook pattern. Alternatively, refactor the registration logic to be composable so plugins can inject validation steps without duplicating the entire handler.

Copilot uses AI. Check for mistakes.

return nil
}

func init() {
plugins.Register(&EmailVerifyPlugin{})
}
30 changes: 30 additions & 0 deletions plugins/email_verify/test.sh
Original file line number Diff line number Diff line change
@@ -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)"
Comment on lines +13 to +15
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent spacing in test output message. Line 13 has "Test Passed-Allowed" (no space before hyphen) while line 27 has "Test Passed - Disallowed" (spaces around hyphen). For consistency, both should use the same format.

Suggested change
echo "✅ Test Passed-Allowed Domain"
else
echo "❌ Test Failed-Allowed Domain (Got HTTP $response_allowed)"
echo "✅ Test Passed - Allowed Domain"
else
echo "❌ Test Failed - Allowed Domain (Got HTTP $response_allowed)"

Copilot uses AI. Check for mistakes.
fi
Comment on lines +5 to +16
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test script expects HTTP 200 for allowed domains, but based on the plugin implementation, when email domain validation passes and the user is successfully created, the response will be HTTP 200. However, if the same username is used in subsequent test runs, the CreateUserEntry will fail due to duplicate username constraints, causing the test to fail on repeated runs.

The test should either use unique usernames for each run (e.g., by appending a timestamp) or clean up created users after the test, to ensure the test is idempotent and can be run multiple times.

Copilot uses AI. Check for mistakes.

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