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
This commit is contained in:
commit
13d6e33b72
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@ -9,7 +9,7 @@
|
|||||||
"type": "go",
|
"type": "go",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "auto",
|
"mode": "auto",
|
||||||
"program": "main.go"
|
"program": "cmd/main.go"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -3,6 +3,7 @@
|
|||||||
"echojwt",
|
"echojwt",
|
||||||
"glebarez",
|
"glebarez",
|
||||||
"gocook",
|
"gocook",
|
||||||
|
"godotenv",
|
||||||
"huandu",
|
"huandu",
|
||||||
"labstack",
|
"labstack",
|
||||||
"sqlbuilder"
|
"sqlbuilder"
|
||||||
|
6
Justfile
6
Justfile
@ -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
|
@ -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
2
go.mod
@ -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
|
||||||
|
@ -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
|
@ -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"`
|
||||||
|
}
|
@ -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"`
|
|
||||||
}
|
}
|
@ -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{
|
||||||
Success: true,
|
BaseResponse: domain.BaseResponse{
|
||||||
Token: token,
|
Success: true,
|
||||||
|
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)
|
@ -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"
|
@ -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{
|
||||||
Success: true,
|
BaseResponse: domain.BaseResponse{
|
||||||
Message: "Hello world!",
|
Success: true,
|
||||||
|
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{
|
||||||
Success: true,
|
BaseResponse: domain.BaseResponse{
|
||||||
Message: fmt.Sprintf("Hello, %s", name),
|
Success: true,
|
||||||
|
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{
|
||||||
Success: true,
|
BaseResponse: domain.BaseResponse{
|
||||||
Message: fmt.Sprintf("Hello, %s", request.Name),
|
Success: true,
|
||||||
|
Message: fmt.Sprintf("Hello, %s", request.Name),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
@ -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
|
scopes = append(scopes, domain.ScopeAll)
|
||||||
|
claims["scopes"] = scopes
|
||||||
tokenString, err := token.SignedString(secret)
|
} else {
|
||||||
if err != nil {
|
scopes = append(scopes, domain.ScopeRecipeRead)
|
||||||
return "", err
|
claims["scopes"] = scopes
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
tokenString, err := token.SignedString(secret)
|
||||||
if err != nil {
|
if err != nil {
|
16
internal/migrations/20240416180636_refreshtoken.sql
Normal file
16
internal/migrations/20240416180636_refreshtoken.sql
Normal 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
|
@ -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 {
|
112
internal/repositories/refreshTokens.go
Normal file
112
internal/repositories/refreshTokens.go
Normal 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()
|
110
internal/repositories/refreshTokens_test.go
Normal file
110
internal/repositories/refreshTokens_test.go
Normal 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
|
||||||
|
}
|
@ -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
|
@ -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"
|
@ -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"
|
||||||
)
|
)
|
86
internal/services/refreshTokenService.go
Normal file
86
internal/services/refreshTokenService.go
Normal 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
|
||||||
|
}
|
@ -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
|
@ -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"
|
||||||
)
|
)
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user