用Golang实现一个简单的Cassandra OAuth2客户端和令牌模型

144 阅读9分钟

在这个例子中,我们将为OAuth2协议创建Cassandra数据模型。这里的重点是设计模型和实际执行的相关查询。我们不打算创建真正的API端点,但我至少会添加关于每个功能如何使用的例子。

密钥空间

CREATE KEYSPACE IF NOT EXISTS auth
WITH replication = {
    'class': 'NetworkTopologyStrategy',
    'datacenter1': 1
};

模型

客户

id 字段在代码中没有使用。它可以被用作另一个应用程序的 "外键",那里保存着关于客户的更详细的信息。这完全取决于你的系统设计,所以如果你想,你可以把它删除。

CREATE TABLE IF NOT EXISTS auth.clients (
    id uuid,
    key uuid,
    secret text,
    created_at timestamp,
    deleted_at timestamp,
    PRIMARY KEY (key)
) WITH comment = 'The id field is the external identifier.';

令牌

访问和刷新令牌记录都会有一个TTL,所以这个表的作用就像一个缓存。这是为了防止出现一个巨大的表。

CREATE TABLE IF NOT EXISTS auth.tokens (
    hash text,
    client_key text,
    client_secret text,
    scopes set,
    PRIMARY KEY (hash, client_key)
) WITH comment = 'Holds both access and refresh tokens identified by type field.';

应用流程

客户端注册

通过接受UUID v4格式的外部标识符来创建一个新客户:

POST /api/v1/clients HTTP/1.1
Content-Type: application/json

{
  "id": "xxxxxxxx"
}
HTTP/1.1 201 Created
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
  "key":"xxxxxxxx",
  "secret":"xxxxxxxx"
}

创建令牌 (client_credentials)

创建一个新的访问和刷新令牌:

POST /oauth/token HTTP/1.1
Authorization: Basic {client_key+client_secret}
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
  "access_token":"xxxxxxxx",
  "token_type":"bearer",
  "expires_in":3600,
  "refresh_token":"xxxxxxxx"
}

刷新令牌 (refresh_token)

创建一个新的访问和旋转刷新令牌:

POST /oauth/token HTTP/1.1
Authorization: Basic {client_key+client_secret}
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
refresh_token=xxxxxxxx
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
  "access_token":"xxxxxxxx",
  "token_type":"bearer",
  "expires_in":3600,
  "refresh_token":"xxxxxxxx"
}

撤销令牌

撤销属于一个客户的访问令牌或刷新令牌:

POST /oauth/revoke HTTP/1.1
Authorization: Basic {client_key+client_secret}
Content-Type: application/x-www-form-urlencoded

token=xxxxxxxx
HTTP/1.1 200 OK

消耗API

在进行受保护的API请求之前,验证一个访问令牌:

{Method} {endpoint} HTTP/1.1
Authorization: Bearer {access_token}

结构

├── docker
│   └── docker-compose.yaml
├── internal
│   └── pkg
│       ├── cassandra
│       │   └── cassandra.go
│       └── storage
│           ├── driver
│           │   └── cassandra
│           │       ├── client.go
│           │       └── token.go
│           ├── error.go
│           ├── manager.go
│           ├── model.go
│           └── type.go
└── main.go

文件

docker-compose.yaml

version: "3.7"

services:

  auth-cassandra:
    image: "cassandra:3.11.9"
    container_name: "auth-cassandra"
    ports:
      - "9042:9042"
    environment:
      - "MAX_HEAP_SIZE=256M"
      - "HEAP_NEWSIZE=128M"

cassandra.go

package cassandra

import (
	"time"

	"github.com/gocql/gocql"
)

// The `gocql: no response received from cassandra within timeout period` error
// will be prevented by increasing the default timeout value. e.g. 5 sec
type Config struct {
	Hosts        []string
	Port         int
	ProtoVersion int
	Consistency  string
	Keyspace     string
	Timeout      time.Duration
}

func New(config Config) (*gocql.Session, error) {
	cluster := gocql.NewCluster(config.Hosts...)

	cluster.Port = config.Port
	cluster.ProtoVersion = config.ProtoVersion
	cluster.Keyspace = config.Keyspace
	cluster.Consistency = gocql.ParseConsistency(config.Consistency)
	cluster.Timeout = config.Timeout

	return cluster.CreateSession()
}

error.go

package storage

import "errors"

var (
	ErrDuplication = errors.New("duplicated record")
	ErrNotFound    = errors.New("record not found")
)

type.go

package storage

type TokenScope string

const (
	TokenScopeUnlimited    TokenScope = "*"
	TokenScopeCreateLeague TokenScope = "create-league"
	TokenScopeReadLeague   TokenScope = "read-league"
	TokenScopeUpdateLeague TokenScope = "update-league"
	TokenScopeDeleteLeague TokenScope = "delete-league"
)

type TokenTTL int

const (
	TokenTTLAccess  TokenTTL = 3600
	TokenTTLRefresh TokenTTL = 86400
)

model.go

package storage

import "time"

type Client struct {
	ID        string
	Key       string
	Secret    string
	CreatedAt time.Time
	DeletedAt *time.Time
}

type Token struct {
	ClientKey    string
	ClientSecret string
	Hash         string
	TTL          TokenTTL
	Scopes       []TokenScope
}

manager.go

package storage

import "context"

type ClientManager interface {
	Create(ctx context.Context, client Client) error
	Find(ctx context.Context, key string) (Client, error)
	UpdateSecret(ctx context.Context, key, secret string) error
	SoftDelete(ctx context.Context, key string) error
	HardDelete(ctx context.Context, key string) error
}

type TokenManager interface {
	// Creates a new access and refresh token.
	// Used for `client_credentials` grant type.
	Create(ctx context.Context, accTok Token, refTok Token) error

	// Deletes the current refresh token then creates a new access token and refresh token.
	// Used for `refresh_token` grant type.
	// The actual refresh token hash should be looked up with `Find` function before coming this stage.
	// Access token is not deleted as the `refresh_token` grant type has no knowledge it. Also, it could
	// have had expired anyway unless the client is requesting to refresh before expiry. Either way, it
	// is not a problem.
	Refresh(ctx context.Context, refTokHash string, accTok Token, refTok Token) error

	// Revokes either an access token or a refresh token.
	// The actual token hash should be looked up with `Find` function before coming this stage.
	Revoke(ctx context.Context, hash string) error

	// Finds either an access token or a refresh token.
	// Used for `refresh_token` grant type and all protected API calls.
	Find(ctx context.Context, hash string) (Token, error)
}

客户端

package cassandra

import (
	"context"
	"time"

	"github.com/you/auth/internal/pkg/storage"
	"github.com/gocql/gocql"
)

var _ storage.ClientManager = Client{}

type Client struct {
	connection *gocql.Session
	timeout    time.Duration
}

func NewClient(connection *gocql.Session, timeout time.Duration) Client {
	return Client{
		connection: connection,
		timeout:    timeout,
	}
}

func (c Client) Create(ctx context.Context, client storage.Client) error {
	ctx, cancel := context.WithTimeout(ctx, c.timeout)
	defer cancel()

	qry := `
INSERT INTO clients
(id, key, secret, created_at, deleted_at)
VALUES
(?, ?, ?, ?, ?)
IF NOT EXISTS
`

	apl, err := c.connection.Query(qry,
		client.ID,
		client.Key,
		client.Secret,
		client.CreatedAt,
		client.DeletedAt,
	).WithContext(ctx).MapScanCAS(map[string]interface{}{})

	if err != nil {
		return err
	}
	if !apl {
		return storage.ErrDuplication
	}

	return nil
}

func (c Client) Find(ctx context.Context, key string) (storage.Client, error) {
	ctx, cancel := context.WithTimeout(ctx, c.timeout)
	defer cancel()

	var client storage.Client

	qry := `
SELECT id, key, secret, created_at, deleted_at
FROM clients
WHERE key = ?
`

	err := c.connection.Query(qry, key).WithContext(ctx).Scan(
		&client.ID,
		&client.Key,
		&client.Secret,
		&client.CreatedAt,
		&client.DeletedAt,
	)
	if err != nil {
		if err == gocql.ErrNotFound {
			err = storage.ErrNotFound
		}
		return storage.Client{}, err
	}

	return client, nil
}

func (c Client) UpdateSecret(ctx context.Context, key, secret string) error {
	ctx, cancel := context.WithTimeout(ctx, c.timeout)
	defer cancel()

	qry := `
UPDATE clients
SET secret = ?
WHERE key = ?
IF EXISTS
`

	apl, err := c.connection.Query(qry, secret, key).WithContext(ctx).MapScanCAS(map[string]interface{}{})
	if err != nil {
		return err
	}
	if !apl {
		return storage.ErrNotFound
	}

	return nil
}

func (c Client) SoftDelete(ctx context.Context, key string) error {
	ctx, cancel := context.WithTimeout(ctx, c.timeout)
	defer cancel()

	qry := `
UPDATE clients
SET deleted_at = ?
WHERE key = ?
IF EXISTS
`

	apl, err := c.connection.Query(qry, time.Now().UTC(), key).WithContext(ctx).MapScanCAS(map[string]interface{}{})
	if err != nil {
		return err
	}
	if !apl {
		return storage.ErrNotFound
	}

	return nil
}

func (c Client) HardDelete(ctx context.Context, key string) error {
	ctx, cancel := context.WithTimeout(ctx, c.timeout)
	defer cancel()

	qry := `
DELETE FROM clients
WHERE key = ?
IF EXISTS
`

	apl, err := c.connection.Query(qry, key).WithContext(ctx).MapScanCAS(map[string]interface{}{})
	if err != nil {
		return err
	}
	if !apl {
		return storage.ErrNotFound
	}

	return nil
}

符号.go

package cassandra

import (
	"context"
	"time"

	"github.com/you/auth/internal/pkg/storage"
	"github.com/gocql/gocql"
)

var _ storage.TokenManager = Token{}

type Token struct {
	connection *gocql.Session
	timeout    time.Duration
}

func NewToken(connection *gocql.Session, timeout time.Duration) Token {
	return Token{
		connection: connection,
		timeout:    timeout,
	}
}

func (t Token) Create(ctx context.Context, accTok storage.Token, refTok storage.Token) error {
	ctx, cancel := context.WithTimeout(ctx, t.timeout)
	defer cancel()

	qry := `
INSERT INTO tokens
(hash, client_key, client_secret, scopes)
VALUES
(?, ?, ?, ?)
USING TTL ?
`

	btc := t.connection.NewBatch(gocql.LoggedBatch).WithContext(ctx)

	btc.Query(qry, accTok.Hash, accTok.ClientKey, accTok.ClientSecret, accTok.Scopes, accTok.TTL)
	btc.Query(qry, refTok.Hash, refTok.ClientKey, refTok.ClientSecret, refTok.Scopes, refTok.TTL)

	return t.connection.ExecuteBatch(btc)
}

func (t Token) Refresh(ctx context.Context, refTokHash string, accTok storage.Token, refTok storage.Token) error {
	ctx, cancel := context.WithTimeout(ctx, t.timeout)
	defer cancel()

	qry1 := `
DELETE FROM tokens
WHERE hash = ?
`

	qry2 := `
INSERT INTO tokens
(hash, client_key, client_secret, scopes)
VALUES
(?, ?, ?, ?)
USING TTL ?
`

	btc := t.connection.NewBatch(gocql.LoggedBatch).WithContext(ctx)

	btc.Query(qry1, refTokHash)
	btc.Query(qry2, accTok.Hash, accTok.ClientKey, accTok.ClientSecret, accTok.Scopes, accTok.TTL)
	btc.Query(qry2, refTok.Hash, refTok.ClientKey, refTok.ClientSecret, refTok.Scopes, refTok.TTL)

	return t.connection.ExecuteBatch(btc)
}

func (t Token) Revoke(ctx context.Context, hash string) error {
	ctx, cancel := context.WithTimeout(ctx, t.timeout)
	defer cancel()

	qry := `
DELETE FROM tokens
WHERE hash = ?
`

	return t.connection.Query(qry, hash).WithContext(ctx).Exec()
}

func (t Token) Find(ctx context.Context, hash string) (storage.Token, error) {
	ctx, cancel := context.WithTimeout(ctx, t.timeout)
	defer cancel()

	var token storage.Token

	qry := `
SELECT hash, client_key, client_secret, scopes
FROM tokens
WHERE hash = ?
`

	err := t.connection.Query(qry, hash).WithContext(ctx).Scan(
		&token.Hash,
		&token.ClientKey,
		&token.ClientSecret,
		&token.Scopes,
	)
	if err != nil {
		if err == gocql.ErrNotFound {
			err = storage.ErrNotFound
		}
		return storage.Token{}, err
	}

	return token, nil
}

主程序

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/you/auth/internal/pkg/cassandra"
	"github.com/you/auth/internal/pkg/storage"

	storagemanager "github.com/you/auth/internal/pkg/storage/driver/cassandra"
)

func main() {
	// Cassandra connection
	cass, err := cassandra.New(cassandra.Config{
		Hosts:        []string{"127.0.0.1"},
		Port:         9042,
		ProtoVersion: 4,
		Consistency:  "Quorum",
		Keyspace:     "auth",
		Timeout:      time.Second * 5,
	})
	if err != nil {
		log.Fatalln(err)
	}
	defer cass.Close()

	// Create cancellable context.
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// Create client storage manager.
	clientManager := storagemanager.NewClient(cass, time.Second)

	// Create client.
	createClient(ctx, clientManager)
	// Find client.
	findClient(ctx, clientManager)
	// Update client secret.
	updateClientSecret(ctx, clientManager)
	// Soft delete client.
	softDeleteClient(ctx, clientManager)
	// Hard delete client.
	hardDeleteClient(ctx, clientManager)

	// Create token storage manager.
	tokenManager := storagemanager.NewToken(cass, time.Second)

	// Create token.
	createToken(ctx, tokenManager)
	// Find token.
	findToken(ctx, tokenManager)
	// Refresh token.
	refreshToken(ctx, tokenManager)
	// Revoke token.
	revokeToken(ctx, tokenManager)
}

/**
------------------------------------------------------------------------------------------
CLIENT
------------------------------------------------------------------------------------------
*/

func createClient(ctx context.Context, clientManager storage.ClientManager) {
	if err := clientManager.Create(ctx, storage.Client{
		ID:        "7a5481cd-4d2b-47f1-8336-691efb67d45a",
		Key:       "ec3b7eaf-e7b9-46e1-877e-6bf8e2cc405b",
		Secret:    "8pt1kN4Urirt77DwCeAPz69DAZ0guCVtPFk6",
		CreatedAt: time.Now().UTC(),
		DeletedAt: &time.Time{},
	}); err != nil {
		fmt.Println("create client:", err)
	}
	fmt.Println("create client: ok")
}

func findClient(ctx context.Context, clientManager storage.ClientManager) {
	res, err := clientManager.Find(ctx, "ec3b7eaf-e7b9-46e1-877e-6bf8e2cc405b")
	if err != nil {
		fmt.Println("find client:", err)
		return
	}
	fmt.Printf("find client: %+v\n", res)
}

func updateClientSecret(ctx context.Context, clientManager storage.ClientManager) {
	if err := clientManager.UpdateSecret(ctx,
		"ec3b7eaf-e7b9-46e1-877e-6bf8e2cc405b",
		"grWiC12EW6tBim6Si1CjvkC6xVOmtRyuRpok",
	); err != nil {
		fmt.Println("update client secret:", err)
		return
	}
	fmt.Println("update client secret: ok")
}

func softDeleteClient(ctx context.Context, clientManager storage.ClientManager) {
	if err := clientManager.SoftDelete(ctx, "be2be55c-04fb-4b1c-b41b-fdfc20b81ff8"); err != nil {
		fmt.Println("soft delete client:", err)
		return
	}
	fmt.Println("soft delete client: ok")
}

func hardDeleteClient(ctx context.Context, clientManager storage.ClientManager) {
	if err := clientManager.HardDelete(ctx, "aaaeb5a9-0ca2-4dce-949c-75bd268a15a5"); err != nil {
		fmt.Println("hard delete client:", err)
		return
	}
	fmt.Println("hard delete client: ok")
}

/**
------------------------------------------------------------------------------------------
TOKEN
------------------------------------------------------------------------------------------
*/

func createToken(ctx context.Context, tokenManager storage.TokenManager) {
	accTok := storage.Token{
		ClientKey:    "be2be55c-04fb-4b1c-b41b-fdfc20b81ff8",
		ClientSecret: "cbHR4JaAw8go1vPxUxW9a5rwl8D4uFDN8HfV",               // Should be stored as, e.g. Argon2id
		Hash:         "ub4BQJSU0KfzkqbZTpqsZVvTCflC0foIw7xVdtbGVRyADnTwKQ", // Should be, e.g. 255 length
		TTL:          storage.TokenTTLAccess,
		Scopes:       []storage.TokenScope{storage.TokenScopeCreateLeague, storage.TokenScopeReadLeague},
	}
	refTok := storage.Token{
		ClientKey:    "be2be55c-04fb-4b1c-b41b-fdfc20b81ff8",
		ClientSecret: "cbHR4JaAw8go1vPxUxW9a5rwl8D4uFDN8HfV",               // Should be stored as, e.g. Argon2id
		Hash:         "WN2HxZiUsGLy2ZWpUGOjG5Bn7masaVlaBRGXTS3noo44YdMJhT", // Should be, e.g. 255 length
		TTL:          storage.TokenTTLRefresh,
		Scopes:       []storage.TokenScope{storage.TokenScopeCreateLeague, storage.TokenScopeReadLeague},
	}

	if err := tokenManager.Create(ctx, accTok, refTok); err != nil {
		fmt.Println("create token:", err)
		return
	}
	fmt.Println("create token: ok")
}

func findToken(ctx context.Context, tokenManager storage.TokenManager) {
	res, err := tokenManager.Find(ctx, "ub4BQJSU0KfzkqbZTpqsZVvTCflC0foIw7xVdtbGVRyADnTwKQ")
	if err != nil {
		fmt.Println("find token:", err)
		return
	}
	fmt.Printf("find token: %+v\n", res)
}

func refreshToken(ctx context.Context, tokenManager storage.TokenManager) {
	tok, err := tokenManager.Find(ctx, "WN2HxZiUsGLy2ZWpUGOjG5Bn7masaVlaBRGXTS3noo44YdMJhT")
	if err != nil {
		fmt.Println("refresh token: token not found: 401:", err)
		return
	}

	accTok := storage.Token{
		ClientKey:    tok.ClientKey,
		ClientSecret: tok.ClientSecret,                                     // Already Argon2id at this stage
		Hash:         "q1kOUVJbsQm2ZUQ7VeS91ODjYaUBPHRRmTztSgJ5GTk1IqPmOi", // Should be, e.g. 255 length
		TTL:          storage.TokenTTLAccess,
		Scopes:       tok.Scopes,
	}
	refTok := storage.Token{
		ClientKey:    tok.ClientKey,
		ClientSecret: tok.ClientSecret,                                     // Already Argon2id at this stage
		Hash:         "ZJNgenlyow5uMFaCE1pQRuDWHUME48xRsYu5w2j2ZjqNHCho9T", // Should be, e.g. 255 length
		TTL:          storage.TokenTTLRefresh,
		Scopes:       tok.Scopes,
	}

	if err := tokenManager.Refresh(ctx, tok.Hash, accTok, refTok); err != nil {
		fmt.Println("refresh token:", err)
		return
	}
	fmt.Println("refresh token: ok")
}

func revokeToken(ctx context.Context, tokenManager storage.TokenManager) {
	clientKey := "be2be55c-04fb-4b1c-b41b-fdfc20b81ff8"
	clientSec := "cbHR4JaAw8go1vPxUxW9a5rwl8D4uFDN8HfV"
	hash := "ZJNgenlyow5uMFaCE1pQRuDWHUME48xRsYu5w2j2ZjqNHCho9T"

	tok, err := tokenManager.Find(ctx, hash)
	if err != nil {
		fmt.Println("revoke token: token not found: 401:", err)
		return
	}

	if clientKey != tok.ClientKey || clientSec != tok.ClientSecret {
		fmt.Println("revoke token: invalid client: 401")
		return
	}

	if err := tokenManager.Revoke(ctx, tok.Hash); err != nil {
		fmt.Println("revoke token:", err)
		return
	}
	fmt.Println("revoke token: ok")
}

测试

当前状态

cqlsh> SELECT * FROM auth.clients;

 key                                  | created_at                      | deleted_at                      | id                                   | secret
--------------------------------------+---------------------------------+---------------------------------+--------------------------------------+--------------------------------------
 be2be55c-04fb-4b1c-b41b-fdfc20b81ff8 | 2019-11-27 10:00:49.000000+0000 |                                 | 2c62c3d8-55ae-46ed-89bb-350e33bfb602 | cbHR4JaAw8go1vPxUxW9a5rwl8D4uFDN8HfV
 d7cc525a-98c4-48b3-9520-f8f9ee1d9c7b | 2019-11-27 10:00:49.000000+0000 |                                 | 32534bcd-c9ed-4613-a478-1539856386d5 | 7hTmGjGdOjpdlynE7SzkynK6l5ySbckRRlnd
 c291ecc3-5a98-45ac-8473-6486f86058d0 | 2021-01-17 13:09:49.000000+0000 | 2021-12-17 13:00:11.000000+0000 | 152e3280-baa7-452a-a5d5-d22ede044e5e | Qlr83GgYIppCdvrUfXr8vQRkgqj04nqgc8Q1
 aaaeb5a9-0ca2-4dce-949c-75bd268a15a5 | 2020-01-17 11:09:00.000000+0000 |                                 | 4b3bef03-2eb3-4783-9e68-9e4b443f0fdf | y6BIS7Z2BtEx1aeYusTq8Zd0VqC6Mx4dRcmr
 f74b6770-8e68-4c82-88e3-17b6c9362b8f | 2019-11-27 10:00:49.000000+0000 | 2021-01-30 22:45:49.000000+0000 | 5962f914-a146-452e-bb9c-661534abdef4 | Rtl09mcaFWX7kcgD2ZndvzEHn8HVorn9rEUp

(5 rows)
cqlsh> SELECT * FROM auth.tokens;

 hash | client_key | client_secret | scopes
------+------------+---------------+--------

(0 rows)

新状态

在这个阶段,运行go run -race main.go 命令:

$ go run -race main.go

create client: ok
find client: {ID:7a5481cd-4d2b-47f1-8336-691efb67d45a Key:ec3b7eaf-e7b9-46e1-877e-6bf8e2cc405b Secret:8pt1kN4Urirt77DwCeAPz69DAZ0guCVtPFk6 CreatedAt:2021-02-01 15:11:23.545 +0000 UTC DeletedAt:0001-01-01 00:00:00 +0000 UTC}
update client secret: ok
soft delete client: ok
hard delete client: ok
create token: ok
find token: {ClientKey:be2be55c-04fb-4b1c-b41b-fdfc20b81ff8 ClientSecret:cbHR4JaAw8go1vPxUxW9a5rwl8D4uFDN8HfV Hash:ub4BQJSU0KfzkqbZTpqsZVvTCflC0foIw7xVdtbGVRyADnTwKQ TTL:0 Scopes:[create-league read-league]}
refresh token: ok
revoke token: ok
cqlsh> SELECT * FROM auth.clients;

 key                                  | created_at                      | deleted_at                      | id                                   | secret
--------------------------------------+---------------------------------+---------------------------------+--------------------------------------+--------------------------------------
 be2be55c-04fb-4b1c-b41b-fdfc20b81ff8 | 2019-11-27 10:00:49.000000+0000 | 2021-02-01 15:11:23.620000+0000 | 2c62c3d8-55ae-46ed-89bb-350e33bfb602 | cbHR4JaAw8go1vPxUxW9a5rwl8D4uFDN8HfV
 ec3b7eaf-e7b9-46e1-877e-6bf8e2cc405b | 2021-02-01 15:11:23.545000+0000 |                                 | 7a5481cd-4d2b-47f1-8336-691efb67d45a | grWiC12EW6tBim6Si1CjvkC6xVOmtRyuRpok
 d7cc525a-98c4-48b3-9520-f8f9ee1d9c7b | 2019-11-27 10:00:49.000000+0000 |                                 | 32534bcd-c9ed-4613-a478-1539856386d5 | 7hTmGjGdOjpdlynE7SzkynK6l5ySbckRRlnd
 c291ecc3-5a98-45ac-8473-6486f86058d0 | 2021-01-17 13:09:49.000000+0000 | 2021-12-17 13:00:11.000000+0000 | 152e3280-baa7-452a-a5d5-d22ede044e5e | Qlr83GgYIppCdvrUfXr8vQRkgqj04nqgc8Q1
 f74b6770-8e68-4c82-88e3-17b6c9362b8f | 2019-11-27 10:00:49.000000+0000 | 2021-01-30 22:45:49.000000+0000 | 5962f914-a146-452e-bb9c-661534abdef4 | Rtl09mcaFWX7kcgD2ZndvzEHn8HVorn9rEUp

(5 rows)
cqlsh> SELECT * FROM auth.tokens;

 hash                                               | client_key                           | client_secret                        | scopes
----------------------------------------------------+--------------------------------------+--------------------------------------+----------------------------------
 q1kOUVJbsQm2ZUQ7VeS91ODjYaUBPHRRmTztSgJ5GTk1IqPmOi | be2be55c-04fb-4b1c-b41b-fdfc20b81ff8 | cbHR4JaAw8go1vPxUxW9a5rwl8D4uFDN8HfV | {'create-league', 'read-league'}
 ub4BQJSU0KfzkqbZTpqsZVvTCflC0foIw7xVdtbGVRyADnTwKQ | be2be55c-04fb-4b1c-b41b-fdfc20b81ff8 | cbHR4JaAw8go1vPxUxW9a5rwl8D4uFDN8HfV | {'create-league', 'read-league'}

(2 rows)