Compare commits

...

16 Commits

Author SHA1 Message Date
13d6e33b72 Merge pull request 'Refresh Token Support and package refactor based on best practice docs' (#18) from features/restructure-go-recommendations into main
Reviewed-on: #18
2024-04-21 10:30:51 -07:00
7d6e7d46cd small helper file updates 2024-04-21 10:29:46 -07:00
26820d80f7 uuid was added 2024-04-21 10:29:24 -07:00
9627c51e7a minor changes to requests and responses 2024-04-21 10:29:17 -07:00
be337b4bb9 auth handler now can work with refresh token request 2024-04-21 10:28:50 -07:00
8482afed8d small improvement to creating a jwt logic 2024-04-21 10:28:21 -07:00
e7f706b6e7 The refreshtoken no longer cares about when a token expired. When they want a new jtw from refresh a new refresh token is made also 2024-04-21 10:27:56 -07:00
b02ed3f86a Moved handler to use interfaces and added the refreshToken endpoint 2024-04-21 08:59:51 -07:00
0383d554b8 added RefreshTokenRequest as a body 2024-04-21 08:59:25 -07:00
e218da5e7d Updated some responses to return the base struct with embedding 2024-04-21 08:59:07 -07:00
5b2ff607b9 Moved entity id's to int64 that the sql driver returns 2024-04-21 08:58:32 -07:00
029710ad31 updated pathing in main and updated how to find migrations 2024-04-21 08:58:06 -07:00
8d8781eac4 added a interface for userService 2024-04-21 08:57:32 -07:00
f5eea08c1e updated how refresh token service and repo work 2024-04-21 08:57:10 -07:00
439c8ed733 moved main.go to cmd folder per best practice 2024-04-20 16:45:57 -07:00
a2e740eefd moved away from the api directory to internal per go doc recommendations 2024-04-20 08:09:24 -07:00
30 changed files with 496 additions and 91 deletions

2
.vscode/launch.json vendored
View File

@ -9,7 +9,7 @@
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"program": "main.go" "program": "cmd/main.go"
} }
] ]
} }

View File

@ -3,6 +3,7 @@
"echojwt", "echojwt",
"glebarez", "glebarez",
"gocook", "gocook",
"godotenv",
"huandu", "huandu",
"labstack", "labstack",
"sqlbuilder" "sqlbuilder"

View File

@ -1,5 +1,7 @@
# Brings the database up to the current migration
migrate-up: 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: migrate-down:
GOOSE_DRIVER=sqlite3 GOOSE_DBSTRING=./gocook.db goose -dir ./api/migrations down GOOSE_DRIVER=sqlite3 GOOSE_DBSTRING=./cmd/gocook.db goose -dir ./internal/migrations down

View File

@ -5,8 +5,8 @@ import (
"log" "log"
"net/http" "net/http"
"git.jamestombleson.com/jtom38/go-cook/api/services" v1 "git.jamestombleson.com/jtom38/go-cook/internal/handlers/v1"
v1 "git.jamestombleson.com/jtom38/go-cook/api/handlers/v1" "git.jamestombleson.com/jtom38/go-cook/internal/services"
_ "github.com/glebarez/go-sqlite" _ "github.com/glebarez/go-sqlite"
"github.com/go-playground/validator" "github.com/go-playground/validator"
@ -34,7 +34,7 @@ func main() {
panic(err) panic(err)
} }
err = goose.Up(db, "api/migrations") err = goose.Up(db, "../internal/migrations")
if err != nil { if err != nil {
panic(err) panic(err)
} }

2
go.mod
View File

@ -7,6 +7,7 @@ require (
github.com/glebarez/go-sqlite v1.22.0 github.com/glebarez/go-sqlite v1.22.0
github.com/go-playground/validator v9.31.0+incompatible github.com/go-playground/validator v9.31.0+incompatible
github.com/golang-jwt/jwt/v5 v5.2.1 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/huandu/go-sqlbuilder v1.25.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/labstack/echo-jwt/v4 v4.2.0 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/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // 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/huandu/xstrings v1.3.2 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect

View File

@ -3,7 +3,7 @@ package domain
import "time" import "time"
type UserEntity struct { type UserEntity struct {
Id int Id int64
CreatedAt time.Time CreatedAt time.Time
LastUpdated time.Time LastUpdated time.Time
Name string Name string
@ -11,8 +11,16 @@ type UserEntity struct {
Scopes string Scopes string
} }
type RefreshTokenEntity struct {
Id int64
Username string
Token string
CreatedAt time.Time
LastUpdated time.Time
}
type RecipeEntity struct { type RecipeEntity struct {
Id int32 Id int64
CreatedAt time.Time CreatedAt time.Time
LastUpdated time.Time LastUpdated time.Time
Title string Title string

View File

@ -8,3 +8,9 @@ type UpdateScopesRequest struct {
Username string `json:"username"` Username string `json:"username"`
Scopes []string `json:"scopes" validate:"required"` Scopes []string `json:"scopes" validate:"required"`
} }
type RefreshTokenRequest struct {
Username string `json:"username"`
RefreshToken string `json:"refreshToken"`
//ExpiresAt time.Time `json:"expiresAt"`
}

View File

@ -1,19 +1,30 @@
package domain package domain
type LoginResponse struct { type LoginResponse struct {
Success bool `json:"success"` BaseResponse
Token string `json:"token"` Token string `json:"token"`
Type string `json:"type"` Type string `json:"type"`
RefreshToken string `json:"refreshToken"` 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 { type ErrorResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Message string `json:"message"` Message string `json:"message"`
} }
type HelloWhoResponse struct { type HelloWhoResponse struct {
Success bool `json:"success"` BaseResponse
Error string `json:"error"` Error string `json:"error"`
Message string `json:"message"`
} }

View File

@ -3,9 +3,10 @@ package v1
import ( import (
"errors" "errors"
"net/http" "net/http"
"time"
"git.jamestombleson.com/jtom38/go-cook/api/domain" "git.jamestombleson.com/jtom38/go-cook/internal/domain"
"git.jamestombleson.com/jtom38/go-cook/api/repositories" "git.jamestombleson.com/jtom38/go-cook/internal/repositories"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
@ -25,13 +26,12 @@ func (h *Handler) AuthRegister(c echo.Context) error {
password := c.FormValue("password") password := c.FormValue("password")
//username := c.QueryParam("username") //username := c.QueryParam("username")
exists, err := h.userRepo.GetByName(username) exists, err := h.users.GetUser(username)
if err != nil { if err != nil {
// if we have an err, validate that if its not user not found. // 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 the user is not found, we can use that name
if err.Error() != repositories.ErrUserNotFound { if err.Error() != repositories.ErrUserNotFound {
return c.JSON(http.StatusInternalServerError, domain.ErrorResponse{ return c.JSON(http.StatusInternalServerError, domain.ErrorResponse{
Message: err.Error(), Message: err.Error(),
Success: true, Success: true,
}) })
@ -42,7 +42,7 @@ func (h *Handler) AuthRegister(c echo.Context) error {
} }
//password := c.QueryParam("password") //password := c.QueryParam("password")
err = h.UserService.CheckPasswordForRequirements(password) err = h.users.CheckPasswordForRequirements(password)
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, domain.ErrorResponse{ return c.JSON(http.StatusInternalServerError, domain.ErrorResponse{
Success: false, 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 { if err != nil {
return c.JSON(http.StatusInternalServerError, domain.ErrorResponse{ return c.JSON(http.StatusInternalServerError, domain.ErrorResponse{
Success: false, Success: false,
@ -73,27 +73,38 @@ func (h *Handler) AuthLogin(c echo.Context) error {
} }
// check if the user exists // check if the user exists
err := h.UserService.DoesUserExist(username) err := h.users.DoesUserExist(username)
if err != nil { if err != nil {
return h.InternalServerErrorResponse(c, err.Error()) return h.InternalServerErrorResponse(c, err.Error())
} }
// make sure the hash matches // make sure the hash matches
err = h.UserService.DoesPasswordMatchHash(username, password) err = h.users.DoesPasswordMatchHash(username, password)
if err != nil { if err != nil {
return h.InternalServerErrorResponse(c, err.Error()) 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 { if err != nil {
return h.InternalServerErrorResponse(c, err.Error()) return h.InternalServerErrorResponse(c, err.Error())
} }
return c.JSON(http.StatusOK, domain.LoginResponse{ return c.JSON(http.StatusOK, domain.LoginResponse{
BaseResponse: domain.BaseResponse{
Success: true, Success: true,
Token: token, Message: "OK",
},
Token: jwt,
Type: "Bearer", Type: "Bearer",
RefreshToken: "", RefreshToken: refresh,
}) })
} }
@ -108,7 +119,7 @@ func (h *Handler) validateAdminToken(c echo.Context, password string) error {
return h.ReturnUnauthorizedResponse(c, ErrUserNotFound) return h.ReturnUnauthorizedResponse(c, ErrUserNotFound)
} }
token, err := h.generateAdminJwt("admin") token, err := h.generateJwt("admin", h.Config.ApiUri)
if err != nil { if err != nil {
return h.InternalServerErrorResponse(c, err.Error()) 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) 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 { func (h *Handler) AddScopes(c echo.Context) error {
token, err := h.getJwtToken(c) token, err := h.getJwtToken(c)
if err != nil { 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 { if err != nil {
return h.InternalServerErrorResponse(c, err.Error()) 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 { if err != nil {
return h.InternalServerErrorResponse(c, err.Error()) 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) { func (h *Handler) getJwtToken(c echo.Context) (JwtToken, error) {
// Make sure that the request came with a jwtToken // Make sure that the request came with a jwtToken
token, ok := c.Get("user").(*jwt.Token) token, ok := c.Get("user").(*jwt.Token)

View File

@ -8,8 +8,8 @@ import (
"strings" "strings"
"testing" "testing"
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/api/domain" "git.jamestombleson.com/jtom38/go-cook/internal/domain"
_ "github.com/glebarez/go-sqlite" _ "github.com/glebarez/go-sqlite"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"

View File

@ -4,23 +4,27 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"git.jamestombleson.com/jtom38/go-cook/api/domain" "git.jamestombleson.com/jtom38/go-cook/internal/domain"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
func (h *Handler) DemoHello(c echo.Context) error { func (h *Handler) DemoHello(c echo.Context) error {
return c.JSON(http.StatusOK, domain.HelloWhoResponse{ return c.JSON(http.StatusOK, domain.HelloWhoResponse{
BaseResponse: domain.BaseResponse{
Success: true, Success: true,
Message: "Hello world!", Message: "Hello world!",
},
}) })
} }
func (h *Handler) HelloWho(c echo.Context) error { func (h *Handler) HelloWho(c echo.Context) error {
name := c.Param("who") name := c.Param("who")
return c.JSON(http.StatusOK, domain.HelloWhoResponse{ return c.JSON(http.StatusOK, domain.HelloWhoResponse{
BaseResponse: domain.BaseResponse{
Success: true, Success: true,
Message: fmt.Sprintf("Hello, %s", name), Message: fmt.Sprintf("Hello, %s", name),
},
}) })
} }
@ -28,15 +32,14 @@ func (h *Handler) HelloBody(c echo.Context) error {
request := domain.HelloBodyRequest{} request := domain.HelloBodyRequest{}
err := (&echo.DefaultBinder{}).BindBody(c, &request) err := (&echo.DefaultBinder{}).BindBody(c, &request)
if err != nil { if err != nil {
return c.JSON(http.StatusBadRequest, domain.HelloWhoResponse{ return h.InternalServerErrorResponse(c, err.Error())
Success: false,
Error: err.Error(),
})
} }
return c.JSON(http.StatusOK, domain.HelloWhoResponse{ return c.JSON(http.StatusOK, domain.HelloWhoResponse{
BaseResponse: domain.BaseResponse{
Success: true, Success: true,
Message: fmt.Sprintf("Hello, %s", request.Name), Message: fmt.Sprintf("Hello, %s", request.Name),
},
}) })
} }

View File

@ -4,9 +4,8 @@ import (
"database/sql" "database/sql"
"net/http" "net/http"
"git.jamestombleson.com/jtom38/go-cook/api/repositories" "git.jamestombleson.com/jtom38/go-cook/internal/domain"
"git.jamestombleson.com/jtom38/go-cook/api/services" "git.jamestombleson.com/jtom38/go-cook/internal/services"
"git.jamestombleson.com/jtom38/go-cook/api/domain"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
echojwt "github.com/labstack/echo-jwt/v4" echojwt "github.com/labstack/echo-jwt/v4"
@ -16,17 +15,18 @@ import (
type Handler struct { type Handler struct {
Config domain.EnvConfig Config domain.EnvConfig
UserService services.UserService users services.UserService
userRepo repositories.IUserTable recipes services.Recipes
recipeRepo repositories.IRecipeTable refreshTokens services.RefreshToken
} }
func NewHandler(conn *sql.DB, cfg domain.EnvConfig) *Handler { func NewHandler(conn *sql.DB, cfg domain.EnvConfig) *Handler {
return &Handler{ return &Handler{
Config: cfg, Config: cfg,
UserService: services.NewUserService(conn),
userRepo: repositories.NewUserRepository(conn), users: services.NewUserService(conn),
recipeRepo: repositories.NewRecipeRepository(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.Use(echojwt.WithConfig(jwtConfig))
auth.POST("/scopes/add", h.AddScopes) auth.POST("/scopes/add", h.AddScopes)
auth.POST("/scopes/remove", h.RemoveScopes) auth.POST("/scopes/remove", h.RemoveScopes)
auth.POST("/refreshToken", h.RefreshJwtToken)
demo := v1.Group("/demo") demo := v1.Group("/demo")
demo.GET("/hello", h.DemoHello) demo.GET("/hello", h.DemoHello)

View File

@ -5,7 +5,7 @@ import (
"strings" "strings"
"time" "time"
"git.jamestombleson.com/jtom38/go-cook/api/domain" "git.jamestombleson.com/jtom38/go-cook/internal/domain"
"github.com/golang-jwt/jwt/v5" "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) { 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) secret := []byte(h.Config.JwtSecret)
// Anyone who wants to decrypt the key needs to use the same method // Anyone who wants to decrypt the key needs to use the same method
token := jwt.New(jwt.SigningMethodHS256) token := jwt.New(jwt.SigningMethodHS256)
claims := token.Claims.(jwt.MapClaims) claims := token.Claims.(jwt.MapClaims)
claims["exp"] = time.Now().Add(10 * time.Minute) claims["exp"] = expiresAt
claims["authorized"] = true claims["authorized"] = true
claims["username"] = username claims["username"] = username
claims["iss"] = issuer claims["iss"] = issuer
var scopes []string var scopes []string
scopes = append(scopes, domain.ScopeRecipeRead) if (username == "admin") {
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) scopes = append(scopes, domain.ScopeAll)
claims["scopes"] = scopes claims["scopes"] = scopes
} else {
scopes = append(scopes, domain.ScopeRecipeRead)
claims["scopes"] = scopes
}
tokenString, err := token.SignedString(secret) tokenString, err := token.SignedString(secret)
if err != nil { if err != nil {

View File

@ -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

View File

@ -4,7 +4,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"git.jamestombleson.com/jtom38/go-cook/api/domain" "git.jamestombleson.com/jtom38/go-cook/internal/domain"
) )
type IRecipeTable interface { type IRecipeTable interface {

View File

@ -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()

View File

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

View File

@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"time" "time"
"git.jamestombleson.com/jtom38/go-cook/api/domain" "git.jamestombleson.com/jtom38/go-cook/internal/domain"
"github.com/huandu/go-sqlbuilder" "github.com/huandu/go-sqlbuilder"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@ -133,7 +133,7 @@ func (ur UserRepository) processRows(rows *sql.Rows) []domain.UserEntity {
items := []domain.UserEntity{} items := []domain.UserEntity{}
for rows.Next() { for rows.Next() {
var id int var id int64
var name string var name string
var hash string var hash string
var createdAt time.Time var createdAt time.Time

View File

@ -5,8 +5,8 @@ import (
"log" "log"
"testing" "testing"
"git.jamestombleson.com/jtom38/go-cook/api/repositories" "git.jamestombleson.com/jtom38/go-cook/internal/repositories"
"git.jamestombleson.com/jtom38/go-cook/api/domain" "git.jamestombleson.com/jtom38/go-cook/internal/domain"
"github.com/DATA-DOG/go-sqlmock" "github.com/DATA-DOG/go-sqlmock"
_ "github.com/glebarez/go-sqlite" _ "github.com/glebarez/go-sqlite"

View File

@ -5,7 +5,7 @@ import (
"os" "os"
"strconv" "strconv"
"git.jamestombleson.com/jtom38/go-cook/api/domain" "git.jamestombleson.com/jtom38/go-cook/internal/domain"
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )

View File

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

View File

@ -5,8 +5,8 @@ import (
"errors" "errors"
"strings" "strings"
"git.jamestombleson.com/jtom38/go-cook/api/domain" "git.jamestombleson.com/jtom38/go-cook/internal/domain"
"git.jamestombleson.com/jtom38/go-cook/api/repositories" "git.jamestombleson.com/jtom38/go-cook/internal/repositories"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@ -17,6 +17,16 @@ const (
ErrInvalidPassword = "invalid password" 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 // This will handle operations that are user related, but one layer higher then the repository
type UserService struct { type UserService struct {
repo repositories.IUserTable repo repositories.IUserTable
@ -111,7 +121,7 @@ func (us UserService) doesScopeExist(scopes []string, target string) bool {
return false 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) err := us.CheckPasswordForRequirements(password)
if err != nil { if err != nil {
return domain.UserEntity{}, err return domain.UserEntity{}, err

View File

@ -3,7 +3,7 @@ package services_test
import ( import (
"testing" "testing"
"git.jamestombleson.com/jtom38/go-cook/api/services" "git.jamestombleson.com/jtom38/go-cook/internal/services"
"github.com/DATA-DOG/go-sqlmock" "github.com/DATA-DOG/go-sqlmock"
) )

View File

@ -16,6 +16,15 @@ Content-Type: application/x-www-form-urlencoded
password=lol 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 ### Add Scope to test user
POST http://localhost:1323/api/v1/auth/scopes/add POST http://localhost:1323/api/v1/auth/scopes/add