Python+Redisでcredentialsとaccess_tokenの管理をする

この記事は[JSL(日本システム技研) Advent Calendar 2020年 7日](https://qiita.com/advent-calendar/2020/jsl)の記事です。
毎年のことながら遅刻してる… :innocent: 

今回はPython+Redisの記事です。
自分はRDBMSばかり使っていて、NoSQL設計時にもどうも考え方の違いに混乱した身です。

## この記事の目的

PythonとRedisで

* credentialsの作成と管理
* access_tokenの作成と管理
* credentiasが作成したaccess_tokenの取得

をする。

DynamoDBを使えばセカンダリインデックスを使用してすぐできる問題なのですが、Redisを使うとなると設計周りで少し頭を使います。
意外にこの辺にターゲットを絞ってコミットした記事を見かけなかったため、同じように悩んでる人のために少しでも参考になれと思います:hand_splayed: 

## まずはRedisライブラリを使用してみる

### 前提

* Redis Serverインストール済み
* Pythonはvirtualenvにactivateしredis-pyをインストール済み

---

1. Redis起動  

    ```terminal
$ redis-server
    ```

1. Pythonをインタラクティブモードで開く

    ```terminal
$ python
    ```

1. clientを生成して操作する

    ```python
>>> from redis import Redis
>>> client = Redis.from_url('redis://localhost:6379/1')
>>> client.set('hoge', 'fuga')
True
>>> client.get('hoge')
b'fuga'
    ```

こんな感じでRedis操作が可能です。
clientのメソッドはだいたいredis-cliと同じなので、もともとRedisを触っていた人ならすんなり入れるかと思います。

## credentialsとaccess_tokenの関係

ココから本題です。
今回扱うcredentialsとaccess_tokenの構成は以下

| credentials |
|:-:|
| key [primary]|
| secret |
| max_token_count |

※credentialsのmax_token_countは、最大発行できるtokenの数が入ります。

| access_token |
|:-:|
| token [primary]|
| expires_at |


単純に作成するとこのようになります。(コピペしやすいよう>>>を排除しています)

```python
import json
from redis import Redis
client = Redis.from_url('redis://localhost:6379/1')
credentials_params = {'secret': 'aaaaaa_secret', 'max_token_count': 3}
client.set('credentials:::aaaaaa_key', json.dumps(credentials_params))
> True
access_token_params = {'expires_at': '2021-05-08T09:46:10.363778+00:00'}
client.set('access_token:::aaaaaa_token', json.dumps(access_token_params))
> True
client.get('credentials:::aaaaaa_key')
> b'{"secret": "bbbbbbbb", "max_token_count": 3}'
client.get('access_token:::aaaaaa_token')
> b'{"expires_at": "2021-05-08T09:46:10.363778+00:00"}' 
```

同一DBを使用していることによるkeyの重複を防ぐため、set時に`access_token:::`や`credentials:::`とprefixにつけています。
また、msetを使ってHash形式(辞書)で保存することもできるのですが、valueをjsonにしたほうがデータの加工がしやすいため、ここではsetを使っています。

### 問題点

簡単に図式化すると、今Redisは以下のような感じになっています。
![redis01](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/129996/e1df7668-a914-9930-bcd5-43f21e561ee9.png)
視覚的にCredentialsとAccessTokenを分けていますが、実際にパーティションが区切られているということはありません。
Redis DBに対して`credentials:::aaaaaa_key`で取り出すか`access_token:::aaaaaa_token`で取り出すかの違いです。

ここで問題になるのはaccess_tokenをcredentials_keyで引っ張ってこれないため、max_token_countが役に立たないということです。
RDBMS脳な私は「access_tokenにcredentials_key列を追加して、そこから引っ張ればいいじゃん」と思っていたのですが、そこはNoSQL。そんなことはできません。
Redisへ格納できる形式を色々と調べてみましたが、不可能でした…

## 解決策

途方にくれる私に天啓が舞い降りた。
https://tylerstroud.com/2014/11/18/storing-and-querying-objects-in-redis/
< 「セット型使おうぜ」

...access_token生成/破棄でcredentialsに紐づくセットを増減させて手動でindexingさせればよいのでは！？

ということで対応

```python
...
# set型でcredentialsに紐づく値=access_tokenを登録
client.sadd('credentials_has_access_tokens:::access_token:::aaaaaa_key', 'access_token:::aaaaaa_token')
> 1
# set型の検索
client.smembers('credentials_has_access_tokens:::access_token:::aaaaaa_key')
> {b'access_token:::aaaaaa_token'}
# もう一つトークン発行
client.set('access_token:::bbbbbb_token', json.dumps(access_token_params))
> True
client.get('access_token:::bbbbbb_token')
> b'{"expires_at": "2021-05-08T09:46:10.363778+00:00"}'
client.sadd('credentials_has_access_tokens:::access_token:::aaaaaa_key', 'access_token:::bbbbbb_token')
> 1
# 2つのaccess_tokenがcredentialsをキーに取得できる
client.smembers('credentials_has_access_tokens:::access_token:::aaaaaa_key')
> {b'access_token:::aaaaaa_token', b'access_token:::bbbbbb_token'}
```
これで当初の目的は達成できそうですね！
この対応で、下図CredentialsHasAccessTokensというcredentialsとaccess_tokenの集合ができました。
![redis02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/129996/56d8e7ea-e93f-23b4-f518-d5453c20a060.png)


なお、今回の設計ではsmembersで引っ張ってくるのがあくまでaccess_tokenと定義しているため、expires_atなどの内部情報はcredentials_keyから取得することはできません。

## Pythonコード化

上述はあくまでインタラクティブモードでの動作です。
ここから実際にPythonのコードに落とし込んでいきます。

```python
import redis
import json
import random
import secrets
import string

from typing import Optional, Tuple
from datetime import datetime
from dateutil import relativedelta

# keyを引数にしてcredentialsを取得する(access_token発行時にcredentialsを確認する時等)
def get_credentials(client: redis.Redis, key: str) -> Optional[dict]:
    if not (credentials := client.get(f'credentials:::{key}')):
        return None
    return json.loads(credentials)

# access_tokenを引数にしてtokenに紐づく情報(=ユーザー情報)を取得する
def get_user(client: redis.Redis, access_token: str) -> Optional[dict]:
    if not (user := client.get(f'access_token:::{access_token}')):
        return None
    return json.loads(user)

# access_tokenを生成すると同時にkey - access_tokenの集合に追加し、access_tokenとexpires_atを返却
def create_access_token(client: redis.Redis, key: str, expires_days: int = 7) -> Tuple[str, str]:
    access_token = secrets.token_urlsafe(64)
    expires_at = (datetime.now() + relativedelta.relativedelta(days=+expires_days)).isoformat(timespec='seconds')
    client.set(
        f'access_token:::{access_token}',
        json.dumps({'key': key, 'expires_at': expires_at}),
    )
    client.sadd(f'credentials_has_access_tokens:::{key}', access_token)
    return access_token, expires_at

# 引数のcredentials_keyで生成したaccess_tokenの集合を返却
def issued_access_tokens(client: redis.Redis, key) -> set:
    return client.smembers(f'credentials_has_access_tokens:::{key}')

# credentialsを生成し、key/secretを返却
def create_credentials(client: redis.Redis) -> Tuple[str, str]:
    key = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(32))
    params = {
        'secret':  secrets.token_urlsafe(64),
        'max_token_count': 3,
    }
    client.set(f'credentials:::{key}', json.dumps(params))
    return key, params['secret']
```

こんな感じになりました。
exampleなのでところどころ実用化する際には修正が必要になるかと思います。

## まとめ

* credentialsの作成と管理
* access_tokenの作成と管理
* credentiasが作成したaccess_tokenの取得

問題となったのは3つ目に関しては、集合を使うことで実現できましたが、今回の問題はPythonに限定されるわけではありません。
NoSQLは基本的に正規化の概念がないため、RDBMSで普通に行っていたことができず苦労するかもしれませんが、
考え方を変えることによっていくらでも実現は可能です。

redis-pyを使った良きPython+Redisがありますよう :pray: