diff --git a/.vscode/launch.json b/.vscode/launch.json index 6deb9a6..7a5ffe9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,7 @@ "type": "go", "request": "launch", "mode": "auto", - "program": "main.go" + "program": "cmd/main.go" } ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index a160986..6524a35 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,7 @@ "echojwt", "glebarez", "gocook", + "godotenv", "huandu", "labstack", "sqlbuilder" diff --git a/Justfile b/Justfile index 264ab7f..ebe4c2f 100644 --- a/Justfile +++ b/Justfile @@ -1,5 +1,7 @@ +# Brings the database up to the current migration migrate-up: - GOOSE_DRIVER=sqlite3 GOOSE_DBSTRING=./gocook.db goose -dir ./api/migrations up + GOOSE_DRIVER=sqlite3 GOOSE_DBSTRING=./cmd/gocook.db goose -dir ./internal/migrations up +# Rolls back one migration at a time migrate-down: - GOOSE_DRIVER=sqlite3 GOOSE_DBSTRING=./gocook.db goose -dir ./api/migrations down \ No newline at end of file + GOOSE_DRIVER=sqlite3 GOOSE_DBSTRING=./cmd/gocook.db goose -dir ./internal/migrations down \ No newline at end of file diff --git a/main.go b/cmd/main.go similarity index 89% rename from main.go rename to cmd/main.go index 63fb65d..2c024a4 100644 --- a/main.go +++ b/cmd/main.go @@ -5,8 +5,8 @@ import ( "log" "net/http" - "git.jamestombleson.com/jtom38/go-cook/api/services" - v1 "git.jamestombleson.com/jtom38/go-cook/api/handlers/v1" + v1 "git.jamestombleson.com/jtom38/go-cook/internal/handlers/v1" + "git.jamestombleson.com/jtom38/go-cook/internal/services" _ "github.com/glebarez/go-sqlite" "github.com/go-playground/validator" @@ -34,7 +34,7 @@ func main() { panic(err) } - err = goose.Up(db, "api/migrations") + err = goose.Up(db, "../internal/migrations") if err != nil { panic(err) } diff --git a/go.mod b/go.mod index 950e205..660df2d 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/glebarez/go-sqlite v1.22.0 github.com/go-playground/validator v9.31.0+incompatible github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 github.com/huandu/go-sqlbuilder v1.25.0 github.com/joho/godotenv v1.5.1 github.com/labstack/echo-jwt/v4 v4.2.0 @@ -22,7 +23,6 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect - github.com/google/uuid v1.6.0 // indirect github.com/huandu/xstrings v1.3.2 // indirect github.com/kr/text v0.2.0 // indirect github.com/labstack/gommon v0.4.2 // indirect diff --git a/api/domain/dto.go b/internal/domain/dto.go similarity index 100% rename from api/domain/dto.go rename to internal/domain/dto.go diff --git a/api/domain/entities.go b/internal/domain/entities.go similarity index 62% rename from api/domain/entities.go rename to internal/domain/entities.go index f26454a..edd2557 100644 --- a/api/domain/entities.go +++ b/internal/domain/entities.go @@ -3,7 +3,7 @@ package domain import "time" type UserEntity struct { - Id int + Id int64 CreatedAt time.Time LastUpdated time.Time Name string @@ -11,8 +11,16 @@ type UserEntity struct { Scopes string } +type RefreshTokenEntity struct { + Id int64 + Username string + Token string + CreatedAt time.Time + LastUpdated time.Time +} + type RecipeEntity struct { - Id int32 + Id int64 CreatedAt time.Time LastUpdated time.Time Title string diff --git a/api/domain/models.go b/internal/domain/models.go similarity index 100% rename from api/domain/models.go rename to internal/domain/models.go diff --git a/api/domain/requests.go b/internal/domain/requests.go similarity index 57% rename from api/domain/requests.go rename to internal/domain/requests.go index 5be5b3b..6d43ce6 100644 --- a/api/domain/requests.go +++ b/internal/domain/requests.go @@ -8,3 +8,9 @@ type UpdateScopesRequest struct { Username string `json:"username"` Scopes []string `json:"scopes" validate:"required"` } + +type RefreshTokenRequest struct { + Username string `json:"username"` + RefreshToken string `json:"refreshToken"` + //ExpiresAt time.Time `json:"expiresAt"` +} diff --git a/api/domain/responses.go b/internal/domain/responses.go similarity index 59% rename from api/domain/responses.go rename to internal/domain/responses.go index dce4b9a..852a776 100644 --- a/api/domain/responses.go +++ b/internal/domain/responses.go @@ -1,19 +1,30 @@ package domain type LoginResponse struct { - Success bool `json:"success"` - Token string `json:"token"` - Type string `json:"type"` + BaseResponse + Token string `json:"token"` + Type string `json:"type"` RefreshToken string `json:"refreshToken"` } +// This / +// /auth/refreshToken +type TokenRefreshResponse struct { + ErrorResponse + +} + +type BaseResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + type ErrorResponse struct { Success bool `json:"success"` Message string `json:"message"` } type HelloWhoResponse struct { - Success bool `json:"success"` + BaseResponse Error string `json:"error"` - Message string `json:"message"` } diff --git a/api/domain/scopes.go b/internal/domain/scopes.go similarity index 100% rename from api/domain/scopes.go rename to internal/domain/scopes.go diff --git a/api/handlers/v1/auth.go b/internal/handlers/v1/auth.go similarity index 68% rename from api/handlers/v1/auth.go rename to internal/handlers/v1/auth.go index f7404dc..4b98d3e 100644 --- a/api/handlers/v1/auth.go +++ b/internal/handlers/v1/auth.go @@ -3,9 +3,10 @@ package v1 import ( "errors" "net/http" + "time" - "git.jamestombleson.com/jtom38/go-cook/api/domain" - "git.jamestombleson.com/jtom38/go-cook/api/repositories" + "git.jamestombleson.com/jtom38/go-cook/internal/domain" + "git.jamestombleson.com/jtom38/go-cook/internal/repositories" "github.com/golang-jwt/jwt/v5" "github.com/labstack/echo/v4" @@ -25,13 +26,12 @@ func (h *Handler) AuthRegister(c echo.Context) error { password := c.FormValue("password") //username := c.QueryParam("username") - exists, err := h.userRepo.GetByName(username) + exists, err := h.users.GetUser(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, domain.ErrorResponse{ - Message: err.Error(), Success: true, }) @@ -42,7 +42,7 @@ func (h *Handler) AuthRegister(c echo.Context) error { } //password := c.QueryParam("password") - err = h.UserService.CheckPasswordForRequirements(password) + err = h.users.CheckPasswordForRequirements(password) if err != nil { return c.JSON(http.StatusInternalServerError, domain.ErrorResponse{ Success: false, @@ -50,7 +50,7 @@ func (h *Handler) AuthRegister(c echo.Context) error { }) } - _, err = h.userRepo.Create(username, password, domain.ScopeRecipeRead) + _, err = h.users.Create(username, password, domain.ScopeRecipeRead) if err != nil { return c.JSON(http.StatusInternalServerError, domain.ErrorResponse{ Success: false, @@ -73,27 +73,38 @@ func (h *Handler) AuthLogin(c echo.Context) error { } // check if the user exists - err := h.UserService.DoesUserExist(username) + err := h.users.DoesUserExist(username) if err != nil { return h.InternalServerErrorResponse(c, err.Error()) } // make sure the hash matches - err = h.UserService.DoesPasswordMatchHash(username, password) + err = h.users.DoesPasswordMatchHash(username, password) if err != nil { return h.InternalServerErrorResponse(c, err.Error()) } - token, err := h.generateJwt(username, h.Config.ApiUri) + // TODO think about moving this down some? + expiresAt := time.Now().Add(time.Hour * 48) + + jwt, err := h.generateJwtWithExp(username, h.Config.ApiUri, expiresAt) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + refresh, err := h.refreshTokens.Create(username) if err != nil { return h.InternalServerErrorResponse(c, err.Error()) } return c.JSON(http.StatusOK, domain.LoginResponse{ - Success: true, - Token: token, + BaseResponse: domain.BaseResponse{ + Success: true, + Message: "OK", + }, + Token: jwt, Type: "Bearer", - RefreshToken: "", + RefreshToken: refresh, }) } @@ -108,7 +119,7 @@ func (h *Handler) validateAdminToken(c echo.Context, password string) error { return h.ReturnUnauthorizedResponse(c, ErrUserNotFound) } - token, err := h.generateAdminJwt("admin") + token, err := h.generateJwt("admin", h.Config.ApiUri) if err != nil { return h.InternalServerErrorResponse(c, err.Error()) } @@ -116,6 +127,41 @@ func (h *Handler) validateAdminToken(c echo.Context, password string) error { return c.JSON(http.StatusOK, token) } +// This will take collect some information about the requested refresh, validate and then return a new jwt token if approved. +func (h *Handler) RefreshJwtToken(c echo.Context) error { + // Check the context for the refresh token + var request domain.RefreshTokenRequest + err := (&echo.DefaultBinder{}).BindBody(c, &request) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + err = h.refreshTokens.IsRequestValid(request.Username, request.RefreshToken) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + jwt, err := h.generateJwtWithExp(request.Username, h.Config.ApiUri, time.Now().Add(time.Hour * 48)) + if err!= nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + newRefreshToken, err := h.refreshTokens.Create(request.Username) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + return c.JSON(http.StatusOK, domain.LoginResponse{ + BaseResponse: domain.BaseResponse{ + Success: true, + Message: "OK", + }, + Token: jwt, + Type: "Bearer", + RefreshToken: newRefreshToken, + }) +} + func (h *Handler) AddScopes(c echo.Context) error { token, err := h.getJwtToken(c) if err != nil { @@ -136,7 +182,7 @@ func (h *Handler) AddScopes(c echo.Context) error { }) } - err = h.UserService.AddScopes(request.Username, request.Scopes) + err = h.users.AddScopes(request.Username, request.Scopes) if err != nil { return h.InternalServerErrorResponse(c, err.Error()) } @@ -166,7 +212,7 @@ func (h *Handler) RemoveScopes(c echo.Context) error { }) } - err = h.UserService.RemoveScopes(request.Username, request.Scopes) + err = h.users.RemoveScopes(request.Username, request.Scopes) if err != nil { return h.InternalServerErrorResponse(c, err.Error()) } @@ -176,10 +222,6 @@ func (h *Handler) RemoveScopes(c echo.Context) error { }) } -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) diff --git a/api/handlers/v1/auth_test.go b/internal/handlers/v1/auth_test.go similarity index 94% rename from api/handlers/v1/auth_test.go rename to internal/handlers/v1/auth_test.go index 800127d..f564533 100644 --- a/api/handlers/v1/auth_test.go +++ b/internal/handlers/v1/auth_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - v1 "git.jamestombleson.com/jtom38/go-cook/api/handlers/v1" - "git.jamestombleson.com/jtom38/go-cook/api/domain" + v1 "git.jamestombleson.com/jtom38/go-cook/internal/handlers/v1" + "git.jamestombleson.com/jtom38/go-cook/internal/domain" _ "github.com/glebarez/go-sqlite" "github.com/labstack/echo/v4" diff --git a/api/handlers/v1/demo.go b/internal/handlers/v1/demo.go similarity index 67% rename from api/handlers/v1/demo.go rename to internal/handlers/v1/demo.go index 9c888d3..82997bd 100644 --- a/api/handlers/v1/demo.go +++ b/internal/handlers/v1/demo.go @@ -4,23 +4,27 @@ import ( "fmt" "net/http" - "git.jamestombleson.com/jtom38/go-cook/api/domain" + "git.jamestombleson.com/jtom38/go-cook/internal/domain" "github.com/labstack/echo/v4" ) func (h *Handler) DemoHello(c echo.Context) error { return c.JSON(http.StatusOK, domain.HelloWhoResponse{ - Success: true, - Message: "Hello world!", + BaseResponse: domain.BaseResponse{ + Success: true, + Message: "Hello world!", + }, }) } func (h *Handler) HelloWho(c echo.Context) error { name := c.Param("who") return c.JSON(http.StatusOK, domain.HelloWhoResponse{ - Success: true, - Message: fmt.Sprintf("Hello, %s", name), + BaseResponse: domain.BaseResponse{ + Success: true, + Message: fmt.Sprintf("Hello, %s", name), + }, }) } @@ -28,15 +32,14 @@ func (h *Handler) HelloBody(c echo.Context) error { request := domain.HelloBodyRequest{} err := (&echo.DefaultBinder{}).BindBody(c, &request) if err != nil { - return c.JSON(http.StatusBadRequest, domain.HelloWhoResponse{ - Success: false, - Error: err.Error(), - }) + return h.InternalServerErrorResponse(c, err.Error()) } return c.JSON(http.StatusOK, domain.HelloWhoResponse{ - Success: true, - Message: fmt.Sprintf("Hello, %s", request.Name), + BaseResponse: domain.BaseResponse{ + Success: true, + Message: fmt.Sprintf("Hello, %s", request.Name), + }, }) } diff --git a/api/handlers/v1/handler.go b/internal/handlers/v1/handler.go similarity index 78% rename from api/handlers/v1/handler.go rename to internal/handlers/v1/handler.go index 4428740..567b7ce 100644 --- a/api/handlers/v1/handler.go +++ b/internal/handlers/v1/handler.go @@ -4,9 +4,8 @@ import ( "database/sql" "net/http" - "git.jamestombleson.com/jtom38/go-cook/api/repositories" - "git.jamestombleson.com/jtom38/go-cook/api/services" - "git.jamestombleson.com/jtom38/go-cook/api/domain" + "git.jamestombleson.com/jtom38/go-cook/internal/domain" + "git.jamestombleson.com/jtom38/go-cook/internal/services" "github.com/golang-jwt/jwt/v5" echojwt "github.com/labstack/echo-jwt/v4" @@ -16,17 +15,18 @@ import ( type Handler struct { Config domain.EnvConfig - UserService services.UserService - userRepo repositories.IUserTable - recipeRepo repositories.IRecipeTable + users services.UserService + recipes services.Recipes + refreshTokens services.RefreshToken } 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), + Config: cfg, + + users: services.NewUserService(conn), + recipes: services.NewRecipesService(conn), + refreshTokens: services.NewRefreshTokenService(conn), } } @@ -44,6 +44,7 @@ func (h *Handler) Register(v1 *echo.Group) { auth.Use(echojwt.WithConfig(jwtConfig)) auth.POST("/scopes/add", h.AddScopes) auth.POST("/scopes/remove", h.RemoveScopes) + auth.POST("/refreshToken", h.RefreshJwtToken) demo := v1.Group("/demo") demo.GET("/hello", h.DemoHello) diff --git a/api/handlers/v1/jwt.go b/internal/handlers/v1/jwt.go similarity index 68% rename from api/handlers/v1/jwt.go rename to internal/handlers/v1/jwt.go index 0fc2f7d..d1cd668 100644 --- a/api/handlers/v1/jwt.go +++ b/internal/handlers/v1/jwt.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "git.jamestombleson.com/jtom38/go-cook/api/domain" + "git.jamestombleson.com/jtom38/go-cook/internal/domain" "github.com/golang-jwt/jwt/v5" ) @@ -57,40 +57,28 @@ func (j JwtToken) hasScope(scope string) error { } func (h *Handler) generateJwt(username, issuer string) (string, error) { + return h.generateJwtWithExp(username, issuer, time.Now().Add(10 * time.Minute)) +} + +func (h *Handler) generateJwtWithExp(username, issuer string, expiresAt time.Time) (string, error) { secret := []byte(h.Config.JwtSecret) // Anyone who wants to decrypt the key needs to use the same method token := jwt.New(jwt.SigningMethodHS256) claims := token.Claims.(jwt.MapClaims) - claims["exp"] = time.Now().Add(10 * time.Minute) + claims["exp"] = expiresAt claims["authorized"] = true claims["username"] = username claims["iss"] = issuer 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 + if (username == "admin") { + scopes = append(scopes, domain.ScopeAll) + claims["scopes"] = scopes + } else { + scopes = append(scopes, domain.ScopeRecipeRead) + claims["scopes"] = scopes + } tokenString, err := token.SignedString(secret) if err != nil { diff --git a/api/handlers/v1/users.go b/internal/handlers/v1/users.go similarity index 100% rename from api/handlers/v1/users.go rename to internal/handlers/v1/users.go diff --git a/api/migrations/20240321194227_user_table.sql b/internal/migrations/20240321194227_user_table.sql similarity index 100% rename from api/migrations/20240321194227_user_table.sql rename to internal/migrations/20240321194227_user_table.sql diff --git a/api/migrations/20240329211828_user_scopes.sql b/internal/migrations/20240329211828_user_scopes.sql similarity index 100% rename from api/migrations/20240329211828_user_scopes.sql rename to internal/migrations/20240329211828_user_scopes.sql diff --git a/internal/migrations/20240416180636_refreshtoken.sql b/internal/migrations/20240416180636_refreshtoken.sql new file mode 100644 index 0000000..ce13bff --- /dev/null +++ b/internal/migrations/20240416180636_refreshtoken.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +SELECT 'up SQL query'; +CREATE TABLE RefreshTokens ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + Username TEXT NOT NULL, + Token TEXT NOT NULL, + CreatedAt DATETIME NOT NULL, + LastUpdated DATETIME NOT NULL +) +-- +goose StatementEnd +-- +goose Down +-- +goose StatementBegin +SELECT 'down SQL query'; +DROP TABLE IF EXISTS RefreshTokens; +-- +goose StatementEnd \ No newline at end of file diff --git a/api/repositories/recipe.go b/internal/repositories/recipe.go similarity index 94% rename from api/repositories/recipe.go rename to internal/repositories/recipe.go index b510c07..ab8c0b4 100644 --- a/api/repositories/recipe.go +++ b/internal/repositories/recipe.go @@ -4,7 +4,7 @@ import ( "database/sql" "errors" - "git.jamestombleson.com/jtom38/go-cook/api/domain" + "git.jamestombleson.com/jtom38/go-cook/internal/domain" ) type IRecipeTable interface { diff --git a/internal/repositories/refreshTokens.go b/internal/repositories/refreshTokens.go new file mode 100644 index 0000000..9e8889b --- /dev/null +++ b/internal/repositories/refreshTokens.go @@ -0,0 +1,112 @@ +package repositories + +import ( + "database/sql" + "errors" + "fmt" + "time" + + "git.jamestombleson.com/jtom38/go-cook/internal/domain" + "github.com/huandu/go-sqlbuilder" +) + +const ( + refreshTokenTableName = "RefreshTokens" +) + +type RefreshTokenTable interface { + Create(username string, token string) (int64, error) + GetByUsername(name string) (domain.RefreshTokenEntity, error) + DeleteById(id int64) (int64, error) +} + +type RefreshTokenRepository struct { + connection *sql.DB +} + +func NewRefreshTokenRepository(conn *sql.DB) RefreshTokenRepository { + return RefreshTokenRepository{ + connection: conn, + } +} + +func (rt RefreshTokenRepository) Create(username string, token string) (int64, error) { + dt := time.Now() + builder := sqlbuilder.NewInsertBuilder() + builder.InsertInto(refreshTokenTableName) + builder.Cols("Username", "Token", "CreatedAt", "LastUpdated") + builder.Values(username, token, dt, dt) + query, args := builder.Build() + + _, err := rt.connection.Exec(query, args...) + if err != nil { + return 0, err + } + + return 1, nil +} + +func (rt RefreshTokenRepository) GetByUsername(name string) (domain.RefreshTokenEntity, error) { + builder := sqlbuilder.NewSelectBuilder() + builder.Select("*").From(refreshTokenTableName).Where( + builder.E("Username", name), + ) + + query, args := builder.Build() + rows, err := rt.connection.Query(query, args...) + if err != nil { + return domain.RefreshTokenEntity{}, err + } + + data := rt.processRows(rows) + if len(data) == 0 { + return domain.RefreshTokenEntity{}, errors.New("no token found for user") + } + + return data[0], nil +} + +func (rt RefreshTokenRepository) DeleteById(id int64) (int64, error) { + builder := sqlbuilder.NewDeleteBuilder() + builder.DeleteFrom(refreshTokenTableName) + builder.Where( + builder.EQ("Id", id), + ) + + query, args := builder.Build() + rows, err := rt.connection.Exec(query, args...) + if err != nil { + return -1, err + } + + return rows.RowsAffected() +} + +func (rd RefreshTokenRepository) processRows(rows *sql.Rows) []domain.RefreshTokenEntity { + items := []domain.RefreshTokenEntity{} + + for rows.Next() { + var id int64 + var username string + var token string + var createdAt time.Time + var lastUpdated time.Time + + err := rows.Scan(&id, &username, &token, &createdAt, &lastUpdated) + if err != nil { + fmt.Println(err) + } + + items = append(items, domain.RefreshTokenEntity{ + Id: id, + Username: username, + Token: token, + CreatedAt: createdAt, + LastUpdated: lastUpdated, + }) + } + + return items +} + +//func (rt RefreshTokenRepository) Delete() diff --git a/internal/repositories/refreshTokens_test.go b/internal/repositories/refreshTokens_test.go new file mode 100644 index 0000000..bd3a98a --- /dev/null +++ b/internal/repositories/refreshTokens_test.go @@ -0,0 +1,110 @@ +package repositories_test + +import ( + "database/sql" + "testing" + + "git.jamestombleson.com/jtom38/go-cook/internal/repositories" + _ "github.com/glebarez/go-sqlite" + "github.com/pressly/goose/v3" +) + +func TestRefreshTokenCreate(t *testing.T) { + conn, err := setupInMemoryDb() + if err != nil { + t.Log(err) + t.FailNow() + } + + client := repositories.NewRefreshTokenRepository(conn) + rows, err := client.Create("tester", "BadTokenDontUse") + if err != nil { + t.Log(err) + t.FailNow() + } + + if rows == 0 { + t.Log("expected one row to come back but got 0") + } +} + +func TestRefreshTokenGetByUsername(t *testing.T) { + conn, err := setupInMemoryDb() + if err != nil { + t.Log(err) + t.FailNow() + } + + client := repositories.NewRefreshTokenRepository(conn) + rows, err := client.Create("tester", "BadTokenDoNotUse") + if err != nil { + t.Log(err) + t.FailNow() + } + + if rows != 1 { + t.Log("expected a row to be added but not the wrong value back") + t.FailNow() + } + + model, err := client.GetByUsername("tester") + if err != nil { + t.Log(err) + t.FailNow() + } + + if model.Username != "tester" { + t.Log("got the wrong user back") + t.FailNow() + } +} + +func TestRefreshTokenDeleteById(t *testing.T) { + conn, err := setupInMemoryDb() + if err != nil { + t.Log(err) + t.FailNow() + } + + client := repositories.NewRefreshTokenRepository(conn) + _, err = client.Create("tester", "BadTokenDoNotUse") + if err != nil { + t.Log(err) + t.FailNow() + } + + model, err := client.GetByUsername("tester") + if err != nil { + t.Log(err) + t.FailNow() + } + + updated, err := client.DeleteById(model.Id) + if err != nil { + t.Log(err) + t.FailNow() + } + + if updated != 1 { + t.Log("deleted the wrong number of records") + t.FailNow() + } +} + +func setupInMemoryDb() (*sql.DB, error) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + return nil, err + } + + err = goose.SetDialect("sqlite3") + if err != nil { + return nil, err + } + + err = goose.Up(db, "../migrations") + if err != nil { + return nil, err + } + return db, nil +} diff --git a/api/repositories/users.go b/internal/repositories/users.go similarity index 98% rename from api/repositories/users.go rename to internal/repositories/users.go index 78cd5cf..39bc13b 100644 --- a/api/repositories/users.go +++ b/internal/repositories/users.go @@ -6,7 +6,7 @@ import ( "fmt" "time" - "git.jamestombleson.com/jtom38/go-cook/api/domain" + "git.jamestombleson.com/jtom38/go-cook/internal/domain" "github.com/huandu/go-sqlbuilder" "golang.org/x/crypto/bcrypt" @@ -133,7 +133,7 @@ func (ur UserRepository) processRows(rows *sql.Rows) []domain.UserEntity { items := []domain.UserEntity{} for rows.Next() { - var id int + var id int64 var name string var hash string var createdAt time.Time diff --git a/api/repositories/users_test.go b/internal/repositories/users_test.go similarity index 90% rename from api/repositories/users_test.go rename to internal/repositories/users_test.go index 4d62136..32c7a30 100644 --- a/api/repositories/users_test.go +++ b/internal/repositories/users_test.go @@ -5,8 +5,8 @@ import ( "log" "testing" - "git.jamestombleson.com/jtom38/go-cook/api/repositories" - "git.jamestombleson.com/jtom38/go-cook/api/domain" + "git.jamestombleson.com/jtom38/go-cook/internal/repositories" + "git.jamestombleson.com/jtom38/go-cook/internal/domain" "github.com/DATA-DOG/go-sqlmock" _ "github.com/glebarez/go-sqlite" diff --git a/api/services/env.go b/internal/services/env.go similarity index 90% rename from api/services/env.go rename to internal/services/env.go index 3ea6d90..3c1f73d 100644 --- a/api/services/env.go +++ b/internal/services/env.go @@ -5,7 +5,7 @@ import ( "os" "strconv" - "git.jamestombleson.com/jtom38/go-cook/api/domain" + "git.jamestombleson.com/jtom38/go-cook/internal/domain" "github.com/joho/godotenv" ) diff --git a/internal/services/refreshTokenService.go b/internal/services/refreshTokenService.go new file mode 100644 index 0000000..aa1f457 --- /dev/null +++ b/internal/services/refreshTokenService.go @@ -0,0 +1,86 @@ +package services + +import ( + "database/sql" + "errors" + + "git.jamestombleson.com/jtom38/go-cook/internal/domain" + "git.jamestombleson.com/jtom38/go-cook/internal/repositories" + "github.com/google/uuid" +) + +const ( + ErrUnexpectedAmountOfRowsUpdated = "got a unexpected of rows updated" +) + +type RefreshToken interface { + Create(username string) (string, error) + GetByName(name string) (domain.RefreshTokenEntity, error) + Delete(id int64) (int64, error) + IsRequestValid(username, refreshToken string) error +} + +// A new jwt token can be made if the user has the correct refresh token for the user. +// It will also require the old JWT token so the expire time is pulled and part of the validation +type RefreshTokenService struct { + table repositories.RefreshTokenTable +} + +func NewRefreshTokenService(conn *sql.DB) RefreshTokenService { + return RefreshTokenService{ + table: repositories.NewRefreshTokenRepository(conn), + } +} + +func (rt RefreshTokenService) Create(username string) (string, error) { + //if a refresh token already exists for a user, reuse + existingToken, err := rt.GetByName(username) + if err == nil { + rowsRemoved, err := rt.Delete(existingToken.Id) + if err != nil { + return "", err + } + + if rowsRemoved != 1 { + return "", errors.New(ErrUnexpectedAmountOfRowsUpdated) + } + } + + token, err := uuid.NewV7() + if err != nil { + return "", err + } + + rows, err := rt.table.Create(username, token.String()) + if err != nil { + return "", err + } + + if rows != 1 { + return "", errors.New("expected one row but got none") + } + return token.String(), nil +} + +// Find the saved refresh token for a user and return it if it exists +func (rt RefreshTokenService) GetByName(name string) (domain.RefreshTokenEntity, error) { + return rt.table.GetByUsername(name) +} + +// This will request that a object is removed from the database +func (rt RefreshTokenService) Delete(id int64) (int64, error) { + return rt.table.DeleteById(id) +} + +func (rt RefreshTokenService) IsRequestValid(username, refreshToken string) error { + token, err := rt.GetByName(username) + if err != nil { + return err + } + + if token.Token != refreshToken { + return errors.New("the refresh token given does not match") + } + + return nil +} diff --git a/api/services/userService.go b/internal/services/userService.go similarity index 84% rename from api/services/userService.go rename to internal/services/userService.go index 48f0948..ec626e5 100644 --- a/api/services/userService.go +++ b/internal/services/userService.go @@ -5,8 +5,8 @@ import ( "errors" "strings" - "git.jamestombleson.com/jtom38/go-cook/api/domain" - "git.jamestombleson.com/jtom38/go-cook/api/repositories" + "git.jamestombleson.com/jtom38/go-cook/internal/domain" + "git.jamestombleson.com/jtom38/go-cook/internal/repositories" "golang.org/x/crypto/bcrypt" ) @@ -17,6 +17,16 @@ const ( ErrInvalidPassword = "invalid password" ) +type Users interface { + DoesUserExist(username string) error + DoesPasswordMatchHash(username, password string) error + GetUser(username string) (domain.UserEntity, error) + AddScopes(username string, scopes []string) error + RemoveScopes(username string, scopes []string) error + Create(name, password, scope string) (domain.UserEntity, error) + CheckPasswordForRequirements(password string) error +} + // This will handle operations that are user related, but one layer higher then the repository type UserService struct { repo repositories.IUserTable @@ -111,7 +121,7 @@ func (us UserService) doesScopeExist(scopes []string, target string) bool { return false } -func (us UserService) CreateNewUser(name, password, scope string) (domain.UserEntity, error) { +func (us UserService) Create(name, password, scope string) (domain.UserEntity, error) { err := us.CheckPasswordForRequirements(password) if err != nil { return domain.UserEntity{}, err diff --git a/api/services/userService_test.go b/internal/services/userService_test.go similarity index 93% rename from api/services/userService_test.go rename to internal/services/userService_test.go index 5be3811..38106c9 100644 --- a/api/services/userService_test.go +++ b/internal/services/userService_test.go @@ -3,7 +3,7 @@ package services_test import ( "testing" - "git.jamestombleson.com/jtom38/go-cook/api/services" + "git.jamestombleson.com/jtom38/go-cook/internal/services" "github.com/DATA-DOG/go-sqlmock" ) diff --git a/rest.http b/rest.http index bdc77a3..26416c5 100644 --- a/rest.http +++ b/rest.http @@ -16,6 +16,15 @@ Content-Type: application/x-www-form-urlencoded password=lol +### Try to refresh the token +POST http://localhost:1323/api/v1/auth/refreshToken +Content-Type: application/json +Authorization: Bearer + +{ + "username": "test", + "refreshToken": "" +} ### Add Scope to test user POST http://localhost:1323/api/v1/auth/scopes/add