diff --git a/controllers/core/core.go b/controllers/core/core.go new file mode 100644 index 0000000..9248ccb --- /dev/null +++ b/controllers/core/core.go @@ -0,0 +1,193 @@ +package core + +import ( + "fmt" + "net/http" + "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" + "github.com/yxzzy-wtf/gin-gonic-prepack/util" +) + +type login struct { + UserKey string `json:"userkey" binding:"required"` + Password string `json:"password" binding:"required"` + TwoFactor string `json:"twofactorcode"` +} + +type signup struct { + UserKey string `json:"userkey" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type failmsg struct { + Reason string `json:"reason"` +} + +const JwtHeader = "jwt" +const ServicePath = "TODOPATH" +const ServiceDomain = "TODODOMAIN" + +func UserSignup() gin.HandlerFunc { + return func(c *gin.Context) { + var signupVals signup + if err := c.ShouldBind(&signupVals); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, failmsg{"Requires username and password"}) + return + } + + u := models.User{ + Email: signupVals.UserKey, + } + + if err := u.SetPassword(signupVals.Password); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, failmsg{"Bad password"}) + return + } + + if err := database.Db.Model(&u).Create(&u).Error; err != nil { + if err.Error() == "UNIQUE constraint failed: users.email" { + c.AbortWithStatusJSON(http.StatusInternalServerError, failmsg{"already exists"}) + } else { + fmt.Println(fmt.Errorf("error: %w", err)) + c.AbortWithStatus(http.StatusInternalServerError) + } + return + } + + c.JSON(http.StatusOK, map[string]string{"id": u.Uid.String()}) + } +} + +func UserLogin() gin.HandlerFunc { + return func(c *gin.Context) { + var loginVals login + if err := c.ShouldBind(&loginVals); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, failmsg{"Requires username and password"}) + } + + u := models.User{} + if err := u.ByEmail(loginVals.UserKey); err != nil { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + if err, returnErr := u.Login(loginVals.Password, loginVals.TwoFactor); err != nil { + if returnErr { + c.AbortWithStatusJSON(http.StatusUnauthorized, failmsg{err.Error()}) + } else { + c.AbortWithStatus(http.StatusUnauthorized) + } + return + } + + jwt, maxAge := u.GetJwt() + c.SetCookie(JwtHeader, jwt, maxAge, ServicePath, ServiceDomain, true, true) + } +} + +func AdminLogin() gin.HandlerFunc { + return func(c *gin.Context) { + var loginVals login + if err := c.ShouldBind(&loginVals); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, failmsg{"requires username and password"}) + } + + if loginVals.TwoFactor == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, failmsg{"admin access requires 2FA"}) + return + } + + a := models.Admin{} + if err := a.ByEmail(loginVals.UserKey); err != nil { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + if err, returnErr := a.Login(loginVals.Password, loginVals.TwoFactor); err != nil { + if returnErr { + c.AbortWithStatusJSON(http.StatusUnauthorized, failmsg{err.Error()}) + } else { + c.AbortWithStatus(http.StatusUnauthorized) + } + return + } + + jwt, maxAge := a.GetJwt() + c.SetCookie(JwtHeader, jwt, maxAge, ServicePath, ServiceDomain, true, true) + } +} + +func genericAuth(expectedRole string) gin.HandlerFunc { + return func(c *gin.Context) { + tokenStr := c.GetHeader(JwtHeader) + if tokenStr == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, failmsg{"requires `" + JwtHeader + "` header"}) + return + } + + claims, err := parseJwt(tokenStr, models.UserHmac) + if err != nil { + if strings.HasPrefix(err.Error(), "token ") || err.Error() == "signature is invalid" { + c.AbortWithStatusJSON(http.StatusUnauthorized, failmsg{err.Error()}) + } else { + fmt.Println(err) + c.AbortWithStatusJSON(http.StatusInternalServerError, failmsg{"something went wrong"}) + } + return + } + if claims["role"] != expectedRole { + c.AbortWithStatusJSON(http.StatusUnauthorized, failmsg{"wrong access role"}) + return + } + + uid, err := uuid.Parse(claims["sub"].(string)) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, failmsg{"cannot extract sub"}) + return + } + + c.Set("principal", util.PrincipalInfo{Uid: uid, Role: expectedRole}) + } +} + +func UserAuth() gin.HandlerFunc { + return genericAuth("user") +} + +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 + } +} + +func Doot() gin.HandlerFunc { + return func(c *gin.Context) { + piCtx, exists := c.Get("principal") + if exists { + pi := piCtx.(util.PrincipalInfo) + dooter := pi.Role + ":" + pi.Uid.String() + c.JSON(http.StatusOK, map[string]string{"snoot": "dooted by " + dooter}) + } else { + c.JSON(http.StatusOK, map[string]string{"snoot": "dooted"}) + } + } +} diff --git a/main.go b/main.go index 337393d..a3e9ebb 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,14 @@ package main import ( - "fmt" "log" "net/http" + "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" "github.com/gin-gonic/gin" - _ "github.com/golang-jwt/jwt" "gorm.io/gorm" ) @@ -26,162 +25,23 @@ func main() { v1 := r.Group("/v1") // Ping functionality - v1.GET("/doot", doot()) + v1.GET("/doot", core.Doot()) // Standard user login - v1.POST("/signup", userSignup()) - v1.POST("/login", userLogin()) - v1Sec := v1.Group("/sec", userAuth()) + v1.POST("/signup", core.UserSignup()) + v1.POST("/login", core.UserLogin()) + v1Sec := v1.Group("/sec", core.UserAuth()) - v1Sec.GET("/doot", doot()) + v1Sec.GET("/doot", core.Doot()) // Administrative login - v1.POST("/admin", adminLogin()) - v1Admin := v1.Group("/adm", adminAuth()) + v1.POST("/admin", core.AdminLogin()) + v1Admin := v1.Group("/adm", core.AdminAuth()) - v1Admin.GET("/doot", doot()) + v1Admin.GET("/doot", core.Doot()) // Start server if err := http.ListenAndServe(":9091", r); err != nil { log.Fatal(err) } } - -func doot() gin.HandlerFunc { - return func(c *gin.Context) { - c.JSON(http.StatusOK, map[string]string{"snoot": "dooted"}) - } -} - -type login struct { - UserKey string `json:"userkey" binding:"required"` - Password string `json:"password" binding:"required"` - TwoFactor string `json:"twofactorcode"` -} - -type signup struct { - UserKey string `json:"userkey" binding:"required"` - Password string `json:"password" binding:"required"` -} - -type failmsg struct { - Reason string `json:"reason"` -} - -const JwtHeader = "jwt" -const ServicePath = "TODOPATH" -const ServiceDomain = "TODODOMAIN" - -func userLogin() gin.HandlerFunc { - return func(c *gin.Context) { - var loginVals login - if err := c.ShouldBind(&loginVals); err != nil { - c.AbortWithStatusJSON(http.StatusBadRequest, failmsg{"Requires username and password"}) - } - - u := models.User{} - if err := u.ByEmail(loginVals.UserKey); err != nil { - c.AbortWithStatus(http.StatusUnauthorized) - return - } - - if err, returnErr := u.Login(loginVals.Password, loginVals.TwoFactor); err != nil { - if returnErr { - c.AbortWithStatusJSON(http.StatusUnauthorized, failmsg{err.Error()}) - } else { - c.AbortWithStatus(http.StatusUnauthorized) - } - return - } - - jwt, maxAge := u.GetJwt() - c.SetCookie(JwtHeader, jwt, maxAge, ServicePath, ServiceDomain, true, true) - } -} - -func userSignup() gin.HandlerFunc { - return func(c *gin.Context) { - var signupVals signup - if err := c.ShouldBind(&signupVals); err != nil { - c.AbortWithStatusJSON(http.StatusBadRequest, failmsg{"Requires username and password"}) - return - } - - u := models.User{ - Email: signupVals.UserKey, - } - - if err := u.SetPassword(signupVals.Password); err != nil { - c.AbortWithStatusJSON(http.StatusBadRequest, failmsg{"Bad password"}) - return - } - - if err := database.Db.Model(&u).Create(&u).Error; err != nil { - if err.Error() == "UNIQUE constraint failed: users.email" { - c.AbortWithStatusJSON(http.StatusInternalServerError, failmsg{"already exists"}) - } else { - fmt.Println(fmt.Errorf("error: %w", err)) - c.AbortWithStatus(http.StatusInternalServerError) - } - return - } - - c.JSON(http.StatusOK, map[string]string{"id": u.Uid.String()}) - } -} - -func adminLogin() gin.HandlerFunc { - return func(c *gin.Context) { - var loginVals login - if err := c.ShouldBind(&loginVals); err != nil { - c.AbortWithStatusJSON(http.StatusBadRequest, failmsg{"requires username and password"}) - } - - if loginVals.TwoFactor == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, failmsg{"admin access requires 2FA"}) - return - } - - a := models.Admin{} - if err := a.ByEmail(loginVals.UserKey); err != nil { - c.AbortWithStatus(http.StatusUnauthorized) - return - } - - if err, returnErr := a.Login(loginVals.Password, loginVals.TwoFactor); err != nil { - if returnErr { - c.AbortWithStatusJSON(http.StatusUnauthorized, failmsg{err.Error()}) - } else { - c.AbortWithStatus(http.StatusUnauthorized) - } - return - } - - jwt, maxAge := a.GetJwt() - c.SetCookie(JwtHeader, jwt, maxAge, ServicePath, ServiceDomain, true, true) - } -} - -func userAuth() gin.HandlerFunc { - return func(c *gin.Context) { - jwt := c.GetHeader(JwtHeader) - if jwt == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, failmsg{"requires `" + JwtHeader + "` header"}) - return - } - - c.AbortWithStatus(http.StatusUnauthorized) - } -} - -func adminAuth() gin.HandlerFunc { - return func(c *gin.Context) { - jwt := c.GetHeader(JwtHeader) - if jwt == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, failmsg{"requires `" + JwtHeader + "` header"}) - return - } - - c.AbortWithStatus(http.StatusUnauthorized) - } -} diff --git a/models/admin.go b/models/admin.go index 2b9c9f5..85856c0 100644 --- a/models/admin.go +++ b/models/admin.go @@ -11,7 +11,7 @@ import ( type Admin struct { Auth - Email string + Email string `gorm:"unique" sql:"index"` } const adminJwtDuration = time.Hour * 2 @@ -19,11 +19,10 @@ const adminJwtDuration = time.Hour * 2 var adminHmac = util.GenerateHmac() func (a *Admin) GetJwt() (string, int) { - exp := time.Now().Add(adminJwtDuration) j := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": a.Uid.String(), - "iat": time.Now(), - "exp": exp, + "iat": time.Now().Unix(), + "exp": time.Now().Add(adminJwtDuration).Unix(), "role": "admin", }) diff --git a/models/base.go b/models/base.go index 414aaf0..db1bb92 100644 --- a/models/base.go +++ b/models/base.go @@ -1,6 +1,7 @@ package models import ( + "errors" "time" "github.com/google/uuid" @@ -12,7 +13,7 @@ type Base struct { Created time.Time Updated time.Time Deleted time.Time `sql:"index"` - Tenant uuid.UUID + Tenant uuid.UUID `sql:"index"` } func (b *Base) BeforeCreate(scope *gorm.DB) error { @@ -21,7 +22,11 @@ func (b *Base) BeforeCreate(scope *gorm.DB) error { return nil } -func (b *Base) BeforeSave(tx *gorm.DB) error { +func (b *Base) BeforeSave(scope *gorm.DB) error { + if b.Tenant == uuid.Nil { + return errors.New("cannot save an untenanted object") + } + b.Updated = time.Now() return nil } diff --git a/models/user.go b/models/user.go index 43a8bf6..9bb6081 100644 --- a/models/user.go +++ b/models/user.go @@ -11,24 +11,23 @@ import ( type User struct { Auth - Email string `gorm:"unique"` + Email string `gorm:"unique" sql:"index"` } const userJwtDuration = time.Hour * 24 -var userHmac = util.GenerateHmac() +var UserHmac = util.GenerateHmac() func (u *User) GetJwt() (string, int) { - exp := time.Now().Add(userJwtDuration) j := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": u.Uid.String(), - "iat": time.Now(), - "exp": exp, + "iat": time.Now().Unix(), + "exp": time.Now().Add(userJwtDuration).Unix(), "role": "user", "tid": u.Tenant.String(), }) - jstr, err := j.SignedString(userHmac) + jstr, err := j.SignedString(UserHmac) if err != nil { // we should ALWAYS be able to build and sign a str panic(err) diff --git a/util/util.go b/util/util.go index a4b2c7c..ae7cc14 100644 --- a/util/util.go +++ b/util/util.go @@ -1,6 +1,10 @@ package util -import "crypto/rand" +import ( + "crypto/rand" + + "github.com/google/uuid" +) func GenerateHmac() []byte { b := make([]byte, 64) @@ -10,3 +14,12 @@ func GenerateHmac() []byte { return b } + +type PrincipalInfo struct { + Uid uuid.UUID + Role string +} + +type FailMsg struct { + Reason string `json:"reason"` +}