Refresh Token Support and package refactor based on best practice docs #18
@ -11,6 +11,15 @@ type UserEntity struct {
|
|||||||
Scopes string
|
Scopes string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RefreshTokenEntity struct {
|
||||||
|
Id int
|
||||||
|
Username string
|
||||||
|
Token string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastUpdated time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type RecipeEntity struct {
|
type RecipeEntity struct {
|
||||||
Id int32
|
Id int32
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
@ -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 string `json:"expiresAt"`
|
||||||
|
}
|
@ -4,8 +4,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"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"
|
||||||
@ -116,6 +116,16 @@ func (h *Handler) validateAdminToken(c echo.Context, password string) error {
|
|||||||
return c.JSON(http.StatusOK, token)
|
return c.JSON(http.StatusOK, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GenerateRefreshToken(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 err
|
||||||
|
}
|
||||||
|
h.refreshTokenRepo.Create()
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
@ -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,7 +4,7 @@ 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"
|
||||||
)
|
)
|
@ -4,9 +4,9 @@ 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/repositories"
|
||||||
"git.jamestombleson.com/jtom38/go-cook/api/domain"
|
"git.jamestombleson.com/jtom38/go-cook/internal/services"
|
||||||
|
|
||||||
"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 +16,19 @@ import (
|
|||||||
type Handler struct {
|
type Handler struct {
|
||||||
Config domain.EnvConfig
|
Config domain.EnvConfig
|
||||||
|
|
||||||
UserService services.UserService
|
UserService services.UserService
|
||||||
userRepo repositories.IUserTable
|
userRepo repositories.IUserTable
|
||||||
recipeRepo repositories.IRecipeTable
|
recipeRepo repositories.IRecipeTable
|
||||||
|
refreshTokenRepo repositories.RefreshTokenRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
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),
|
UserService: services.NewUserService(conn),
|
||||||
userRepo: repositories.NewUserRepository(conn),
|
userRepo: repositories.NewUserRepository(conn),
|
||||||
recipeRepo: repositories.NewRecipeRepository(conn),
|
recipeRepo: repositories.NewRecipeRepository(conn),
|
||||||
|
refreshTokenRepo: repositories.NewRefreshTokenRepository(conn),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
17
internal/migrations/20240416180636_refreshtoken.sql
Normal file
17
internal/migrations/20240416180636_refreshtoken.sql
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
SELECT 'up SQL query';
|
||||||
|
CREATE TABLE RefreshTokens (
|
||||||
|
ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
Username TEXT NOT NULL,
|
||||||
|
Token TEXT NOT NULL,
|
||||||
|
ExpiresAt DATETIME 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 {
|
111
internal/repositories/refreshTokens.go
Normal file
111
internal/repositories/refreshTokens.go
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
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 {
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshTokenRepository struct {
|
||||||
|
connection *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRefreshTokenRepository(conn *sql.DB) RefreshTokenRepository {
|
||||||
|
return RefreshTokenRepository{
|
||||||
|
connection: conn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt RefreshTokenRepository) Create(username string, token string, expiresAt time.Time) (int64, error) {
|
||||||
|
dt := time.Now()
|
||||||
|
builder := sqlbuilder.NewInsertBuilder()
|
||||||
|
builder.InsertInto(refreshTokenTableName)
|
||||||
|
builder.Cols("Username", "Token", "ExpiresAt", "CreatedAt", "LastUpdated")
|
||||||
|
builder.Values(username, token, expiresAt, 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 int) (int64, error) {
|
||||||
|
builder := sqlbuilder.NewDeleteBuilder()
|
||||||
|
builder.DeleteFrom(refreshTokenTableName)
|
||||||
|
builder.Where(
|
||||||
|
builder.EQ("Id", id),
|
||||||
|
)
|
||||||
|
|
||||||
|
query, args := builder.Build()
|
||||||
|
_, err := rt.connection.Exec(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rd RefreshTokenRepository) processRows(rows *sql.Rows) []domain.RefreshTokenEntity {
|
||||||
|
items := []domain.RefreshTokenEntity{}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var id int
|
||||||
|
var username string
|
||||||
|
var token string
|
||||||
|
var expiresAt time.Time
|
||||||
|
var createdAt time.Time
|
||||||
|
var lastUpdated time.Time
|
||||||
|
|
||||||
|
err := rows.Scan(&id, &username, &token, &expiresAt, &createdAt, &lastUpdated)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, domain.RefreshTokenEntity{
|
||||||
|
Id: id,
|
||||||
|
Username: username,
|
||||||
|
Token: token,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
LastUpdated: lastUpdated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
//func (rt RefreshTokenRepository) Delete()
|
111
internal/repositories/refreshTokens_test.go
Normal file
111
internal/repositories/refreshTokens_test.go
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
package repositories_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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", time.Now().Add(time.Hour+1))
|
||||||
|
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", time.Now().Add(time.Hour+1))
|
||||||
|
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", time.Now().Add(time.Hour+1))
|
||||||
|
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"
|
@ -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"
|
||||||
)
|
)
|
@ -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"
|
||||||
)
|
)
|
@ -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"
|
||||||
)
|
)
|
Loading…
Reference in New Issue
Block a user