Merge pull request 'Basic JWT is now working' (#12) from features/jwt/maybe into main

Reviewed-on: #12
This commit is contained in:
jtom38 2024-03-29 14:51:00 -07:00
commit 4981ef7f81
18 changed files with 407 additions and 57 deletions

View File

@ -1,9 +1,18 @@
{
"cSpell.words": [
"echojwt",
"glebarez",
"gocook",
"huandu",
"labstack",
"sqlbuilder"
],
"sqltools.connections": [
{
"previewLimit": 50,
"driver": "SQLite",
"database": "${workspaceFolder:go-cook}/gocook.db",
"name": "gocook"
}
]
}

128
api/handlers/v1/auth.go Normal file
View File

@ -0,0 +1,128 @@
package v1
import (
"errors"
"go-cook/api/models"
"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"
)
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)
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(),
})
}
}
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(),
})
}
_, err = h.userRepo.Create(username, password)
if err != nil {
return c.JSON(http.StatusInternalServerError, models.ErrorResponse{
HttpCode: http.StatusInternalServerError,
Message: err.Error(),
})
}
return nil
}
func (h *Handler) AuthLogin(c echo.Context) error {
username := c.QueryParam("username")
password := c.QueryParam("password")
// check if the user exists
err := h.UserService.DoesUserExist(username)
if err != nil {
return c.JSON(http.StatusInternalServerError, err)
}
// make sure the hash matches
err = h.UserService.DoesPasswordMatchHash(username, password)
if err != nil {
return c.JSON(http.StatusInternalServerError, err)
}
token, err := generateJwt(username)
if err != nil {
return c.JSON(http.StatusInternalServerError, err)
}
return c.JSON(http.StatusOK, token)
}
func (h *Handler) RefreshJwtToken(c echo.Context) error {
return nil
}
func (h *Handler) getJwtToken(c echo.Context) (JwtToken, error) {
// Make sure that the request came with a jwtToken
token, ok := c.Get("user").(*jwt.Token)
if !ok {
return JwtToken{}, errors.New(ErrJwtMissing)
}
// Generate the claims from the token
claims, ok := token.Claims.(*JwtToken)
if !ok {
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
}

View File

@ -2,6 +2,7 @@ package v1
import (
"fmt"
"go-cook/api/models"
"net/http"
"github.com/labstack/echo/v4"
@ -38,12 +39,24 @@ func (h *Handler) HelloBody(c echo.Context) error {
if err != nil {
return c.JSON(http.StatusBadRequest, HelloWhoResponse{
Success: false,
Error: err.Error(),
Error: err.Error(),
})
}
return c.JSON(http.StatusOK, HelloWhoResponse{
Success: true,
Message: fmt.Sprintf("Hello, %s", request.Name),
})
}
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(),
})
}
return c.JSON(http.StatusOK, token)
}

View File

@ -3,26 +3,53 @@ package v1
import (
"database/sql"
"go-cook/api/repositories"
"go-cook/api/services"
"github.com/golang-jwt/jwt/v5"
echojwt "github.com/labstack/echo-jwt/v4"
"github.com/labstack/echo/v4"
)
type Handler struct {
userRepo repositories.UserRepository
UserService services.UserService
userRepo repositories.IUserTable
recipeRepo repositories.IRecipeTable
}
func NewHandler(conn *sql.DB) *Handler {
return &Handler{
userRepo: repositories.NewUserRepository(conn),
UserService: services.NewUserService(conn),
userRepo: repositories.NewUserRepository(conn),
recipeRepo: repositories.NewRecipeRepository(conn),
}
}
func (h *Handler) Register(v1 *echo.Group) {
jwtConfig := echojwt.Config{
NewClaimsFunc: func(c echo.Context) jwt.Claims {
return new(JwtToken)
},
SigningKey: []byte("ThisIsABadSecretDontReallyUseThis"),
}
v1.POST("/login", h.AuthLogin)
v1.POST("/register", h.AuthRegister)
demo := v1.Group("/demo")
demo.GET("/hello", h.DemoHello)
demo.GET("/hello", h.DemoHello)
demo.GET("/hello/:who", h.HelloWho)
demo.Use(echojwt.WithConfig(jwtConfig))
demo.GET("/hello/body", h.HelloBody)
users := v1.Group("/users")
users.POST("/new", h.NewUser)
protected := v1.Group("/demo/protected")
protected.Use(echojwt.WithConfig(jwtConfig))
protected.GET("", h.ProtectedRoute)
//recipes := v1.Group("/recipe")
//users := v1.Group("/users")
//users.POST("/register", h.RegisterUser)
//users.POST("/login", h.LoginUser)
//users.POST("/update/password", h.UpdatePassword)
}

View File

@ -6,6 +6,10 @@ import (
"github.com/labstack/echo/v4"
)
func (h *Handler) NewUser(c echo.Context) error {
return c.JSON(http.StatusOK, "not implemented yet")
type newUserResponse struct {
}
func (h *Handler) RegisterUser(c echo.Context) error {
return c.JSON(http.StatusOK, newUserResponse{})
}

12
api/models/recipe.go Normal file
View File

@ -0,0 +1,12 @@
package models
import "time"
type RecipeModel struct {
Id int32
Title string
Thumbnail string
Content string
CreatedAt time.Time
LastUpdated time.Time
}

6
api/models/std.go Normal file
View File

@ -0,0 +1,6 @@
package models
type ErrorResponse struct {
HttpCode int `json:"code"`
Message string `json:"message"`
}

View File

@ -9,3 +9,7 @@ type UserModel struct {
CreatedAt time.Time
LastUpdated time.Time
}
type UserDto struct {
}

View File

@ -0,0 +1,45 @@
package repositories
import (
"database/sql"
"errors"
"go-cook/api/models"
)
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
}
type RecipeRepository struct {
client *sql.DB
}
func NewRecipeRepository(client *sql.DB) RecipeRepository {
return RecipeRepository{
client: client,
}
}
func (rr RecipeRepository) Create(models.RecipeModel) error {
return errors.New("not implemented")
}
func (rr RecipeRepository) List() ([]models.RecipeModel, error) {
return []models.RecipeModel{}, errors.New("not implemented")
}
func (rr RecipeRepository) Get(id int) (models.RecipeModel, error) {
return models.RecipeModel{}, errors.New("not implemented")
}
func (rr RecipeRepository) Update(id int, entity models.RecipeModel) error {
return errors.New("not implemented")
}
func (rr RecipeRepository) Delete(id int) error {
return errors.New("not implemented")
}

View File

@ -2,6 +2,7 @@ package repositories
import (
"database/sql"
"errors"
"fmt"
"go-cook/api/models"
"time"
@ -12,8 +13,17 @@ import (
const (
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
UpdatePassword(name, password string) error
CheckUserHash(name, password string) error
}
// Creates a new instance of UserRepository with the bound sql
func NewUserRepository(conn *sql.DB) UserRepository {
return UserRepository{
@ -37,10 +47,15 @@ func (ur UserRepository) GetByName(name string) (models.UserModel, error) {
return models.UserModel{}, err
}
return ur.processRows(rows)[0], nil
data := ur.processRows(rows)
if (len(data) == 0) {
return models.UserModel{}, errors.New(ErrUserNotFound)
}
return data[0], nil
}
func (ur UserRepository) NewUser(name, password string) (int64, error) {
func (ur UserRepository) Create(name, password string) (int64, error) {
passwordBytes := []byte(password)
hash, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost)
if err != nil {
@ -62,6 +77,10 @@ func (ur UserRepository) NewUser(name, password string) (int64, error) {
return 1, nil
}
func (ur UserRepository) Update(id int, entity models.UserModel) error {
return errors.New("not implemented")
}
func (ur UserRepository) UpdatePassword(name, password string) error {
_, err := ur.GetByName(name)
if err != nil {

View File

@ -20,7 +20,7 @@ func TestCanCreateNewUser(t *testing.T) {
defer db.Close()
repo := repositories.NewUserRepository(db)
updated, err := repo.NewUser("testing", "NotSecure")
updated, err := repo.Create("testing", "NotSecure")
if err != nil {
log.Println(err)
t.FailNow()

106
api/services/userService.go Normal file
View File

@ -0,0 +1,106 @@
package services
import (
"database/sql"
"errors"
"go-cook/api/models"
"go-cook/api/repositories"
"strings"
"golang.org/x/crypto/bcrypt"
)
const (
ErrPasswordNotLongEnough = "password needs to be 8 character or longer"
ErrPasswordMissingSpecialCharacter = "password needs to contain one of the following: !, @, #"
ErrInvalidPassword = "invalid password"
)
// This will handle operations that are user related, but one layer higher then the repository
type UserService struct {
repo repositories.IUserTable
}
func NewUserService(conn *sql.DB) UserService {
return UserService{
repo: repositories.NewUserRepository(conn),
}
}
func (us UserService) DoesUserExist(username string) error {
_, err := us.repo.GetByName(username)
if err != nil {
return err
}
return nil
}
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
}
err = bcrypt.CompareHashAndPassword([]byte(model.Hash), []byte(password))
if err != nil {
return errors.New(ErrInvalidPassword)
}
return nil
}
func (us UserService) GetUser(username string) (models.UserModel, error) {
return us.repo.GetByName(username)
}
func (us UserService) CreateNewUser(name, password string) (models.UserModel, error) {
err := us.CheckPasswordForRequirements(password)
if err != nil {
return models.UserModel{}, err
}
us.repo.Create(name, password)
return models.UserModel{}, nil
}
func (us UserService) CheckPasswordForRequirements(password string) error {
err := us.checkPasswordLength(password)
if err != nil {
return err
}
err = us.checkPasswordForSpecialCharacters(password)
if err != nil {
return err
}
return nil
}
func (us UserService) checkPasswordLength(password string) error {
if len(password) < 8 {
return errors.New(ErrPasswordNotLongEnough)
}
return nil
}
func (us UserService) checkPasswordForSpecialCharacters(password string) error {
var chars []string
chars = append(chars, "!")
chars = append(chars, "@")
chars = append(chars, "#")
for _, char := range chars {
if strings.Contains(password, char) {
return nil
}
}
return errors.New(ErrPasswordMissingSpecialCharacter)
}

View File

@ -1,43 +0,0 @@
package services
import (
"database/sql"
"errors"
"go-cook/api/models"
"go-cook/api/repositories"
)
const (
ErrPasswordNotLongEnough = "password needs to be 8 character or longer"
)
// This will handle operations that are user related, but one layer higher then the repository
type UserService struct {
repo repositories.UserRepository
}
func NewUserService(conn *sql.DB) UserService {
return UserService{
repo: repositories.NewUserRepository(conn),
}
}
func (us UserService) DoesUserExist(username string) error {
_, err := us.repo.GetByName(username)
if err != nil {
return err
}
return nil
}
func (us UserService) CreateNewUser(name, password string) (models.UserModel, error) {
us.repo.NewUser(name, password)
return models.UserModel{}, nil
}
func (us UserService) CheckPasswordForRequirements(password string) error {
if len(password) <= 8 {
return errors.New(ErrPasswordNotLongEnough)
}
return nil
}

2
go.mod
View File

@ -21,10 +21,12 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator v9.31.0+incompatible // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
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/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
github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect

4
go.sum
View File

@ -34,6 +34,8 @@ github.com/go-playground/validator v9.31.0+incompatible h1:UA72EPEogEnq76ehGdEDp
github.com/go-playground/validator v9.31.0+incompatible/go.mod h1:yrEkQXlcI+PugkyDjY2bRrL/UBU4f3rvrgkN3V8JEig=
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-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U=
@ -50,6 +52,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
github.com/labstack/echo-jwt/v4 v4.2.0 h1:odSISV9JgcSCuhgQSV/6Io3i7nUmfM/QkBeR5GVJj5c=
github.com/labstack/echo-jwt/v4 v4.2.0/go.mod h1:MA2RqdXdEn4/uEglx0HcUOgQSyBaTh5JcaHIan3biwU=
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=

View File

@ -27,6 +27,7 @@ func main() {
}
e.Pre(middleware.RemoveTrailingSlash())
e.Pre(middleware.Logger())
e.Pre(middleware.Recover())
v1Group := e.Group("/api/v1")
v1Api := v1.NewHandler(db)

View File

@ -1,3 +1,8 @@
###
POST http://localhost:1323/api/v1/register?username=test&password=test1234!
###
POST http://localhost:1323/api/v1/login?username=test&password=test1234!
###
GET http://localhost:1323/api/v1/demo/hello
###
GET http://localhost:1323/api/v1/demo/hello/james
@ -5,5 +10,13 @@ GET http://localhost:1323/api/v1/demo/hello/james
GET http://localhost:1323/api/v1/demo/hello/body
Content-Type: application/json
name = "body"
###
{
"name": "body"
}
###
POST http://localhost:1323/api/v1/login?username=test
###
GET http://localhost:1323/api/v1/demo/protected
Authorization: Bearer