From 66c35e7e4a2fcb6101437635ea9dc3fa1725bfdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=90=99PiperYxzzy?= Date: Tue, 3 May 2022 18:46:22 +0200 Subject: [PATCH] Adding admin creation, conf and other items * Config now added, accessible via config.Config * Admin can now be generated via a randomized URL if there are no admins in the system * Added a shared floor to login attempts to block enumeration attacks --- conf.json | 4 ++ config/config.go | 27 ++++++++++++++ controllers/core/core.go | 80 ++++++++++++++++++++++++++++++++++------ main.go | 20 ++++++++++ models/admin.go | 2 +- models/auth.go | 13 +++++-- models/user.go | 2 +- util/util.go | 8 ++-- 8 files changed, 134 insertions(+), 22 deletions(-) create mode 100644 conf.json create mode 100644 config/config.go diff --git a/conf.json b/conf.json new file mode 100644 index 0000000..7017f33 --- /dev/null +++ b/conf.json @@ -0,0 +1,4 @@ +{ + "AllowFreshAdminGeneration": true, + "AdminEmails": ["admin@admin.invalid"] +} \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..cb3b330 --- /dev/null +++ b/config/config.go @@ -0,0 +1,27 @@ +package config + +import ( + "encoding/json" + "os" +) + +type StackConfiguration struct { + ConfigLoaded bool + AllowFreshAdminGeneration bool + AdminEmails []string + AdminHmacEnv string + UserHmacEnv string +} + +var Config = StackConfiguration{} + +func LoadConfig() { + file, _ := os.Open("conf.json") + defer file.Close() + dec := json.NewDecoder(file) + if err := dec.Decode(&Config); err != nil { + panic(err) + } + + Config.ConfigLoaded = true +} diff --git a/controllers/core/core.go b/controllers/core/core.go index d5a1d8a..a7da217 100644 --- a/controllers/core/core.go +++ b/controllers/core/core.go @@ -6,11 +6,13 @@ package core import ( "fmt" "net/http" + "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/yxzzy-wtf/gin-gonic-prepack/config" "github.com/yxzzy-wtf/gin-gonic-prepack/database" "github.com/yxzzy-wtf/gin-gonic-prepack/models" "github.com/yxzzy-wtf/gin-gonic-prepack/util" @@ -74,12 +76,12 @@ func UserSignup() gin.HandlerFunc { return } else { // Email conflict means we should still mock verify - go util.SendEmail("Signup Attempt", "Someone tried to sign up with this email. This is a cursory warning. If it was you, good news! You're already signed up!", u.Email) + go util.SendEmail("Signup Attempt", "Someone tried to sign up with this email. This is a cursory warning. If it was you, good news! You're already signed up!", []string{u.Email}) } } else { // Send verification verifyToken := u.GetVerificationJwt() - go util.SendEmail("Verify Email", "Helloooo! Go here to verify: http://localhost:9091/v1/verify?verify="+verifyToken, u.Email) + go util.SendEmail("Verify Email", "Helloooo! Go here to verify: http://localhost:9091/v1/verify?verify="+verifyToken, []string{u.Email}) } c.JSON(http.StatusOK, util.NextMsg{Next: "verification pending"}) @@ -91,15 +93,28 @@ func UserSignup() gin.HandlerFunc { // are only displayed IFF the user exists AND the password is correct, otherwise a 401 is returned func UserLogin() gin.HandlerFunc { return func(c *gin.Context) { + // Why do we do this? Assuming a consistent and stable service and an attacker + // with an equally consistent internet connection, it is possible to + // still launch an enumeration attack by comparing the time of a known + // extant address and a known non-extant one. For this reason, login duration is + // floored to at least 5 seconds + minTime := make(chan bool) + go func(c chan bool) { + time.Sleep(time.Second * 5) + minTime <- true + }(minTime) + var loginVals login if err := c.ShouldBind(&loginVals); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, util.FailMsg{Reason: "Requires username and password"}) + <-minTime return } u := models.User{} if err := u.ByEmail(loginVals.UserKey); err != nil { c.AbortWithStatus(http.StatusUnauthorized) + <-minTime return } @@ -109,17 +124,20 @@ func UserLogin() gin.HandlerFunc { } else { c.AbortWithStatus(http.StatusUnauthorized) } + <-minTime return } if loginVals.TwoFactor != "" && !checkTwoFactorNotReused(&u.Auth, loginVals.TwoFactor) { fmt.Printf("WARNING: two factor code %v reused for %v\n", loginVals.TwoFactor, u.Uid) c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: "2fa reused"}) + <-minTime return } jwt, maxAge := u.GetJwt() c.SetCookie(JwtHeader, jwt, maxAge, "/v1/sec/", "", true, true) + <-minTime } } @@ -191,7 +209,7 @@ func UserForgotPassword() gin.HandlerFunc { if err := u.ByEmail(forgotVals.UserKey); err == nil { // Actually send renew token forgotJwt := u.GetResetPasswordJwt() - go util.SendEmail("Forgot Password", "Token to reset password: "+forgotJwt, u.Email) + go util.SendEmail("Forgot Password", "Token to reset password: "+forgotJwt, []string{u.Email}) } c.JSON(http.StatusOK, util.NextMsg{Next: "check email to reset"}) @@ -249,19 +267,24 @@ func UserResetForgottenPassword() gin.HandlerFunc { // Admin login functionality, similar to user login but requires 2FA to be set up. func AdminLogin() gin.HandlerFunc { return func(c *gin.Context) { - var loginVals login - if err := c.ShouldBind(&loginVals); err != nil { - c.AbortWithStatusJSON(http.StatusBadRequest, util.FailMsg{Reason: "requires username and password"}) - } + // Same as user slowdown + minTime := make(chan bool) + go func(c chan bool) { + time.Sleep(time.Second * 5) + minTime <- true + }(minTime) - if loginVals.TwoFactor == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: "admin access requires 2FA"}) + var loginVals login + if err := c.ShouldBind(&loginVals); err != nil || loginVals.TwoFactor == "" { + c.AbortWithStatusJSON(http.StatusBadRequest, util.FailMsg{Reason: "Requires username, 2FA and password"}) + <-minTime return } a := models.Admin{} if err := a.ByEmail(loginVals.UserKey); err != nil { c.AbortWithStatus(http.StatusUnauthorized) + <-minTime return } @@ -271,17 +294,20 @@ func AdminLogin() gin.HandlerFunc { } else { c.AbortWithStatus(http.StatusUnauthorized) } + <-minTime return } if loginVals.TwoFactor != "" && !checkTwoFactorNotReused(&a.Auth, loginVals.TwoFactor) { - fmt.Printf("WARNING: two factor code %v reused for admin %v\n", loginVals.TwoFactor, a.Uid) + fmt.Printf("WARNING: two factor code %v reused by admin %v\n", loginVals.TwoFactor, a.Uid) c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: "2fa reused"}) + <-minTime return } jwt, maxAge := a.GetJwt() - c.SetCookie(JwtHeader, jwt, maxAge, "/v1/adm", "", true, true) + c.SetCookie(JwtHeader, jwt, maxAge, "/v1/sec/", "", true, true) + <-minTime } } @@ -344,7 +370,6 @@ func LiveTwoFactor() gin.HandlerFunc { } var a models.Auth - fmt.Println(p) if p.Role == "user" { u := models.User{} if err := database.Db.Find(&u, "uid = ?", p.Uid).Error; err != nil { @@ -382,6 +407,37 @@ func LiveTwoFactor() gin.HandlerFunc { } } +func StarterAdmin() gin.HandlerFunc { + return func(c *gin.Context) { + var count int64 + database.Db.Model(&models.Admin{}).Count(&count) + if count != 0 { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + var signupVals signup + if err := c.ShouldBind(&signupVals); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, util.FailMsg{Reason: "invalid fields, requires userkey=email and password"}) + return + } + + a := models.Admin{} + if err := a.ByEmail(signupVals.UserKey); err == nil { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + a.Email = signupVals.UserKey + a.SetPassword(signupVals.Password) + a.GenerateNewTwoFactorSecret() + + go util.SendEmail("Admin Created", "A new admin, "+a.Email+", has been created", config.Config.AdminEmails) + + c.JSON(http.StatusOK, util.NextMsg{Next: "db verify"}) + } +} + // A simple context-aware ping method. If there is a "principal" in this request context // it will indicate the principal Uid and role in the response func Doot() gin.HandlerFunc { diff --git a/main.go b/main.go index f19a171..fc57666 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,12 @@ package main import ( + "fmt" "log" "net/http" + "github.com/google/uuid" + "github.com/yxzzy-wtf/gin-gonic-prepack/config" "github.com/yxzzy-wtf/gin-gonic-prepack/controllers/core" "github.com/yxzzy-wtf/gin-gonic-prepack/database" "github.com/yxzzy-wtf/gin-gonic-prepack/models" @@ -19,6 +22,8 @@ func Migrate(g *gorm.DB) { } func main() { + config.LoadConfig() + db := database.Init() Migrate(db) @@ -28,6 +33,21 @@ func main() { // Ping functionality v1.GET("/doot", core.Doot()) + if config.Config.AllowFreshAdminGeneration { + var adminCount int64 + database.Db.Model(models.Admin{}).Count(&adminCount) + + if adminCount == 0 { + randUri := uuid.New() + v1.POST("/"+randUri.String(), core.StarterAdmin()) + + fmt.Println("#################") + fmt.Println("No admins and AllowFreshAdminGeneration=TRUE") + fmt.Println("Sign up starter at: /" + randUri.String()) + fmt.Println("#################") + } + } + // Standard user signup, verify, login and forgot/reset pw v1.POST("/signup", core.UserSignup()) v1.POST("/login", core.UserLogin()) diff --git a/models/admin.go b/models/admin.go index 5561068..312685e 100644 --- a/models/admin.go +++ b/models/admin.go @@ -16,7 +16,7 @@ type Admin struct { const adminJwtDuration = time.Hour * 2 -var AdminHmac = util.GenerateHmac() +var AdminHmac = util.GenerateHmac(64) func (a *Admin) GetJwt() (string, int) { j := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ diff --git a/models/auth.go b/models/auth.go index 26ea30b..a9b4813 100644 --- a/models/auth.go +++ b/models/auth.go @@ -7,15 +7,16 @@ import ( "github.com/google/uuid" "github.com/pquerna/otp/totp" + "github.com/yxzzy-wtf/gin-gonic-prepack/util" "golang.org/x/crypto/bcrypt" ) type Auth struct { Base - PasswordHash string - TwoFactorSecret string - TwoFactorRecovery string - Verified bool + PasswordHash string `json:"-"` + TwoFactorSecret string `json:"-"` + TwoFactorRecovery string `json:"-"` + Verified bool `json:"-"` } func (a *Auth) SetPassword(pass string) error { @@ -85,3 +86,7 @@ func (a *Auth) ValidateTwoFactor(tfCode string, stamp time.Time) error { return errors.New("unlock invalid") } } + +func (a *Auth) GenerateNewTwoFactorSecret() { + a.TwoFactorSecret = string(util.GenerateHmac(20)) +} diff --git a/models/user.go b/models/user.go index 4449590..3d6c3ee 100644 --- a/models/user.go +++ b/models/user.go @@ -17,7 +17,7 @@ type User struct { const userJwtDuration = time.Hour * 24 -var UserHmac = util.GenerateHmac() +var UserHmac = util.GenerateHmac(64) func (u *User) GetJwt() (string, int) { j := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ diff --git a/util/util.go b/util/util.go index 6d5bc47..187c9bc 100644 --- a/util/util.go +++ b/util/util.go @@ -8,8 +8,8 @@ import ( "github.com/google/uuid" ) -func GenerateHmac() []byte { - b := make([]byte, 64) +func GenerateHmac(length int) []byte { + b := make([]byte, length) if _, err := rand.Read(b); err != nil { panic(err) } @@ -30,9 +30,9 @@ type NextMsg struct { Next string `json:"nextaction"` } -func SendEmail(title string, body string, recipient string) { +func SendEmail(title string, body string, recipients []string) { //TODO - fmt.Println("Send", title, body, "to", recipient) + fmt.Println("Send", title, body, "to", recipients) } func ParseJwt(tokenStr string, hmac []byte) (jwt.MapClaims, error) {