From 6e7b30be0a6a02a9855f0986f4b6bb7382d93a1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=90=99PiperYxzzy?= Date: Sun, 1 May 2022 20:49:03 +0200 Subject: [PATCH] Added test suites for all current models --- controllers/core/core.go | 29 ++----- controllers/core/core_test.go | 7 ++ go.mod | 2 + go.sum | 5 ++ models/admin.go | 4 +- models/admin_test.go | 41 ++++++++++ models/auth.go | 25 ++++++- models/auth_test.go | 137 ++++++++++++++++++++++++++++++++++ models/base_test.go | 54 ++++++++++++++ models/tenanted_test.go | 11 +++ models/user_test.go | 95 +++++++++++++++++++++++ util/util.go | 17 +++++ 12 files changed, 400 insertions(+), 27 deletions(-) create mode 100644 controllers/core/core_test.go create mode 100644 models/admin_test.go create mode 100644 models/auth_test.go create mode 100644 models/base_test.go create mode 100644 models/tenanted_test.go create mode 100644 models/user_test.go diff --git a/controllers/core/core.go b/controllers/core/core.go index 68dff2c..369a0ce 100644 --- a/controllers/core/core.go +++ b/controllers/core/core.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt" "github.com/google/uuid" "github.com/yxzzy-wtf/gin-gonic-prepack/database" "github.com/yxzzy-wtf/gin-gonic-prepack/models" @@ -103,7 +102,7 @@ func UserVerify() gin.HandlerFunc { return func(c *gin.Context) { verifyJwt, _ := c.GetQuery("verify") - claims, err := parseJwt(verifyJwt, models.UserHmac) + claims, err := util.ParseJwt(verifyJwt, models.UserHmac) if err != nil || claims["role"] != "verify" { fmt.Println("bad claim or role not 'verify'", err) c.AbortWithStatus(http.StatusUnauthorized) @@ -175,7 +174,7 @@ func UserResetForgottenPassword() gin.HandlerFunc { return } - claims, err := parseJwt(resetVals.Token, models.UserHmac) + claims, err := util.ParseJwt(resetVals.Token, models.UserHmac) if err != nil || claims["role"] != "reset" { fmt.Println("bad claim or role not 'reset'", err) c.AbortWithStatus(http.StatusUnauthorized) @@ -245,7 +244,7 @@ func AdminLogin() gin.HandlerFunc { } } -func genericAuth(expectedRole string) gin.HandlerFunc { +func genericAuth(expectedRole string, hmac []byte) gin.HandlerFunc { return func(c *gin.Context) { tokenStr := c.GetHeader(JwtHeader) if tokenStr == "" { @@ -253,7 +252,7 @@ func genericAuth(expectedRole string) gin.HandlerFunc { return } - claims, err := parseJwt(tokenStr, models.UserHmac) + claims, err := util.ParseJwt(tokenStr, hmac) if err != nil { if strings.HasPrefix(err.Error(), "token ") || err.Error() == "signature is invalid" { c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: err.Error()}) @@ -279,27 +278,11 @@ func genericAuth(expectedRole string) gin.HandlerFunc { } func UserAuth() gin.HandlerFunc { - return genericAuth("user") + return genericAuth("user", models.UserHmac) } func AdminAuth() gin.HandlerFunc { - return genericAuth("admin") -} - -func parseJwt(tokenStr string, hmac []byte) (jwt.MapClaims, error) { - token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("bad signing method %v", token.Header["alg"]) - } - - return hmac, nil - }) - - if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { - return claims, nil - } else { - return jwt.MapClaims{}, err - } + return genericAuth("admin", models.AdminHmac) } func Doot() gin.HandlerFunc { diff --git a/controllers/core/core_test.go b/controllers/core/core_test.go new file mode 100644 index 0000000..afee6ad --- /dev/null +++ b/controllers/core/core_test.go @@ -0,0 +1,7 @@ +package core + +import "testing" + +func TestUserSignup(t *testing.T) { + +} diff --git a/go.mod b/go.mod index a62e9b1..8cf54ab 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.18 require github.com/gin-gonic/gin v1.7.7 require ( + github.com/boombuler/barcode v1.0.1 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect @@ -20,6 +21,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.12 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pquerna/otp v1.3.0 // indirect github.com/ugorji/go/codec v1.2.7 // indirect golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f // indirect golang.org/x/sys v0.0.0-20220429121018-84afa8d3f7b3 // indirect diff --git a/go.sum b/go.sum index 310ba5a..8eb939e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,6 @@ +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -55,6 +58,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs= +github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/models/admin.go b/models/admin.go index 85856c0..5561068 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() func (a *Admin) GetJwt() (string, int) { j := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ @@ -26,7 +26,7 @@ func (a *Admin) GetJwt() (string, int) { "role": "admin", }) - jstr, err := j.SignedString(adminHmac) + jstr, err := j.SignedString(AdminHmac) if err != nil { // we should ALWAYS be able to build and sign a str panic(err) diff --git a/models/admin_test.go b/models/admin_test.go new file mode 100644 index 0000000..f28910c --- /dev/null +++ b/models/admin_test.go @@ -0,0 +1,41 @@ +package models + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/yxzzy-wtf/gin-gonic-prepack/util" +) + +func TestAdminGetJwt(t *testing.T) { + a := Admin{} + a.Uid = uuid.New() + + jwtToken, maxAge := a.GetJwt() + if maxAge != int(time.Hour.Seconds()*2) { + t.Errorf("issued token with incorrect max age, expected %vs but was %vs", time.Hour.Seconds()*2, maxAge) + } + + testClaims, err := util.ParseJwt(jwtToken, AdminHmac) + if err != nil { + t.Errorf("tried to parse valid token but got error %v", err) + } + + if testClaims["sub"] != a.Uid.String() { + t.Errorf("`sub` value of %v does not match expected of %v", testClaims["sub"], a.Uid) + } + + if testClaims["role"] != "admin" { + t.Errorf("`role` value of %v does not match expected of `admin`", testClaims["role"]) + } + + if _, exists := testClaims["iat"]; !exists { + t.Errorf("`iat` does not exist in jwt") + } + + if _, exists := testClaims["exp"]; !exists { + t.Errorf("`exp` does not exist in jwt") + } + +} diff --git a/models/auth.go b/models/auth.go index 67afd62..611ec85 100644 --- a/models/auth.go +++ b/models/auth.go @@ -2,8 +2,10 @@ package models import ( "errors" + "strings" "time" + "github.com/pquerna/otp/totp" "golang.org/x/crypto/bcrypt" ) @@ -16,17 +18,29 @@ type Auth struct { } func (a *Auth) SetPassword(pass string) error { + if len(pass) < 12 { + return errors.New("password too short") + } + + if strings.Contains(strings.ToLower(pass), "password") { + return errors.New("contains phrase 'password'") + } + passHash, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) a.PasswordHash = string(passHash) return nil } func (a *Auth) Login(pass string, tfCode string) (error, bool) { + return a.login(pass, tfCode, time.Now()) +} + +func (a *Auth) login(pass string, tfCode string, stamp time.Time) (error, bool) { if err := a.CheckPassword(pass); err != nil { return err, false } - if err := a.ValidateTwoFactor(tfCode, time.Now()); err != nil { + if err := a.ValidateTwoFactor(tfCode, stamp); err != nil { return err, true } @@ -51,7 +65,14 @@ func (a *Auth) ValidateTwoFactor(tfCode string, stamp time.Time) error { //TODO two factor if len(tfCode) == 6 { // Test 2FA - return errors.New("2FA invalid") + expect, err := totp.GenerateCode(a.TwoFactorSecret, stamp) + if err != nil { + return errors.New("could not process 2fa") + } + if expect == tfCode { + return nil + } + return errors.New("2fa invalid") } else { // May be a renewal code return errors.New("unlock invalid") diff --git a/models/auth_test.go b/models/auth_test.go new file mode 100644 index 0000000..6e2494a --- /dev/null +++ b/models/auth_test.go @@ -0,0 +1,137 @@ +package models + +import ( + "testing" + "time" +) + +func TestBadPasswords(t *testing.T) { + a := Auth{} + + if err := a.SetPassword("short"); err.Error() != "password too short" { + t.Errorf("allowed short password") + } + + if err := a.SetPassword("tqr9wyfPassword9k8rwcd"); err.Error() != "contains phrase 'password'" { + t.Errorf("allowed password containing the word 'password'") + } + + if err := a.SetPassword("qc2q2fn34dqifqu23j7dp0"); err != nil { + t.Errorf("rejected acceptable password") + } +} + +func TestSettingPassword(t *testing.T) { + a := Auth{} + + if a.PasswordHash != "" { + t.Errorf("passwordhash comes with default value") + } + + a.SetPassword("This-q2o37rcfy2ij34tgjwi374f3w") + ph := a.PasswordHash + if ph == "" { + t.Errorf("passwordhash was not set") + } + + a.SetPassword("Different-q2o37rcfy2ij34tgjwi374f3w") + if ph == a.PasswordHash { + t.Errorf("password hashes are the same across different passwords") + } + +} + +func TestPasswordFlow(t *testing.T) { + a := Auth{} + + a.SetPassword("Base-w894t7yw9xj8fxh834dr32") + if err := a.CheckPassword("Incorrect-w894t7yw9xj8fxh834dr32"); err == nil { + t.Errorf("did not fail when provided the wrong password") + } + + if err := a.CheckPassword("Base-w894t7yw9xj8fxh834dr32"); err != nil { + t.Errorf("failed when provided the right password") + } + + a.SetPassword("Secondary-w894t7yw9xj8fxh834dr32") + if err := a.CheckPassword("Base-w894t7yw9xj8fxh834dr32"); err == nil { + t.Errorf("did not fail when provided the original password") + } + + if err := a.CheckPassword("Secondary-w894t7yw9xj8fxh834dr32"); err != nil { + t.Errorf("failed when provided the correct updated password") + } +} + +func TestTwoFactorWhenNotSet(t *testing.T) { + a := Auth{} + if err := a.ValidateTwoFactor("ZZZZZZ", time.Now()); err == nil { + t.Errorf("no 2fa set up but code provided, should get err") + } + + if err := a.ValidateTwoFactor("", time.Now()); err != nil { + t.Errorf("no code give but no 2fa set up, should not have errored") + } +} + +func TestTwoFactor(t *testing.T) { + a := Auth{} + a.TwoFactorSecret = "AAAAAAAAAAAAAAAA" + + testTime := time.Date(2022, 1, 5, 18, 0, 0, 0, time.UTC) + expected := "566833" + + if err := a.ValidateTwoFactor("000000", testTime); err == nil { + t.Errorf("accepted invalid token") + } + + if err := a.ValidateTwoFactor(expected, testTime); err != nil { + t.Errorf("rejected expected token at T0 of period") + } + + if err := a.ValidateTwoFactor(expected, testTime.Add(29*time.Second)); err != nil { + t.Errorf("rejected expected token at T29 of period") + } + + if err := a.ValidateTwoFactor(expected, testTime.Add(35*time.Second)); err == nil { + t.Errorf("accepted valid token at T35 of period (token is from last period)") + } +} + +func TestCombinedLogin(t *testing.T) { + a := Auth{} + a.SetPassword("q2ricy2rqi3c4r23rcou") + a.TwoFactorSecret = "AAAAAAAAAAAAAAAA" + testTime := time.Date(2022, 1, 5, 18, 0, 0, 0, time.UTC) + expected := "566833" + + err, show := a.login("q2ricy2rqi3c4r23rcou", expected, testTime) + if err == nil || err.Error() != "not yet verified" { + t.Errorf("validated login of unverified user") + } + if !show { + t.Errorf("unverified is an acceptable message to show, did not indicate true") + } + + err, show = a.login("q2ricy2rqi3c4r23rcou", "000000", testTime) + if err == nil || err.Error() != "2fa invalid" { + t.Errorf("validated incorrect 2fa code") + } + if !show { + t.Errorf("bad 2fa is an acceptable message to show, did not indicate true") + } + + err, show = a.login("bad", "000000", testTime) + if err == nil { + t.Errorf("validated bad password") + } + if show { + t.Errorf("bad passwrd not an acceptable message to show, but indicated true") + } + + a.Verified = true + err, _ = a.login("q2ricy2rqi3c4r23rcou", expected, testTime) + if err != nil { + t.Errorf("failed good login") + } +} diff --git a/models/base_test.go b/models/base_test.go new file mode 100644 index 0000000..697a420 --- /dev/null +++ b/models/base_test.go @@ -0,0 +1,54 @@ +package models + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +func TestOnCreateNewUUID(t *testing.T) { + now := time.Now() + b := Base{} + b.BeforeCreate(nil) + + if b.Uid == uuid.Nil { + t.Errorf("did not generate uuid BeforeCreate") + } + + if b.Created.IsZero() { + t.Errorf("did not set created time") + } + + if !b.Created.After(now) { + t.Errorf("created date should be after %v, was %v", now, b.Created) + } + + if !b.Updated.IsZero() { + t.Errorf("updated date already set to %v", b.Updated) + } + + if !b.Deleted.IsZero() { + t.Errorf("deleted date already set to %v", b.Updated) + } +} + +func TestOnSaveUpdateDate(t *testing.T) { + now := time.Now() + b := Base{} + b.BeforeSave(nil) + + if !b.Updated.After(now) { + t.Errorf("updated date should be updated to after %v, is %v", now, b.Updated) + } +} + +func TestDeleteSetsTime(t *testing.T) { + now := time.Now() + b := Base{} + b.Delete() + + if !b.Deleted.After(now) { + t.Errorf("updated date should be updated to after %v, is %v", now, b.Updated) + } +} diff --git a/models/tenanted_test.go b/models/tenanted_test.go new file mode 100644 index 0000000..4d70161 --- /dev/null +++ b/models/tenanted_test.go @@ -0,0 +1,11 @@ +package models + +import "testing" + +func TestCannotCreateUntenanted(t *testing.T) { + tnt := Tenanted{} + + if err := tnt.BeforeCreate(nil); err == nil { + t.Errorf("allowed creation of Tenanted model without Tenant value") + } +} diff --git a/models/user_test.go b/models/user_test.go new file mode 100644 index 0000000..c818fe3 --- /dev/null +++ b/models/user_test.go @@ -0,0 +1,95 @@ +package models + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/yxzzy-wtf/gin-gonic-prepack/util" +) + +func TestUserGetJwt(t *testing.T) { + u := User{} + u.Uid = uuid.New() + + jwtToken, maxAge := u.GetJwt() + if maxAge != int(time.Hour.Seconds()*24) { + t.Errorf("issued token with incorrect max age, expected %vs but was %vs", time.Hour.Seconds()*24, maxAge) + } + + testClaims, err := util.ParseJwt(jwtToken, UserHmac) + if err != nil { + t.Errorf("tried to parse valid token but got error %v", err) + } + + if testClaims["sub"] != u.Uid.String() { + t.Errorf("`sub` value of %v does not match expected of %v", testClaims["sub"], u.Uid) + } + + if testClaims["role"] != "user" { + t.Errorf("`role` value of %v does not match expected of `user`", testClaims["role"]) + } + + if _, exists := testClaims["iat"]; !exists { + t.Errorf("`iat` does not exist in jwt") + } + + if _, exists := testClaims["exp"]; !exists { + t.Errorf("`exp` does not exist in jwt") + } +} +func TestUserGetVerifyJwt(t *testing.T) { + u := User{} + u.Uid = uuid.New() + + jwtToken := u.GetVerificationJwt() + + testClaims, err := util.ParseJwt(jwtToken, UserHmac) + if err != nil { + t.Errorf("tried to parse valid token but got error %v", err) + } + + if testClaims["sub"] != u.Uid.String() { + t.Errorf("`sub` value of %v does not match expected of %v", testClaims["sub"], u.Uid) + } + + if testClaims["role"] != "verify" { + t.Errorf("`role` value of %v does not match expected of `verify`", testClaims["role"]) + } + + if _, exists := testClaims["iat"]; !exists { + t.Errorf("`iat` does not exist in jwt") + } + + if _, exists := testClaims["exp"]; !exists { + t.Errorf("`exp` does not exist in jwt") + } +} + +func TestUserGetResetJwt(t *testing.T) { + u := User{} + u.Uid = uuid.New() + + jwtToken := u.GetResetPasswordJwt() + + testClaims, err := util.ParseJwt(jwtToken, UserHmac) + if err != nil { + t.Errorf("tried to parse valid token but got error %v", err) + } + + if testClaims["sub"] != u.Uid.String() { + t.Errorf("`sub` value of %v does not match expected of %v", testClaims["sub"], u.Uid) + } + + if testClaims["role"] != "reset" { + t.Errorf("`role` value of %v does not match expected of `reset`", testClaims["role"]) + } + + if _, exists := testClaims["iat"]; !exists { + t.Errorf("`iat` does not exist in jwt") + } + + if _, exists := testClaims["exp"]; !exists { + t.Errorf("`exp` does not exist in jwt") + } +} diff --git a/util/util.go b/util/util.go index 851cd48..6d5bc47 100644 --- a/util/util.go +++ b/util/util.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "fmt" + "github.com/golang-jwt/jwt" "github.com/google/uuid" ) @@ -33,3 +34,19 @@ func SendEmail(title string, body string, recipient string) { //TODO fmt.Println("Send", title, body, "to", recipient) } + +func ParseJwt(tokenStr string, hmac []byte) (jwt.MapClaims, error) { + token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("bad signing method %v", token.Header["alg"]) + } + + return hmac, nil + }) + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + return claims, nil + } else { + return jwt.MapClaims{}, err + } +}