Signed-off-by: Nikolai Rodionov <allanger@badhouseplants.net>
This commit is contained in:
@@ -55,7 +55,7 @@ func (srv *TokensServer) CreateToken(ctx context.Context, in *tokens.CreateToken
|
||||
Scopes: permissions,
|
||||
}
|
||||
|
||||
token, err := srv.tokenCtrl.Create(ctx, tokenData)
|
||||
token, _, err := srv.tokenCtrl.Create(ctx, tokenData)
|
||||
if err != nil {
|
||||
if errors.Is(err, controllers.ErrServerError) {
|
||||
return nil, status.Error(codes.Internal, "Something is broken on our side")
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/logger"
|
||||
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/token"
|
||||
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -35,6 +36,7 @@ var (
|
||||
ErrServerError = errors.New("internal server error")
|
||||
ErrUserTokenMismatch = errors.New("user doesn't match the token")
|
||||
ErrBadToken = errors.New("bad token")
|
||||
ErrTokenNotFound = errors.New("token not found")
|
||||
)
|
||||
|
||||
type TokenData struct {
|
||||
@@ -96,15 +98,26 @@ func (ctrl *TokenController) VerifyTokenOwner(ctx context.Context, userID, token
|
||||
}
|
||||
|
||||
// Create a new token, store its hash in the database and return the token value
|
||||
func (ctrl *TokenController) Create(ctx context.Context, data *TokenData) (string, error) {
|
||||
func (ctrl *TokenController) Create(ctx context.Context, data *TokenData) (string, string, error) {
|
||||
id := uuid.NewString()
|
||||
log := logger.FromContext(ctx).WithValues("uuid", id)
|
||||
log.V(2).Info("Creating a new token")
|
||||
|
||||
userExists, err := repository.IsAccountExist(ctx, ctrl.DB, data.UserID)
|
||||
if err != nil {
|
||||
log.Error(err, "Couldn't check whether a user exists")
|
||||
return "", "", ErrServerError
|
||||
}
|
||||
|
||||
// If user doesn't exist, do not generate a token
|
||||
if !userExists {
|
||||
return "", "", ErrUserNotFound
|
||||
}
|
||||
|
||||
tokenValue, err := token.GenerateToken()
|
||||
if err != nil {
|
||||
log.Error(err, "Couldn't create a token")
|
||||
return "", ErrServerError
|
||||
return "", "", ErrServerError
|
||||
}
|
||||
|
||||
tokenHash := hashSHA256(tokenValue)
|
||||
@@ -112,29 +125,26 @@ func (ctrl *TokenController) Create(ctx context.Context, data *TokenData) (strin
|
||||
scopesJson, err := json.Marshal(data.Scopes)
|
||||
if err != nil {
|
||||
log.Error(err, "Couldn't marshal permissions into json")
|
||||
return "", ErrServerError
|
||||
return "", "", ErrServerError
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO tokens (uuid, description, token_hash, user_id, scopes, created_at, generated_at, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`
|
||||
|
||||
if _, err := ctrl.DB.Query(
|
||||
query,
|
||||
id,
|
||||
data.Name,
|
||||
tokenHash,
|
||||
data.UserID,
|
||||
scopesJson,
|
||||
time.Now(),
|
||||
time.Now(),
|
||||
data.ExpiresAt,
|
||||
); err != nil {
|
||||
log.Error(err, "Couldn't insert a token in the database")
|
||||
return "", ErrServerError
|
||||
queryData := &repository.TokenData{
|
||||
UUID: id,
|
||||
Decsription: data.Name,
|
||||
TokenHash: tokenHash,
|
||||
UserID: data.UserID,
|
||||
CreatedAt: time.Now(),
|
||||
GeneratedAt: time.Now(),
|
||||
ExpiresAt: data.ExpiresAt,
|
||||
Scope: string(scopesJson),
|
||||
}
|
||||
|
||||
return tokenValue, nil
|
||||
if err := repository.CreateToken(ctx, ctrl.DB, queryData); err != nil {
|
||||
log.Error(err, "Couldn't create a token")
|
||||
return "", "", ErrServerError
|
||||
}
|
||||
|
||||
return tokenValue, id, nil
|
||||
}
|
||||
|
||||
// Update token name or permissions, other changes are ignored by this method
|
||||
@@ -148,9 +158,14 @@ func (ctrl *TokenController) Update(ctx context.Context, data *TokenData) error
|
||||
return ErrServerError
|
||||
}
|
||||
|
||||
query := "UPDATE tokens SET description = $1, scopes = $2 WHERE uuid = $3;"
|
||||
if _, err := ctrl.DB.Query(query, data.Name, scopesJson, data.UUID); err != nil {
|
||||
log.Error(err, "Couldn't update a token in the database")
|
||||
queryData := &repository.TokenData{
|
||||
UUID: data.UUID,
|
||||
Scope: string(scopesJson),
|
||||
Decsription: data.Name,
|
||||
}
|
||||
|
||||
if err := repository.UpdateToken(ctx, ctrl.DB, queryData); err != nil {
|
||||
log.Error(err, "Couldn't update a token")
|
||||
return ErrServerError
|
||||
}
|
||||
|
||||
@@ -203,40 +218,36 @@ func (ctrl *TokenController) Regenerate(ctx context.Context, id string) (string,
|
||||
// Get an existing token data
|
||||
func (ctrl *TokenController) Get(ctx context.Context, id, userID string) (*TokenData, error) {
|
||||
log := logger.FromContext(ctx).WithValues("uuid", id, "user_id", userID)
|
||||
log.V(2).Info("Regenerating a token")
|
||||
query := `
|
||||
SELECT uuid, description, generated_at, expires_at, last_used_at, revoked_at, created_at
|
||||
FROM tokens
|
||||
WHERE uuid = $1 AND user_id = $2`
|
||||
token := &TokenData{}
|
||||
var generatedAt sql.NullTime
|
||||
var expiresAt sql.NullTime
|
||||
var revokedAt sql.NullTime
|
||||
var lastUsedAt sql.NullTime
|
||||
var createdAt sql.NullTime
|
||||
log.V(2).Info("Getting a token")
|
||||
|
||||
if err := ctrl.DB.QueryRowContext(ctx, query, id, userID).Scan(
|
||||
&token.UUID,
|
||||
&token.Name,
|
||||
&generatedAt,
|
||||
&expiresAt,
|
||||
&lastUsedAt,
|
||||
&revokedAt,
|
||||
&createdAt,
|
||||
); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
queryResult, err := repository.GetToken(ctx, ctrl.DB, id, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
log.Error(err, "Couldn't find a token")
|
||||
log.Error(err, "Couldn't get a token from DB")
|
||||
return nil, ErrServerError
|
||||
}
|
||||
token.GeneratedAt = generatedAt.Time
|
||||
token.ExpiresAt = expiresAt.Time
|
||||
token.RevokedAt = revokedAt.Time
|
||||
token.LastUsedAt = lastUsedAt.Time
|
||||
token.CreatedAt = createdAt.Time
|
||||
|
||||
return token, nil
|
||||
scope := map[string][]string{}
|
||||
if err := json.Unmarshal([]byte(queryResult.Scope), &scope); err != nil {
|
||||
log.Error(err, "Couldn't unmarshal scope into json")
|
||||
return nil, ErrServerError
|
||||
|
||||
}
|
||||
|
||||
result := &TokenData{
|
||||
UUID: queryResult.UUID,
|
||||
Name: queryResult.Decsription,
|
||||
CreatedAt: queryResult.CreatedAt,
|
||||
LastUsedAt: queryResult.LastUsedAt,
|
||||
RevokedAt: queryResult.RevokedAt,
|
||||
ExpiresAt: queryResult.ExpiresAt,
|
||||
GeneratedAt: queryResult.GeneratedAt,
|
||||
Scopes: scope,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// List all available token
|
||||
@@ -381,6 +392,7 @@ func hashSHA256(s string) string {
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// Unit Tests
|
||||
func TestUnitHashPersistence(t *testing.T) {
|
||||
password := "qwertyu9"
|
||||
hash1 := hashSHA256(password)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -36,24 +37,16 @@ func TestCreateToken_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
ctrl := newTestTokensController(t.Context())
|
||||
tokenID, err := ctrl.Create(t.Context(), tokenData)
|
||||
tokenVal, tokenID, err := ctrl.Create(t.Context(), tokenData)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, tokenID)
|
||||
assert.NotEmpty(t, tokenVal)
|
||||
}
|
||||
|
||||
func TestCreateToken_Success(t *testing.T) {
|
||||
// Create a user for the token
|
||||
ctrlAccount := newTestAccountController(t.Context())
|
||||
accountData := &controllers.AccountData{
|
||||
Password: "qwertyu9",
|
||||
Email: newTestUniqueEmail("accounts"),
|
||||
}
|
||||
id, err := ctrlAccount.Create(t.Context(), accountData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
func TestCreateToken_UserNotExist(t *testing.T) {
|
||||
tokenData := &controllers.TokenData{
|
||||
Name: "Test Token",
|
||||
UserID: id,
|
||||
UserID: uuid.NewString(),
|
||||
ExpiresAt: time.Now().Add(time.Second * 5),
|
||||
Scopes: map[string][]string{
|
||||
"Test": {"test", "test2"},
|
||||
@@ -61,7 +54,44 @@ func TestCreateToken_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
ctrl := newTestTokensController(t.Context())
|
||||
tokenID, err := ctrl.Create(t.Context(), tokenData)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, tokenID)
|
||||
tokenVal, tokenID, err := ctrl.Create(t.Context(), tokenData)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, controllers.ErrUserNotFound)
|
||||
assert.Empty(t, tokenID)
|
||||
assert.Empty(t, tokenVal)
|
||||
}
|
||||
|
||||
func TestGetToken_Success(t *testing.T) {
|
||||
// Create a user for the token
|
||||
ctrlAccount := newTestAccountController(t.Context())
|
||||
accountData := &controllers.AccountData{
|
||||
Password: "qwertyu9",
|
||||
Email: newTestUniqueEmail("accounts"),
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
userID, err := ctrlAccount.Create(t.Context(), accountData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
tokenData := &controllers.TokenData{
|
||||
Name: "Test Token",
|
||||
UserID: userID,
|
||||
ExpiresAt: time.Now().Add(time.Second * 5),
|
||||
Scopes: map[string][]string{
|
||||
"Test": {"test", "test2"},
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := newTestTokensController(t.Context())
|
||||
_, tokenID, err := ctrl.Create(t.Context(), tokenData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
token, err := ctrl.Get(t.Context(), tokenID, userID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, now.Truncate(time.Second), token.GeneratedAt.Truncate(time.Second))
|
||||
assert.Equal(t, now.Truncate(time.Second), token.CreatedAt.Truncate(time.Second))
|
||||
assert.Equal(t, tokenData.Scopes, token.Scopes)
|
||||
assert.Equal(t, tokenData.Name, token.Name)
|
||||
assert.Equal(t, tokenData.ExpiresAt.Truncate(time.Second), token.ExpiresAt.Truncate(time.Second))
|
||||
}
|
||||
|
||||
@@ -60,3 +60,16 @@ func GetUUIDForEmail(ctx context.Context, db *sql.DB, email string) (uuid string
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// IsAccountExist checks if an account with a UUID exists in the db
|
||||
func IsAccountExist(ctx context.Context, db *sql.DB, uuid string) (bool, error) {
|
||||
exists := false
|
||||
err := db.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM accounts WHERE uuid = $1
|
||||
)`,
|
||||
uuid,
|
||||
).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
94
internal/repository/tokens.go
Normal file
94
internal/repository/tokens.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TokenData struct {
|
||||
UUID string
|
||||
Decsription string
|
||||
TokenHash string
|
||||
UserID string
|
||||
CreatedAt time.Time
|
||||
GeneratedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
RevokedAt time.Time
|
||||
LastUsedAt time.Time
|
||||
Scope string
|
||||
}
|
||||
|
||||
// CreateTokens adds a new token to a database
|
||||
func CreateToken(ctx context.Context, db *sql.DB, data *TokenData) error {
|
||||
query := `
|
||||
INSERT INTO tokens
|
||||
(uuid, description, token_hash, user_id, scopes, created_at, generated_at, expires_at)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8);`
|
||||
if _, err := db.QueryContext(
|
||||
ctx,
|
||||
query,
|
||||
data.UUID,
|
||||
data.Decsription,
|
||||
data.TokenHash,
|
||||
data.UserID,
|
||||
data.Scope,
|
||||
data.CreatedAt,
|
||||
data.GeneratedAt,
|
||||
data.ExpiresAt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateToken updates token description and scope
|
||||
func UpdateToken(ctx context.Context, db *sql.DB, data *TokenData) error {
|
||||
query := "UPDATE tokens SET description = $1, scopes = $2 WHERE uuid = $3;"
|
||||
if _, err := db.QueryContext(ctx, query, data.Decsription, data.Scope, data.UUID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetToken(ctx context.Context, db *sql.DB, tokenID, userID string) (*TokenData, error) {
|
||||
query := `
|
||||
SELECT
|
||||
uuid, description, generated_at, expires_at,
|
||||
last_used_at, revoked_at, created_at, scopes
|
||||
FROM tokens
|
||||
WHERE uuid = $1 AND user_id = $2;`
|
||||
|
||||
var generatedAt sql.NullTime
|
||||
var expiresAt sql.NullTime
|
||||
var revokedAt sql.NullTime
|
||||
var lastUsedAt sql.NullTime
|
||||
var createdAt sql.NullTime
|
||||
|
||||
result := &TokenData{}
|
||||
|
||||
if err := db.QueryRowContext(ctx, query, tokenID, userID).Scan(
|
||||
&result.UUID,
|
||||
&result.Decsription,
|
||||
&generatedAt,
|
||||
&expiresAt,
|
||||
&lastUsedAt,
|
||||
&revokedAt,
|
||||
&createdAt,
|
||||
&result.Scope,
|
||||
); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
result.GeneratedAt = generatedAt.Time
|
||||
result.ExpiresAt = expiresAt.Time
|
||||
result.RevokedAt = revokedAt.Time
|
||||
result.LastUsedAt = lastUsedAt.Time
|
||||
result.CreatedAt = createdAt.Time
|
||||
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user