Herkese merhaba!

Uzun yıllardır bol miktarda kişisel zaman ve enerji harcayarak bilgimizi hepinizle paylaşıyoruz. Ancak şu andan itibaren bu blogu çalışır durumda tutabilmek için yardımınıza ihtiyacımız var. Yapmanız gereken tek şey, sitedeki reklamlardan birine tıklamak olacaktır, aksi takdirde hosting vb. masraflar nedeniyle maalesef yayından kaldırılacaktır. Teşekkürler.

Bu örnekte, OAuth2 protokolü için Cassandra veri modelleri oluşturacağız. Buradaki ana nokta, gerçek uygulama için modeller ve ilgili sorguları tasarlamaktır. Gerçek API uç noktaları yaratmayacağım ama en azından her bir fonksiyonun nasıl kullanıldığına dair örnekler ekleyeceğim.


Keyspace


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

Modeller


Clients


Örneğimizde aslında id alanı kullanılmaz. İstemci hakkında daha ayrıntılı bilgilerin tutulduğu başka bir uygulama için "yabancı anahtar" olarak kullanılabilir. Her şey sistem tasarımınıza bağlıdır, bu nedenle isterseniz kaldırabilirsiniz.


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.';

Tokens


Hem erişim hem de yenileme token kayıtlarına eklenmiş bir TTL olacağından, bu tablo bir önbellek gibi davranır. Bu, büyük bir tablonun oluşmasını önlemek içindir.


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.';

Uygulama akışı


Müşteri (client) Kaydı


Harici tanımlayıcısını UUID v4 biçiminde kabul ederek yeni bir istemci oluşturun.


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

Token yaratma (client_credentials)


Yeni bir erişim ve yenileme belirteci oluşturur.


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

Token yenileme (refresh_token)


Yeni bir erişim oluşturur ve yenileme belirtecini döndürür.


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

Token iptali


Bir istemciye ait bir erişim belirtecini veya yenileme belirtecini iptal eder.


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 kullanımı


Korumalı API isteklerine devam etmeden önce bir erişim tokenini doğrular.


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

Yapı


├── 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

Dosyalar


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

client.go


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
}

token.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
}

main.go


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")
}

Test


Mevcut durum


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)

Yeni durum


Burada go run -race main.go komutu çalıştırılır.


$ 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)