Flask-Loginはremember meをどう実装しているか調査した

### 背景

- Flask-Loginでremember be機能を使うと自動ログインが実現できるが、Flask-LoginはDBにSessionIdを保存しているわけではない。

- 故に、いかにして自動ログイン実現されているのかがイメージつかなかった。

- 公式ドキュメントを読んでも詳細が書かれていなかった。

### [公式ドキュメント](https://flask-login.readthedocs.io/en/latest/#remember-me)によると

> By default, when the user closes their browser the Flask Session is deleted and the user is logged out. “Remember Me” prevents the user from accidentally being logged out when they close their browser. This does NOT mean remembering or pre-filling the user’s username or password in a login form after the user has logged out.

> Just pass remember=True to the login_user call. A cookie will be saved on the user’s computer, and then Flask-Login will automatically restore the user ID from that cookie if it is not in the session. 

login_user(remember=True)とすると、以前のセッション情報が残っていてセッションをリストアするようです。しかし、サーバ側でどのユーザの情報であるかというのを判定する必要性はのこります。通常は、DBのUserテーブルとブラウザのCookieに保存しておいた同一のSessionIdを、再アクセス時に突き合わせることによってどのユーザであるのかという判別をします。DBのSessionIdカラムを必要としないというのはどういうことなのでしょうか？
また、この実装だとブラウザにCookieとして保存されるユーザ情報やセッション情報をいじることで他のユーザとしてログインできる可能性が払拭できません。
この謎を調査していきます！

疑問1: サーバ側でいかにしてユーザを識別しているのか
疑問2: セキュリティをいかにして確保しているの

### さっそく結論から
- Flask-LoginはCookieに直接ユーザIDを保存して、それをリストアすることでユーザを識別する。
- Flask-Loginのremember meは、メッセージ認証の技術を使って、Cookieが改ざんされていないことをチェックすることでセキュリティを確保している。

実装の詳細を知りたい方は以下をお読みください！


### ソースコードを追う

[login_user()の実装](https://github.com/maxcountryman/flask-login/blob/main/flask_login/utils.py#L145-L192)

みやすいようにコメントは除外しておきました。

```Python
def login_user(user, remember=False, duration=None, force=False, fresh=True):
    if not force and not user.is_active:
        return False

    user_id = getattr(user, current_app.login_manager.id_attribute)()
    session['_user_id'] = user_id
    session['_fresh'] = fresh
    session['_id'] = current_app.login_manager._session_identifier_generator()

    if remember:
        session['_remember'] = 'set'
        if duration is not None:
            try:
                # equal to timedelta.total_seconds() but works with Python 2.6
                session['_remember_seconds'] = (duration.microseconds +
                                                (duration.seconds +
                                                 duration.days * 24 * 3600) *
                                                10**6) / 10.0**6
            except AttributeError:
                raise Exception('duration must be a datetime.timedelta, '
                                'instead got: {0}'.format(duration))

    current_app.login_manager._update_request_context_with_user(user)
    user_logged_in.send(current_app._get_current_object(), user=_get_user())
    return True
```

login_user()では、session['_remember']とsession['_remember_seconds']をセットするだけのようです。
Cookieにセッション情報を保存する処理や、反対にそれをリストアする処理を探していきます。

Cookieからセッション情報をリストアする処理は以下のようです。

[_load_user()](https://github.com/maxcountryman/flask-login/blob/main/flask_login/login_manager.py#L310-L348)

```Python
    def _load_user(self):

        ## 省略 ##

        # Load user from Remember Me Cookie or Request Loader
        if user is None:
            config = current_app.config
            ## COOKIE_NAMEのデフォルト値は'remember_me'
            cookie_name = config.get('REMEMBER_COOKIE_NAME', COOKIE_NAME)
            header_name = config.get('AUTH_HEADER_NAME', AUTH_HEADER_NAME)
            has_cookie = (cookie_name in request.cookies and
                          session.get('_remember') != 'clear')
            if has_cookie:
                cookie = request.cookies[cookie_name]
                user = self._load_user_from_remember_cookie(cookie)
            elif self._request_callback:
                user = self._load_user_from_request(request)
            elif header_name in request.headers:
                header = request.headers[header_name]
                user = self._load_user_from_header(header)

        return self._update_request_context_with_user(user)
```

ブラウザにCookieが保存されていたらself.\_load\_user\_from\_remember\_cookie(cookie)が呼ばれるみたいですね。

[_load_user_from_remember_cookie()](https://github.com/maxcountryman/flask-login/blob/main/flask_login/login_manager.py#L377-L389)

```Python
    def _load_user_from_remember_cookie(self, cookie):
        user_id = decode_cookie(cookie)
        if user_id is not None:
            session['_user_id'] = user_id
            session['_fresh'] = False
            user = None
            if self._user_callback:
                user = self._user_callback(user_id)
            if user is not None:
                app = current_app._get_current_object()
                user_loaded_from_cookie.send(app, user=user)
                return user
        return None
```

おっと、見えてきました。
decode\_cookie(cookie)でCookieに保存したuser_idを取り出しているようです。これで一つ目の疑問が解消されました。
サーバ側でいかにしてユーザを識別しているか？というと、Cookieに直接ユーザIDを保存して、それをリストアするんですね。
しかし、セキュティが確保できないので一般的にはこのような実装はしませんね。攻撃者がブラウザのCookieにユーザIDを設定できたら、そのユーザとしてログインできますもの。

decode\_cookie()というのが鍵を握っていそうなので、処理の詳細をみてみます。また、encode\_cookie()がdecode\_cookie()と反対の変換をしているみたいなので、encode\_cookie()も付記します。

[encode_cookie()とdecode_cookie()](https://github.com/maxcountryman/flask-login/blob/main/flask_login/utils.py#L29-L64)

```Python
def encode_cookie(payload, key=None):
    '''
    This will encode a ``unicode`` value into a cookie, and sign that cookie
    with the app's secret key.
    :param payload: The value to encode, as `unicode`.
    :type payload: unicode
    :param key: The key to use when creating the cookie digest. If not
                specified, the SECRET_KEY value from app config will be used.
    :type key: str
    '''
    return u'{0}|{1}'.format(payload, _cookie_digest(payload, key=key))


def decode_cookie(cookie, key=None):
    '''
    This decodes a cookie given by `encode_cookie`. If verification of the
    cookie fails, ``None`` will be implicitly returned.
    :param cookie: An encoded cookie.
    :type cookie: str
    :param key: The key to use when creating the cookie digest. If not
                specified, the SECRET_KEY value from app config will be used.
    :type key: str
    '''
    try:
        payload, digest = cookie.rsplit(u'|', 1)
        if hasattr(digest, 'decode'):
            digest = digest.decode('ascii')  # pragma: no cover
    except ValueError:
        return

    if safe_str_cmp(_cookie_digest(payload, key=key), digest):
        return payload

```

以下に、_cookie_digest()のコードも載せておきますが、hmacというPython標準ライブラリを使っています。sha512をハッシュ関数に、SECRET_KEYを鍵にして、メッセージ認証コードを生成しているみたいです。HMACにおけるメッセージがここではencodeする値(payload)となります。ちなみにメッセージ認証は、共通鍵と任意のハッシュ関数によってメッセージのダイジェスト(MAC値)を生成してメッセージと一緒に送ります。共通鍵をもっている受信者は同じようにダイジェストを計算して、受信したダイジェストと一致すればメッセージが改ざんされていないことがわかるという技術です。上の処理では、受信者と送信者が同一なのでサーバで設定したSECRET\_KEYを共通鍵としてCookieが改ざんされていないかどうかチェックしているみたいです。if sace\_str\_comで認証に失敗したら何も値を返さないようになっていて、\_load\_user\_from\_remember\_cookie()の if user\_id is not Noneがfalseになって、ユーザが返されないんですね。
これで二つ目の疑問も解消されました。メッセージ認証の技術を使って、Cookieが改ざんされていないことをチェックすることでセキュリティを確保しているんですね。

[_cookie_digest](https://github.com/maxcountryman/flask-login/blob/main/flask_login/utils.py#L354-L357)

```Python
def _cookie_digest(payload, key=None):
    key = _secret_key(key)

    return hmac.new(key, payload.encode('utf-8'), sha512).hexdigest()
```

### 気づき

- Flaskでアプリケーションを書くときに、SECRET\_KEYの値をos.random()で生成することがありますが、そうしてしまうとアプリの再起動によって以前にCookieに保存されたuser_idが無効な値となり、remember meが保持されないという事態が起きる。だからといって、SECRET_KEYを特定の値にしてソースコードにベタ書きするのも、AWSのKeysをソースコードにベタ書きするのが危ないのと同じように危ない。SECRET_KEYは他の秘密情報と同じようにクラウドベンダが提供するパラメータストアーなどを使ってアプリ起動時やコンテナ立ち上げ時に注入するのが良さそう。
- Flaskや周辺ライブラリは機能が少ない代わりにコードを追いやすい。Pythonの書き方や、アプリケーションのアーキテクチャの勉強に良さそう。

### 結論(再掲)
- Flask-LoginはCookieに直接ユーザIDを保存して、それをリストアすることでユーザを識別する。
- Flask-Loginのremember meは、メッセージ認証の技術を使って、Cookieが改ざんされていないことをチェックすることでセキュリティを確保している。