Login, JWT and auth overstructure

* Signup -> Login -> JWT-Doot flow now works for users
* Administrators cannot currently sign up for obvious reasons
* Segmented the main.go methods into a core controller package
This commit is contained in:
🐙PiperYxzzy
2022-05-01 12:31:41 +02:00
parent 6db02148ea
commit 8ab45e2401
6 changed files with 231 additions and 162 deletions

193
controllers/core/core.go Normal file
View File

@@ -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"})
}
}
}

158
main.go
View File

@@ -1,15 +1,14 @@
package main package main
import ( import (
"fmt"
"log" "log"
"net/http" "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/database"
"github.com/yxzzy-wtf/gin-gonic-prepack/models" "github.com/yxzzy-wtf/gin-gonic-prepack/models"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
_ "github.com/golang-jwt/jwt"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -26,162 +25,23 @@ func main() {
v1 := r.Group("/v1") v1 := r.Group("/v1")
// Ping functionality // Ping functionality
v1.GET("/doot", doot()) v1.GET("/doot", core.Doot())
// Standard user login // Standard user login
v1.POST("/signup", userSignup()) v1.POST("/signup", core.UserSignup())
v1.POST("/login", userLogin()) v1.POST("/login", core.UserLogin())
v1Sec := v1.Group("/sec", userAuth()) v1Sec := v1.Group("/sec", core.UserAuth())
v1Sec.GET("/doot", doot()) v1Sec.GET("/doot", core.Doot())
// Administrative login // Administrative login
v1.POST("/admin", adminLogin()) v1.POST("/admin", core.AdminLogin())
v1Admin := v1.Group("/adm", adminAuth()) v1Admin := v1.Group("/adm", core.AdminAuth())
v1Admin.GET("/doot", doot()) v1Admin.GET("/doot", core.Doot())
// Start server // Start server
if err := http.ListenAndServe(":9091", r); err != nil { if err := http.ListenAndServe(":9091", r); err != nil {
log.Fatal(err) 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)
}
}

View File

@@ -11,7 +11,7 @@ import (
type Admin struct { type Admin struct {
Auth Auth
Email string Email string `gorm:"unique" sql:"index"`
} }
const adminJwtDuration = time.Hour * 2 const adminJwtDuration = time.Hour * 2
@@ -19,11 +19,10 @@ const adminJwtDuration = time.Hour * 2
var adminHmac = util.GenerateHmac() var adminHmac = util.GenerateHmac()
func (a *Admin) GetJwt() (string, int) { func (a *Admin) GetJwt() (string, int) {
exp := time.Now().Add(adminJwtDuration)
j := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ j := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": a.Uid.String(), "sub": a.Uid.String(),
"iat": time.Now(), "iat": time.Now().Unix(),
"exp": exp, "exp": time.Now().Add(adminJwtDuration).Unix(),
"role": "admin", "role": "admin",
}) })

View File

@@ -1,6 +1,7 @@
package models package models
import ( import (
"errors"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -12,7 +13,7 @@ type Base struct {
Created time.Time Created time.Time
Updated time.Time Updated time.Time
Deleted time.Time `sql:"index"` Deleted time.Time `sql:"index"`
Tenant uuid.UUID Tenant uuid.UUID `sql:"index"`
} }
func (b *Base) BeforeCreate(scope *gorm.DB) error { func (b *Base) BeforeCreate(scope *gorm.DB) error {
@@ -21,7 +22,11 @@ func (b *Base) BeforeCreate(scope *gorm.DB) error {
return nil 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() b.Updated = time.Now()
return nil return nil
} }

View File

@@ -11,24 +11,23 @@ import (
type User struct { type User struct {
Auth Auth
Email string `gorm:"unique"` Email string `gorm:"unique" sql:"index"`
} }
const userJwtDuration = time.Hour * 24 const userJwtDuration = time.Hour * 24
var userHmac = util.GenerateHmac() var UserHmac = util.GenerateHmac()
func (u *User) GetJwt() (string, int) { func (u *User) GetJwt() (string, int) {
exp := time.Now().Add(userJwtDuration)
j := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ j := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": u.Uid.String(), "sub": u.Uid.String(),
"iat": time.Now(), "iat": time.Now().Unix(),
"exp": exp, "exp": time.Now().Add(userJwtDuration).Unix(),
"role": "user", "role": "user",
"tid": u.Tenant.String(), "tid": u.Tenant.String(),
}) })
jstr, err := j.SignedString(userHmac) jstr, err := j.SignedString(UserHmac)
if err != nil { if err != nil {
// we should ALWAYS be able to build and sign a str // we should ALWAYS be able to build and sign a str
panic(err) panic(err)

View File

@@ -1,6 +1,10 @@
package util package util
import "crypto/rand" import (
"crypto/rand"
"github.com/google/uuid"
)
func GenerateHmac() []byte { func GenerateHmac() []byte {
b := make([]byte, 64) b := make([]byte, 64)
@@ -10,3 +14,12 @@ func GenerateHmac() []byte {
return b return b
} }
type PrincipalInfo struct {
Uid uuid.UUID
Role string
}
type FailMsg struct {
Reason string `json:"reason"`
}