diff --git a/.gitignore b/.gitignore index ddc0925..46ef0fe 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ *.dylib go-cook *.db +*.env # Test binary, built with `go test -c` *.test diff --git a/api/domain/dto.go b/api/domain/dto.go new file mode 100644 index 0000000..c2f184f --- /dev/null +++ b/api/domain/dto.go @@ -0,0 +1,7 @@ +package domain + +type UserDto struct { + Id int `json:"id"` + Name string `json:"name"` + Scopes string `json:"scopes"` +} diff --git a/api/domain/entities.go b/api/domain/entities.go new file mode 100644 index 0000000..f26454a --- /dev/null +++ b/api/domain/entities.go @@ -0,0 +1,21 @@ +package domain + +import "time" + +type UserEntity struct { + Id int + CreatedAt time.Time + LastUpdated time.Time + Name string + Hash string + Scopes string +} + +type RecipeEntity struct { + Id int32 + CreatedAt time.Time + LastUpdated time.Time + Title string + Thumbnail string + Content string +} diff --git a/api/domain/models.go b/api/domain/models.go new file mode 100644 index 0000000..a3028ef --- /dev/null +++ b/api/domain/models.go @@ -0,0 +1,6 @@ +package domain + +type EnvConfig struct { + AdminToken string + JwtSecret string +} \ No newline at end of file diff --git a/api/domain/requests.go b/api/domain/requests.go new file mode 100644 index 0000000..83a0a78 --- /dev/null +++ b/api/domain/requests.go @@ -0,0 +1,10 @@ +package domain + +type HelloBodyRequest struct { + Name string `json:"name" validate:"required"` +} + +type UpdateScopesRequest struct { + Username string `json:"name"` + Scopes []string `json:"scopes" validate:"required"` +} diff --git a/api/domain/responses.go b/api/domain/responses.go new file mode 100644 index 0000000..445e05c --- /dev/null +++ b/api/domain/responses.go @@ -0,0 +1,12 @@ +package domain + +type ErrorResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +type HelloWhoResponse struct { + Success bool `json:"success"` + Error string `json:"error"` + Message string `json:"message"` +} diff --git a/api/domain/scopes.go b/api/domain/scopes.go new file mode 100644 index 0000000..49e60b5 --- /dev/null +++ b/api/domain/scopes.go @@ -0,0 +1,8 @@ +package domain + +const ( + ScopeAll = "all" + ScopeRecipeRead = "recipe:read" + ScopeRecipeCreate = "recipe:create" + ScopeRecipeDelete = "recipe:delete" +) diff --git a/api/handlers/v1/auth.go b/api/handlers/v1/auth.go index 86a7828..9d8355a 100644 --- a/api/handlers/v1/auth.go +++ b/api/handlers/v1/auth.go @@ -2,75 +2,58 @@ package v1 import ( "errors" - "go-cook/api/models" + "go-cook/api/domain" "go-cook/api/repositories" "net/http" - "time" "github.com/golang-jwt/jwt/v5" "github.com/labstack/echo/v4" ) const ( - ErrJwtMissing = "auth token is missing" - ErrJwtClaimsMissing = "claims missing on token" - ErrJwtExpired = "auth token has expired" + ErrJwtMissing = "auth token is missing" + ErrJwtClaimsMissing = "claims missing on token" + ErrJwtExpired = "auth token has expired" + ErrJwtScopeMissing = "required scope is missing" + ErrUserNotFound = "requested user does not exist" + ErrUsernameAlreadyExists = "the requested username already exists" ) -type JwtToken struct { - Exp time.Time `json:"exp"` - Authorized bool `json:"authorized"` - UserName string `json:"username"` - Token string `json:"token"` - jwt.RegisteredClaims -} - -func generateJwt(username string) (string, error) { - //TODO use env here - secret := []byte("ThisIsABadSecretDontReallyUseThis") - - token := jwt.New(jwt.SigningMethodHS256) - claims := token.Claims.(jwt.MapClaims) - claims["exp"] = time.Now().Add(10 * time.Minute) - claims["authorized"] = true - claims["username"] = username - - tokenString, err := token.SignedString(secret) - if err != nil { - return "", err - } - - return tokenString, nil -} - func (h *Handler) AuthRegister(c echo.Context) error { - username := c.QueryParam("username") - _, err := h.userRepo.GetByName(username) + username := c.FormValue("username") + password := c.FormValue("password") + + //username := c.QueryParam("username") + exists, err := h.userRepo.GetByName(username) if err != nil { // if we have an err, validate that if its not user not found. // if the user is not found, we can use that name if err.Error() != repositories.ErrUserNotFound { - return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ - HttpCode: http.StatusInternalServerError, - Message: err.Error(), + return c.JSON(http.StatusInternalServerError, domain.ErrorResponse{ + + Message: err.Error(), + Success: true, }) } } + if exists.Name == username { + return h.InternalServerErrorResponse(c, ErrUsernameAlreadyExists) + } - password := c.QueryParam("password") + //password := c.QueryParam("password") err = h.UserService.CheckPasswordForRequirements(password) if err != nil { - return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ - HttpCode: http.StatusInternalServerError, - Message: err.Error(), + return c.JSON(http.StatusInternalServerError, domain.ErrorResponse{ + Success: false, + Message: err.Error(), }) } - _, err = h.userRepo.Create(username, password) + _, err = h.userRepo.Create(username, password, domain.ScopeRecipeRead) if err != nil { - return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ - HttpCode: http.StatusInternalServerError, - Message: err.Error(), + return c.JSON(http.StatusInternalServerError, domain.ErrorResponse{ + Success: false, + Message: err.Error(), }) } @@ -78,29 +61,113 @@ func (h *Handler) AuthRegister(c echo.Context) error { } func (h *Handler) AuthLogin(c echo.Context) error { - username := c.QueryParam("username") - password := c.QueryParam("password") + username := c.FormValue("name") + password := c.FormValue("password") + + // Check to see if they are trying to login with the admin token + if username == "" { + return h.validateAdminToken(c, password) + } // check if the user exists err := h.UserService.DoesUserExist(username) if err != nil { - return c.JSON(http.StatusInternalServerError, err) + return h.InternalServerErrorResponse(c, err.Error()) } // make sure the hash matches err = h.UserService.DoesPasswordMatchHash(username, password) if err != nil { - return c.JSON(http.StatusInternalServerError, err) + return h.InternalServerErrorResponse(c, err.Error()) } - token, err := generateJwt(username) + token, err := h.generateJwt(username) if err != nil { - return c.JSON(http.StatusInternalServerError, err) + return h.InternalServerErrorResponse(c, err.Error()) } return c.JSON(http.StatusOK, token) } +func (h *Handler) validateAdminToken(c echo.Context, password string) error { + // if the admin token is blank, then the admin wanted this disabled. + // this will fail right away and not progress. + if h.Config.AdminToken == "" { + return h.InternalServerErrorResponse(c, ErrUserNotFound) + } + + if h.Config.AdminToken != password { + return h.ReturnUnauthorizedResponse(c, ErrUserNotFound) + } + + token, err := h.generateAdminJwt("admin") + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + return c.JSON(http.StatusOK, token) +} + +func (h *Handler) AddScopes(c echo.Context) error { + token, err := h.getJwtToken(c) + if err != nil { + return h.ReturnUnauthorizedResponse(c, err.Error()) + } + + err = token.IsValid(domain.ScopeAll) + if err != nil { + return h.ReturnUnauthorizedResponse(c, err.Error()) + } + + request := domain.UpdateScopesRequest{} + err = (&echo.DefaultBinder{}).BindBody(c, &request) + if err != nil { + return c.JSON(http.StatusBadRequest, domain.ErrorResponse{ + Success: false, + Message: err.Error(), + }) + } + + err = h.UserService.AddScopes(request.Username, request.Scopes) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + return c.JSON(http.StatusOK, domain.ErrorResponse{ + Success: true, + }) +} + +func (h *Handler) RemoveScopes(c echo.Context) error { + token, err := h.getJwtToken(c) + if err != nil { + return h.ReturnUnauthorizedResponse(c, err.Error()) + } + + err = token.IsValid(domain.ScopeAll) + if err != nil { + return h.ReturnUnauthorizedResponse(c, err.Error()) + } + + request := domain.UpdateScopesRequest{} + err = (&echo.DefaultBinder{}).BindBody(c, &request) + if err != nil { + return c.JSON(http.StatusBadRequest, domain.ErrorResponse{ + Success: false, + Message: err.Error(), + }) + } + + err = h.UserService.RemoveScopes(request.Username, request.Scopes) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + return c.JSON(http.StatusOK, domain.ErrorResponse{ + Success: true, + }) +} + func (h *Handler) RefreshJwtToken(c echo.Context) error { return nil } @@ -118,11 +185,5 @@ func (h *Handler) getJwtToken(c echo.Context) (JwtToken, error) { return JwtToken{}, errors.New(ErrJwtClaimsMissing) } - // Check to see if the token has expired - hasExpired := claims.Exp.Compare(time.Now()) - if hasExpired == -1 { - return JwtToken{}, errors.New(ErrJwtExpired) - } - return *claims, nil } diff --git a/api/handlers/v1/demo.go b/api/handlers/v1/demo.go index 597cb6f..d8bfcdc 100644 --- a/api/handlers/v1/demo.go +++ b/api/handlers/v1/demo.go @@ -2,20 +2,14 @@ package v1 import ( "fmt" - "go-cook/api/models" + "go-cook/api/domain" "net/http" "github.com/labstack/echo/v4" ) -type HelloWhoResponse struct { - Success bool `json:"success"` - Error string `error:"error"` - Message string `json:"message"` -} - func (h *Handler) DemoHello(c echo.Context) error { - return c.JSON(http.StatusOK, HelloWhoResponse{ + return c.JSON(http.StatusOK, domain.HelloWhoResponse{ Success: true, Message: "Hello world!", }) @@ -23,27 +17,23 @@ func (h *Handler) DemoHello(c echo.Context) error { func (h *Handler) HelloWho(c echo.Context) error { name := c.Param("who") - return c.JSON(http.StatusOK, HelloWhoResponse{ + return c.JSON(http.StatusOK, domain.HelloWhoResponse{ Success: true, Message: fmt.Sprintf("Hello, %s", name), }) } -type HelloBodyRequest struct { - Name string `json:"name" validate:"required"` -} - func (h *Handler) HelloBody(c echo.Context) error { - request := HelloBodyRequest{} + request := domain.HelloBodyRequest{} err := (&echo.DefaultBinder{}).BindBody(c, &request) if err != nil { - return c.JSON(http.StatusBadRequest, HelloWhoResponse{ + return c.JSON(http.StatusBadRequest, domain.HelloWhoResponse{ Success: false, Error: err.Error(), }) } - return c.JSON(http.StatusOK, HelloWhoResponse{ + return c.JSON(http.StatusOK, domain.HelloWhoResponse{ Success: true, Message: fmt.Sprintf("Hello, %s", request.Name), }) @@ -52,10 +42,12 @@ func (h *Handler) HelloBody(c echo.Context) error { func (h *Handler) ProtectedRoute(c echo.Context) error { token, err := h.getJwtToken(c) if err != nil { - return c.JSON(http.StatusForbidden, models.ErrorResponse{ - HttpCode: http.StatusForbidden, - Message: err.Error(), - }) + h.ReturnUnauthorizedResponse(c, err.Error()) + } + + err = token.IsValid(domain.ScopeRecipeRead) + if err != nil { + h.ReturnUnauthorizedResponse(c, ErrJwtScopeMissing) } return c.JSON(http.StatusOK, token) diff --git a/api/handlers/v1/handler.go b/api/handlers/v1/handler.go index 9de5b13..8904920 100644 --- a/api/handlers/v1/handler.go +++ b/api/handlers/v1/handler.go @@ -2,8 +2,10 @@ package v1 import ( "database/sql" + "go-cook/api/domain" "go-cook/api/repositories" "go-cook/api/services" + "net/http" "github.com/golang-jwt/jwt/v5" echojwt "github.com/labstack/echo-jwt/v4" @@ -11,16 +13,19 @@ import ( ) type Handler struct { + Config domain.EnvConfig + UserService services.UserService userRepo repositories.IUserTable recipeRepo repositories.IRecipeTable } -func NewHandler(conn *sql.DB) *Handler { +func NewHandler(conn *sql.DB, cfg domain.EnvConfig) *Handler { return &Handler{ + Config: cfg, UserService: services.NewUserService(conn), - userRepo: repositories.NewUserRepository(conn), - recipeRepo: repositories.NewRecipeRepository(conn), + userRepo: repositories.NewUserRepository(conn), + recipeRepo: repositories.NewRecipeRepository(conn), } } @@ -29,13 +34,17 @@ func (h *Handler) Register(v1 *echo.Group) { NewClaimsFunc: func(c echo.Context) jwt.Claims { return new(JwtToken) }, - SigningKey: []byte("ThisIsABadSecretDontReallyUseThis"), + SigningKey: []byte(h.Config.JwtSecret), } - v1.POST("/login", h.AuthLogin) - v1.POST("/register", h.AuthRegister) + auth := v1.Group("/auth") + auth.POST("/login", h.AuthLogin) + auth.POST("/register", h.AuthRegister) + auth.Use(echojwt.WithConfig(jwtConfig)) + auth.POST("/scopes/add", h.AddScopes) + auth.POST("/scopes/remove", h.RemoveScopes) + demo := v1.Group("/demo") - demo.GET("/hello", h.DemoHello) demo.GET("/hello/:who", h.HelloWho) @@ -53,3 +62,17 @@ func (h *Handler) Register(v1 *echo.Group) { //users.POST("/login", h.LoginUser) //users.POST("/update/password", h.UpdatePassword) } + +func (h *Handler) ReturnUnauthorizedResponse(c echo.Context, message string) error { + return c.JSON(http.StatusUnauthorized, domain.ErrorResponse{ + Success: false, + Message: message, + }) +} + +func (h *Handler) InternalServerErrorResponse(c echo.Context, message string) error { + return c.JSON(http.StatusServiceUnavailable, domain.ErrorResponse{ + Success: false, + Message: message, + }) +} diff --git a/api/handlers/v1/jwt.go b/api/handlers/v1/jwt.go new file mode 100644 index 0000000..88da586 --- /dev/null +++ b/api/handlers/v1/jwt.go @@ -0,0 +1,98 @@ +package v1 + +import ( + "errors" + "go-cook/api/domain" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type JwtToken struct { + Exp time.Time `json:"exp"` + Iss string `json:"iss"` + Authorized bool `json:"authorized"` + UserName string `json:"username"` + Scopes []string `json:"scopes"` + jwt.RegisteredClaims +} + +func (j JwtToken) IsValid(scope string) error { + err := j.hasExpired() + if err != nil { + return err + } + + err = j.hasScope(scope) + if err != nil { + return err + } + + return nil +} + +func (j JwtToken) hasExpired() error { + // Check to see if the token has expired + hasExpired := j.Exp.Compare(time.Now()) + if hasExpired == -1 { + return errors.New(ErrJwtExpired) + } + return nil +} + +func (j JwtToken) hasScope(scope string) error { + // they have the scope to access everything, so let them pass. + if strings.Contains(domain.ScopeAll, scope) { + return nil + } + + for _, s := range j.Scopes { + if strings.Contains(s, scope) { + return nil + } + } + return errors.New(ErrJwtScopeMissing) +} + +func (h *Handler) generateJwt(username string) (string, error) { + secret := []byte(h.Config.JwtSecret) + + token := jwt.New(jwt.SigningMethodHS256) + claims := token.Claims.(jwt.MapClaims) + claims["exp"] = time.Now().Add(10 * time.Minute) + claims["authorized"] = true + claims["username"] = username + + var scopes []string + scopes = append(scopes, domain.ScopeRecipeRead) + claims["scopes"] = scopes + + tokenString, err := token.SignedString(secret) + if err != nil { + return "", err + } + + return tokenString, nil +} + +func (h *Handler) generateAdminJwt(username string) (string, error) { + secret := []byte(h.Config.JwtSecret) + + token := jwt.New(jwt.SigningMethodHS256) + claims := token.Claims.(jwt.MapClaims) + claims["exp"] = time.Now().Add(10 * time.Minute) + claims["authorized"] = true + claims["username"] = username + + var scopes []string + scopes = append(scopes, domain.ScopeAll) + claims["scopes"] = scopes + + tokenString, err := token.SignedString(secret) + if err != nil { + return "", err + } + + return tokenString, nil +} diff --git a/api/migrations/20240329211828_user_scopes.sql b/api/migrations/20240329211828_user_scopes.sql new file mode 100644 index 0000000..f75cfe9 --- /dev/null +++ b/api/migrations/20240329211828_user_scopes.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- +goose StatementBegin +SELECT 'up SQL query'; +ALTER Table USERS ADD Scopes TEXT; +-- +goose StatementEnd +-- +goose Down +-- +goose StatementBegin +SELECT 'down SQL query'; +ALTER TABLE USERS DROP COLUMN Scopes; +-- +goose StatementEnd \ No newline at end of file diff --git a/api/models/recipe.go b/api/models/recipe.go deleted file mode 100644 index 9f98366..0000000 --- a/api/models/recipe.go +++ /dev/null @@ -1,12 +0,0 @@ -package models - -import "time" - -type RecipeModel struct { - Id int32 - Title string - Thumbnail string - Content string - CreatedAt time.Time - LastUpdated time.Time -} diff --git a/api/models/std.go b/api/models/std.go deleted file mode 100644 index efd0955..0000000 --- a/api/models/std.go +++ /dev/null @@ -1,6 +0,0 @@ -package models - -type ErrorResponse struct { - HttpCode int `json:"code"` - Message string `json:"message"` -} diff --git a/api/models/users.go b/api/models/users.go deleted file mode 100644 index 14560ca..0000000 --- a/api/models/users.go +++ /dev/null @@ -1,15 +0,0 @@ -package models - -import "time" - -type UserModel struct { - Id int - Name string - Hash string - CreatedAt time.Time - LastUpdated time.Time -} - -type UserDto struct { - -} diff --git a/api/repositories/recipe.go b/api/repositories/recipe.go index 6ee15b3..93addeb 100644 --- a/api/repositories/recipe.go +++ b/api/repositories/recipe.go @@ -3,15 +3,15 @@ package repositories import ( "database/sql" "errors" - "go-cook/api/models" + "go-cook/api/domain" ) type IRecipeTable interface { - Create(models.RecipeModel) error - List() ([]models.RecipeModel, error) - Get(id int) (models.RecipeModel, error) - Update(id int, entity models.RecipeModel) error - Delete(id int) error + Create(domain.RecipeEntity) error + List() ([]domain.RecipeEntity, error) + Get(id int) (domain.RecipeEntity, error) + Update(id int, entity domain.RecipeEntity) error + Delete(id int) error } type RecipeRepository struct { @@ -24,22 +24,22 @@ func NewRecipeRepository(client *sql.DB) RecipeRepository { } } -func (rr RecipeRepository) Create(models.RecipeModel) error { +func (rr RecipeRepository) Create(domain.RecipeEntity) error { return errors.New("not implemented") } -func (rr RecipeRepository) List() ([]models.RecipeModel, error) { - return []models.RecipeModel{}, errors.New("not implemented") +func (rr RecipeRepository) List() ([]domain.RecipeEntity, error) { + return []domain.RecipeEntity{}, errors.New("not implemented") } -func (rr RecipeRepository) Get(id int) (models.RecipeModel, error) { - return models.RecipeModel{}, errors.New("not implemented") +func (rr RecipeRepository) Get(id int) (domain.RecipeEntity, error) { + return domain.RecipeEntity{}, errors.New("not implemented") } -func (rr RecipeRepository) Update(id int, entity models.RecipeModel) error { +func (rr RecipeRepository) Update(id int, entity domain.RecipeEntity) error { return errors.New("not implemented") } func (rr RecipeRepository) Delete(id int) error { return errors.New("not implemented") -} \ No newline at end of file +} diff --git a/api/repositories/users.go b/api/repositories/users.go index 1ee2e91..b46f8df 100644 --- a/api/repositories/users.go +++ b/api/repositories/users.go @@ -4,7 +4,7 @@ import ( "database/sql" "errors" "fmt" - "go-cook/api/models" + "go-cook/api/domain" "time" "github.com/huandu/go-sqlbuilder" @@ -12,16 +12,17 @@ import ( ) const ( - TableName string = "users" + TableName string = "users" ErrUserNotFound string = "requested user was not found" ) type IUserTable interface { - GetByName(name string) (models.UserModel, error) - Create(name, password string) (int64, error) - Update(id int, entity models.UserModel) error + GetByName(name string) (domain.UserEntity, error) + Create(name, password, scope string) (int64, error) + Update(id int, entity domain.UserEntity) error UpdatePassword(name, password string) error CheckUserHash(name, password string) error + UpdateScopes(name, scope string) error } // Creates a new instance of UserRepository with the bound sql @@ -35,7 +36,7 @@ type UserRepository struct { connection *sql.DB } -func (ur UserRepository) GetByName(name string) (models.UserModel, error) { +func (ur UserRepository) GetByName(name string) (domain.UserEntity, error) { builder := sqlbuilder.NewSelectBuilder() builder.Select("*").From("users").Where( builder.E("Name", name), @@ -44,18 +45,18 @@ func (ur UserRepository) GetByName(name string) (models.UserModel, error) { rows, err := ur.connection.Query(query, args...) if err != nil { - return models.UserModel{}, err + return domain.UserEntity{}, err } data := ur.processRows(rows) - if (len(data) == 0) { - return models.UserModel{}, errors.New(ErrUserNotFound) + if len(data) == 0 { + return domain.UserEntity{}, errors.New(ErrUserNotFound) } return data[0], nil } -func (ur UserRepository) Create(name, password string) (int64, error) { +func (ur UserRepository) Create(name, password, scope string) (int64, error) { passwordBytes := []byte(password) hash, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost) if err != nil { @@ -65,8 +66,8 @@ func (ur UserRepository) Create(name, password string) (int64, error) { dt := time.Now() queryBuilder := sqlbuilder.NewInsertBuilder() queryBuilder.InsertInto("users") - queryBuilder.Cols("Name", "Hash", "LastUpdated", "CreatedAt") - queryBuilder.Values(name, string(hash), dt, dt) + queryBuilder.Cols("Name", "Hash", "LastUpdated", "CreatedAt", "Scopes") + queryBuilder.Values(name, string(hash), dt, dt, scope) query, args := queryBuilder.Build() _, err = ur.connection.Exec(query, args...) @@ -77,7 +78,7 @@ func (ur UserRepository) Create(name, password string) (int64, error) { return 1, nil } -func (ur UserRepository) Update(id int, entity models.UserModel) error { +func (ur UserRepository) Update(id int, entity domain.UserEntity) error { return errors.New("not implemented") } @@ -109,8 +110,26 @@ func (ur UserRepository) CheckUserHash(name, password string) error { return nil } -func (ur UserRepository) processRows(rows *sql.Rows) []models.UserModel { - items := []models.UserModel{} +func (ur UserRepository) UpdateScopes(name, scope string) error { + builder := sqlbuilder.NewUpdateBuilder() + builder.Update("users") + builder.Set ( + builder.Assign("Scopes", scope), + ) + builder.Where( + builder.Equal("Name", name), + ) + query, args := builder.Build() + + _, err := ur.connection.Exec(query, args...) + if err != nil { + return err + } + return nil +} + +func (ur UserRepository) processRows(rows *sql.Rows) []domain.UserEntity { + items := []domain.UserEntity{} for rows.Next() { var id int @@ -118,15 +137,17 @@ func (ur UserRepository) processRows(rows *sql.Rows) []models.UserModel { var hash string var createdAt time.Time var lastUpdated time.Time - err := rows.Scan(&id, &name, &hash, &createdAt, &lastUpdated) + var scopes string + err := rows.Scan(&id, &name, &hash, &createdAt, &lastUpdated, &scopes) if err != nil { fmt.Println(err) } - items = append(items, models.UserModel{ + items = append(items, domain.UserEntity{ Id: id, Name: name, Hash: hash, + Scopes: scopes, CreatedAt: createdAt, LastUpdated: lastUpdated, }) diff --git a/api/repositories/users_test.go b/api/repositories/users_test.go index 120b53b..66b1dd3 100644 --- a/api/repositories/users_test.go +++ b/api/repositories/users_test.go @@ -2,6 +2,7 @@ package repositories_test import ( "database/sql" + "go-cook/api/domain" "go-cook/api/repositories" "log" "testing" @@ -20,7 +21,7 @@ func TestCanCreateNewUser(t *testing.T) { defer db.Close() repo := repositories.NewUserRepository(db) - updated, err := repo.Create("testing", "NotSecure") + updated, err := repo.Create("testing", "NotSecure", domain.ScopeRecipeRead) if err != nil { log.Println(err) t.FailNow() diff --git a/api/services/env.go b/api/services/env.go new file mode 100644 index 0000000..4b39063 --- /dev/null +++ b/api/services/env.go @@ -0,0 +1,21 @@ +package services + +import ( + "go-cook/api/domain" + "log" + "os" + + "github.com/joho/godotenv" +) + +func NewEnvConfig() domain.EnvConfig { + err := godotenv.Load() + if err != nil { + log.Println(err) + } + + return domain.EnvConfig{ + AdminToken: os.Getenv("AdminToken"), + JwtSecret: os.Getenv("JwtSecret"), + } +} diff --git a/api/services/userService.go b/api/services/userService.go index 193c53d..5c21787 100644 --- a/api/services/userService.go +++ b/api/services/userService.go @@ -3,7 +3,7 @@ package services import ( "database/sql" "errors" - "go-cook/api/models" + "go-cook/api/domain" "go-cook/api/repositories" "strings" @@ -36,12 +36,6 @@ func (us UserService) DoesUserExist(username string) error { } func (us UserService) DoesPasswordMatchHash(username, password string) error { - //passwordBytes := []byte(password) - //hash, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost) - //if err != nil { - // return err - //} - model, err := us.GetUser(username) if err != nil { return err @@ -55,18 +49,75 @@ func (us UserService) DoesPasswordMatchHash(username, password string) error { return nil } -func (us UserService) GetUser(username string) (models.UserModel, error) { +func (us UserService) GetUser(username string) (domain.UserEntity, error) { return us.repo.GetByName(username) } -func (us UserService) CreateNewUser(name, password string) (models.UserModel, error) { - err := us.CheckPasswordForRequirements(password) +func (us UserService) AddScopes(username string, scopes []string) error { + usr, err := us.repo.GetByName(username) if err != nil { - return models.UserModel{}, err + return err } - us.repo.Create(name, password) - return models.UserModel{}, nil + if usr.Name != username { + return errors.New(repositories.ErrUserNotFound) + } + + currentScopes := strings.Split(usr.Scopes, ",") + + // check the current scopes + for _, item := range scopes { + if !strings.Contains(usr.Scopes, item) { + currentScopes = append(currentScopes, item) + } + } + return us.repo.UpdateScopes(username, strings.Join(currentScopes, ",")) +} + +func (us UserService) RemoveScopes(username string, scopes []string) error { + usr, err := us.repo.GetByName(username) + if err != nil { + return err + } + + if usr.Name != username { + return errors.New(repositories.ErrUserNotFound) + } + + var newScopes []string + + // check all the scopes that are currently assigned + for _, item := range strings.Split(usr.Scopes, ",") { + + // check the scopes given, if one matches skip it + if us.doesScopeExist(scopes, item) { + continue + } + + // did not match, add it + newScopes = append(newScopes, item) + } + + return us.repo.UpdateScopes(username, strings.Join(newScopes, ",")) +} + +func (us UserService) doesScopeExist(scopes []string, target string) bool { + for _, item := range scopes { + if item == target { + return true + } + } + return false +} + +func (us UserService) CreateNewUser(name, password, scope string) (domain.UserEntity, error) { + err := us.CheckPasswordForRequirements(password) + if err != nil { + return domain.UserEntity{}, err + } + + us.repo.Create(name, password, domain.ScopeRecipeRead) + return domain.UserEntity{}, nil } func (us UserService) CheckPasswordForRequirements(password string) error { diff --git a/go.mod b/go.mod index adbb909..19e3cb0 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/google/uuid v1.5.0 // indirect github.com/huandu/go-sqlbuilder v1.25.0 // indirect github.com/huandu/xstrings v1.3.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/labstack/echo-jwt/v4 v4.2.0 // indirect github.com/labstack/echo/v4 v4.11.4 // indirect diff --git a/go.sum b/go.sum index a04d568..e484048 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,8 @@ github.com/huandu/go-sqlbuilder v1.25.0 h1:h1l+6CqeCviPJCnkEZoRGNdfZ5RO9BKMvG3A+ github.com/huandu/go-sqlbuilder v1.25.0/go.mod h1:nUVmMitjOmn/zacMLXT0d3Yd3RHoO2K+vy906JzqxMI= github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= diff --git a/main.go b/main.go index 0896dc4..411f3f4 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "database/sql" v1 "go-cook/api/handlers/v1" + "go-cook/api/services" "log" "net/http" @@ -21,6 +22,8 @@ func main() { log.Fatal(err) } + cfg := services.NewEnvConfig() + e := echo.New() e.Validator = &CustomValidator{ validator: validator.New(), @@ -30,7 +33,7 @@ func main() { e.Pre(middleware.Recover()) v1Group := e.Group("/api/v1") - v1Api := v1.NewHandler(db) + v1Api := v1.NewHandler(db, cfg) v1Api.Register(v1Group) e.Logger.Fatal(e.Start(":1323")) diff --git a/rest.http b/rest.http index a5a5dc3..ad8faed 100644 --- a/rest.http +++ b/rest.http @@ -1,7 +1,48 @@ +### Create a standard User +POST http://localhost:1323/api/v1/auth/register +Content-Type: application/x-www-form-urlencoded + +name=test&password=test1234! +### Login with user +POST http://localhost:1323/api/v1/auth/login +Content-Type: application/x-www-form-urlencoded + +name=test&password=test1234! + + +### Login with the admin token +POST http://localhost:1323/api/v1/auth/login +Content-Type: application/x-www-form-urlencoded + +password=lol + + +### Add Scope to test user +POST http://localhost:1323/api/v1/auth/scopes/add +Content-Type: application/json +Authorization: Bearer + +{ + "name": "test", + "scopes": [ + "recipe:create" + ] +} + +### Remove scope from test user +POST http://localhost:1323/api/v1/auth/scopes/remove +Content-Type: application/json +Authorization: Bearer + +{ + "name": "test", + "scopes": [ + "recipe:create" + ] +} + ### -POST http://localhost:1323/api/v1/register?username=test&password=test1234! -### -POST http://localhost:1323/api/v1/login?username=test&password=test1234! +POST http://localhost:1323/api/v1/ ### GET http://localhost:1323/api/v1/demo/hello ###