diff --git a/controllers/core/core.go b/controllers/core/core.go index 369a0ce..c8ba73a 100644 --- a/controllers/core/core.go +++ b/controllers/core/core.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -93,11 +94,36 @@ func UserLogin() gin.HandlerFunc { 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"}) + return + } + jwt, maxAge := u.GetJwt() c.SetCookie(JwtHeader, jwt, maxAge, "/v1/sec/", "", true, true) } } +func checkTwoFactorNotReused(a *models.Auth, tfCode string) bool { + var count int64 + database.Db.Model(&models.TotpUsage{}).Where("login_uid = ? AND code = ?", a.Uid, tfCode).Count(&count) + + if count > 0 { + // We found a token, should reject + return false + } + + used := models.TotpUsage{ + LoginUid: a.Uid, + Code: tfCode, + Used: time.Now(), + } + go database.Db.Create(&used) + + return true +} + func UserVerify() gin.HandlerFunc { return func(c *gin.Context) { verifyJwt, _ := c.GetQuery("verify") @@ -239,6 +265,12 @@ func AdminLogin() gin.HandlerFunc { 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) + c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: "2fa reused"}) + return + } + jwt, maxAge := a.GetJwt() c.SetCookie(JwtHeader, jwt, maxAge, "/v1/adm", "", true, true) } @@ -285,6 +317,59 @@ func AdminAuth() gin.HandlerFunc { return genericAuth("admin", models.AdminHmac) } +// A handler to attach to any method which requires a two-factor check +// at the time of calling. An example of this might be: changing email, +// changing password, or other high-sensitivity actions that warrant +// an extra 2FA check. +func LiveTwoFactor() gin.HandlerFunc { + return func(c *gin.Context) { + fmt.Println("Required live 2fa") + pif, exists := c.Get("principal") + p := pif.(util.PrincipalInfo) + if !exists { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + 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 { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + a = u.Auth + } else if p.Role == "admin" { + adm := models.Admin{} + if err := database.Db.Find(&adm, "uid = ?", p.Uid).Error; err != nil { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + a = adm.Auth + } + + if a.TwoFactorSecret != "" { + tfCode, exists := c.GetQuery("twofactorcode") + if !exists || len(tfCode) != 6 { + c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: "2fa required"}) + return + } + + if err := a.ValidateTwoFactor(tfCode, time.Now()); err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: err.Error()}) + return + } + + if !checkTwoFactorNotReused(&a, tfCode) { + c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: "2fa reused"}) + return + } + } + + } +} + func Doot() gin.HandlerFunc { return func(c *gin.Context) { piCtx, exists := c.Get("principal") diff --git a/main.go b/main.go index 61ba6d6..f19a171 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( func Migrate(g *gorm.DB) { g.AutoMigrate(&models.User{}) g.AutoMigrate(&models.Admin{}) + g.AutoMigrate(&models.TotpUsage{}) } func main() { @@ -36,6 +37,7 @@ func main() { v1Sec := v1.Group("/sec", core.UserAuth()) v1Sec.GET("/doot", core.Doot()) + v1Sec.GET("/2fa-doot", core.LiveTwoFactor(), core.Doot()) // Administrative login v1.POST("/admin", core.AdminLogin()) diff --git a/models/auth.go b/models/auth.go index 611ec85..26ea30b 100644 --- a/models/auth.go +++ b/models/auth.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/pquerna/otp/totp" "golang.org/x/crypto/bcrypt" ) @@ -55,6 +56,12 @@ func (a *Auth) CheckPassword(pass string) error { return bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(pass)) } +type TotpUsage struct { + LoginUid uuid.UUID `gorm:"index"` + Used time.Time + Code string `gorm:"index"` +} + func (a *Auth) ValidateTwoFactor(tfCode string, stamp time.Time) error { if tfCode == "" && a.TwoFactorSecret != "" { return errors.New("requires 2FA") diff --git a/models/auth_test.go b/models/auth_test.go index 6e2494a..068fe6a 100644 --- a/models/auth_test.go +++ b/models/auth_test.go @@ -3,6 +3,8 @@ package models import ( "testing" "time" + + "github.com/yxzzy-wtf/gin-gonic-prepack/database" ) func TestBadPasswords(t *testing.T) { @@ -75,6 +77,8 @@ func TestTwoFactorWhenNotSet(t *testing.T) { } func TestTwoFactor(t *testing.T) { + database.InitTestDb() + a := Auth{} a.TwoFactorSecret = "AAAAAAAAAAAAAAAA"