diff --git a/.gitignore b/.gitignore index 66fd13c..2eade68 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ # Dependency directories (remove the comment below to include it) # vendor/ + +# Other +prepack.db +test_prepack.db \ No newline at end of file diff --git a/Prepack.postman_collection.json b/Prepack.postman_collection.json new file mode 100644 index 0000000..e0925e0 --- /dev/null +++ b/Prepack.postman_collection.json @@ -0,0 +1,136 @@ +{ + "info": { + "_postman_id": "6485c58d-0675-4f5d-9eed-4c2ecd8174ae", + "name": "Prepack", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "V1 Doot", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "localhost:9091/v1/doot", + "host": [ + "localhost" + ], + "port": "9091", + "path": [ + "v1", + "doot" + ] + } + }, + "response": [] + }, + { + "name": "V1 Secured Doot", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "localhost:9091/v1/sec/doot", + "host": [ + "localhost" + ], + "port": "9091", + "path": [ + "v1", + "sec", + "doot" + ] + } + }, + "response": [] + }, + { + "name": "V1 Admin Doot", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "localhost:9091/v1/adm/doot", + "host": [ + "localhost" + ], + "port": "9091", + "path": [ + "v1", + "adm", + "doot" + ] + } + }, + "response": [] + }, + { + "name": "V1 Signup", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"userkey\": \"NewUser\",\n \"password\": \"NewPass\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:9091/v1/signup", + "host": [ + "localhost" + ], + "port": "9091", + "path": [ + "v1", + "signup" + ], + "query": [ + { + "key": "userkey", + "value": "NewUser", + "disabled": true + }, + { + "key": "password", + "value": "NewPass", + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "V1 User Login", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"userkey\": \"NewUser\",\n \"password\": \"NewPass\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:9091/v1/login", + "host": [ + "localhost" + ], + "port": "9091", + "path": [ + "v1", + "login" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/database/database.go b/database/database.go new file mode 100644 index 0000000..1bc26aa --- /dev/null +++ b/database/database.go @@ -0,0 +1,58 @@ +package database + +import ( + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type Database struct { + *gorm.DB +} + +var Db *gorm.DB + +func Init() *gorm.DB { + db, err := gorm.Open(sqlite.Open("prepack.db"), &gorm.Config{}) + if err != nil { + panic(err) + } + + //TODO GORM settings + database, err := db.DB() + if err != nil { + panic(err) + } + + database.SetMaxIdleConns(10) + database.SetMaxOpenConns(50) + database.SetConnMaxLifetime(time.Minute * 30) + + Db = db + return db +} + +func InitTestDb() *gorm.DB { + db, err := gorm.Open(sqlite.Open("test_prepack.db"), &gorm.Config{}) + if err != nil { + panic(err) + } + + //TODO GORM settings + database, err := db.DB() + if err != nil { + panic(err) + } + + database.SetMaxIdleConns(10) + database.SetMaxOpenConns(50) + database.SetConnMaxLifetime(time.Minute * 30) + + Db = db + return db +} + +func GetDb() *gorm.DB { + return Db +} diff --git a/go.mod b/go.mod index 21b2306..a62e9b1 100644 --- a/go.mod +++ b/go.mod @@ -2,16 +2,22 @@ module github.com/yxzzy-wtf/gin-gonic-prepack go 1.18 +require github.com/gin-gonic/gin v1.7.7 + require ( github.com/gin-contrib/sse v0.1.0 // indirect - github.com/gin-gonic/gin v1.7.7 // indirect github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect github.com/go-playground/validator/v10 v10.10.1 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang/protobuf v1.5.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/leodido/go-urn v1.2.1 // indirect github.com/mattn/go-isatty v0.0.14 // indirect + 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/ugorji/go/codec v1.2.7 // indirect @@ -20,4 +26,6 @@ require ( golang.org/x/text v0.3.7 // indirect google.golang.org/protobuf v1.28.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gorm.io/driver/sqlite v1.3.2 // indirect + gorm.io/gorm v1.23.5 // indirect ) diff --git a/go.sum b/go.sum index 2fcc2b4..310ba5a 100644 --- a/go.sum +++ b/go.sum @@ -15,12 +15,21 @@ github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-playground/validator/v10 v10.10.1 h1:uA0+amWMiglNZKZ9FJRKUAe9U3RX91eVn1JYXMWt7ig= github.com/go-playground/validator/v10 v10.10.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= @@ -36,6 +45,8 @@ github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ic github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0= +github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -96,3 +107,8 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.3.2 h1:nWTy4cE52K6nnMhv23wLmur9Y3qWbZvOBz+V4PrGAxg= +gorm.io/driver/sqlite v1.3.2/go.mod h1:B+8GyC9K7VgzJAcrcXMRPdnMcck+8FgJynEehEPM16U= +gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= +gorm.io/gorm v1.23.5 h1:TnlF26wScKSvknUC/Rn8t0NLLM22fypYBlvj1+aH6dM= +gorm.io/gorm v1.23.5/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= diff --git a/main.go b/main.go index 7f5a890..0eea2e0 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,169 @@ package main -import _ "github.com/gin-gonic/gin" +import ( + "fmt" + "log" + "net/http" + "time" + + "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" +) + +func Migrate(g *gorm.DB) { + g.AutoMigrate(&models.User{}) + g.AutoMigrate(&models.Admin{}) +} func main() { + db := database.Init() + Migrate(db) + r := gin.Default() + v1 := r.Group("/v1") + + // Ping functionality + v1.GET("/doot", doot()) + + // Standard user login + v1.POST("/signup", userSignup()) + v1.POST("/login", userLogin()) + v1Sec := v1.Group("/sec", userAuth()) + + v1Sec.GET("/doot", doot()) + + // Administrative login + v1.POST("/admin", adminLogin()) + v1Admin := v1.Group("/adm", adminAuth()) + + v1Admin.GET("/doot", 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 + } + + err := u.CheckPassword(loginVals.Password) + if err != nil { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + err = u.ValidateTwoFactor(loginVals.TwoFactor, time.Now()) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, failmsg{err.Error()}) + return + } + + if !u.Verified { + c.AbortWithStatusJSON(http.StatusUnauthorized, failmsg{"not yet verified"}) + 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) { + + } +} + +func userAuth() gin.HandlerFunc { + return func(c *gin.Context) { + jwt := c.GetHeader(JwtHeader) + if jwt == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, failmsg{"Requires `" + jwt + "` header"}) + return + } + + } +} + +func adminAuth() gin.HandlerFunc { + return func(c *gin.Context) { + jwt := c.GetHeader(JwtHeader) + if jwt == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, failmsg{"Requires `" + jwt + "` header"}) + return + } + + c.AbortWithStatus(http.StatusUnauthorized) + } } diff --git a/models/admin.go b/models/admin.go new file mode 100644 index 0000000..07bdea3 --- /dev/null +++ b/models/admin.go @@ -0,0 +1,24 @@ +package models + +import ( + "errors" + + "github.com/yxzzy-wtf/gin-gonic-prepack/database" +) + +type Admin struct { + Auth + Email string +} + +func (a *Admin) GetJwt() (string, int) { + return "", 0 +} + +func (a *Admin) ByEmail(email string) error { + if err := database.Db.Where("email = ?", email).First(&a).Error; err != nil { + return errors.New("not found") + } + + return nil +} diff --git a/models/auth.go b/models/auth.go new file mode 100644 index 0000000..243d4d2 --- /dev/null +++ b/models/auth.go @@ -0,0 +1,43 @@ +package models + +import ( + "errors" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type Auth struct { + Base + PasswordHash string + TwoFactorSecret string + TwoFactorRecovery string + Verified bool +} + +func (a *Auth) SetPassword(pass string) error { + passHash, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) + a.PasswordHash = string(passHash) + return nil +} + +func (a *Auth) CheckPassword(pass string) error { + return bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(pass)) +} + +func (a *Auth) ValidateTwoFactor(tfCode string, stamp time.Time) error { + if tfCode == "" && a.TwoFactorSecret != "" { + return errors.New("requires 2FA") + } else if tfCode == "" && a.TwoFactorSecret == "" { + return nil + } + + //TODO two factor + if len(tfCode) == 6 { + // Test 2FA + return errors.New("2FA invalid") + } else { + // May be a renewal code + return errors.New("unlock invalid") + } +} diff --git a/models/base.go b/models/base.go new file mode 100644 index 0000000..414aaf0 --- /dev/null +++ b/models/base.go @@ -0,0 +1,31 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Base struct { + Uid uuid.UUID `gorm:"type:uuid;primary_key;"` + Created time.Time + Updated time.Time + Deleted time.Time `sql:"index"` + Tenant uuid.UUID +} + +func (b *Base) BeforeCreate(scope *gorm.DB) error { + b.Uid = uuid.New() + b.Created = time.Now() + return nil +} + +func (b *Base) BeforeSave(tx *gorm.DB) error { + b.Updated = time.Now() + return nil +} + +func (b *Base) Delete() { + b.Deleted = time.Now() +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..b168b19 --- /dev/null +++ b/models/user.go @@ -0,0 +1,24 @@ +package models + +import ( + "errors" + + "github.com/yxzzy-wtf/gin-gonic-prepack/database" +) + +type User struct { + Auth + Email string `gorm:"unique"` +} + +func (u *User) GetJwt() (string, int) { + return "", 0 +} + +func (u *User) ByEmail(email string) error { + if err := database.Db.Where("email = ?", email).First(&u).Error; err != nil { + return errors.New("not found") + } + + return nil +}