Djangoの標準機能だけで期限つきリンクを作ってみる。

今回はなるべくDjangoの標準機能のみを使って,
期限（制限時間）付きのページを作ってみたいと思います。
ユーザ登録時とかによく見る**30分以内に〜**みたいなやつです。

##作ったもの
![スクリーンショット 2018-12-03 20.49.37.png](https://qiita-image-store.s3.amazonaws.com/0/94505/1828d385-4edb-90b9-a62d-efa2ea6fd00d.png)
「Create URL」で期限付きのURLリンクを生成して、
![スクリーンショット 2018-12-03 20.49.47.png](https://qiita-image-store.s3.amazonaws.com/0/94505/b2dad6fd-0b77-9518-2058-850f5702c78a.png)
リンク先が有効なURLかを検証してくれるといったものです。
ユーザ登録を必要とするアプリケーションなどではあるあるな要件だと思いますので、
生成したリンクはメール文面に貼り付けるなど、よしなに応用してみてください。

##開発環境
- macOS High Sierra 10.13.6
- Docker for Mac
    - Engine : 18.09.0
- Python : 3.7
- Django : 2.1

##環境構築
dockerイメージを作るところからいきましょう。

```:path/to/your/project
.
├── Dockerfile
└── requirements.txt
```
今回はDocker上でDjangoを動かしています。

```dockerfile:Dockerfile
FROM python:3.7

ENV APP_PATH /opt/apps

COPY requirements.txt $APP_PATH/
RUN pip install --no-cache-dir -r $APP_PATH/requirements.txt

WORKDIR $APP_PATH
```

```:requirements.txt
Django==2.1
```

上記のDockerfileから、django2.1というイメージを作成しました。

```bash
$ docker build -t django2.1 ./
```

先程の手順で作成したイメージを使ってDjangoアプリの雛形を作成します。

```bash
$ docker run --rm \
--mount type=bind,src=$(pwd),dst=/opt/apps \
django2.1 \
django-admin startproject my_docker_project .
```

コマンドが少し長くなってしまいましたが、
各オプションについては[こちら](https://qiita.com/kyhei_0727/items/e0eb4cfa46d71258f1be)の記事で解説しています。

ここまで来るとディレクトリが以下のようになっていると思います。

```:path/to/your/project
.
├── Dockerfile
├── manage.py
├── my_docker_project
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── requirements.txt
```

次に本題の期限付きURLを生成するアプリを追加します。

```bash
$ docker run --rm \
--mount type=bind,src=$(pwd),dst=/opt/apps \
django2.1 \
python manage.py startapp timestamp_signer
```
今回は「timestamp_signer」という名前で作成しました。
これをdjango側に認識させるために、
`./my_docker_project/settings.py`と
`./my_docker_project/urls.py`を修正しましょう。

```python:./my_docker_project/settings.py
#~中略~
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'timestamp_signer', #追加！
]
#~中略~
```
```python:./my_docker_project/urls.py
from django.contrib import admin
from django.urls import path,include #追加！

urlpatterns = [
    path('',include('timestamp_signer.urls')) ,#追加！
    path('admin/', admin.site.urls),
]
```

お疲れ様でした。
長くなってしまいましたが次の項から本命の期限付きURLについてです。

##期限付きURLを作ってみる。

はじめにルーティングを定義します。

```python:./timestamp_signer/urls.py
from django.urls import path
from timestamp_signer import views

app_name = 'timestamp_signer'
urlpatterns = [
    path('', views.IndexView.as_view(), name='index'), #URL生成用
    path('<str:token>/', views.IndexView.as_view(), name='index'),#URL検証用
]
```
今回は簡単のため、`localhost:8000`のようなリクエストでも
'localhost:8000/hogehoge/'のようなリクエストでも同じViewでさばいています。

次にViewです。
ぶっちゃけここだけ読んでいただければこの記事で伝えたいことはだいたい終わります。

```python:./timestamp_signer/views.py
from django.core.signing import TimestampSigner,BadSignature,SignatureExpired
from django.shortcuts import render
from django.views import View

import random
import string
from datetime import timedelta

EXPIRED_SECONDS = 5

class IndexView(View):
    template_name = 'timestamp_signer/index.html'
    timestamp_signer = TimestampSigner()

    def get_random_chars(self,char_num=30):
         return ''.join([random.choice(string.ascii_letters + string.digits) for i in range(char_num)])

    def get(self,request,token=None):
        context = {}
        context['expired_seconds'] = EXPIRED_SECONDS

        if token:
            try:
                unsigned_token = self.timestamp_signer.unsign(
                    token,
                    max_age=timedelta(seconds=EXPIRED_SECONDS)
                )
                context['message'] = '有効なトークンです!!!'
            except SignatureExpired:
                context['message'] = 'このトークンは期限切れです。'
            except BadSignature:
                context['message'] = 'トークンが正しくありません。'

        return render(request, self.template_name, context)

    def post(self,request):
        context = {}
        context['expired_seconds'] = EXPIRED_SECONDS
        token = self.get_random_chars()
        token_signed = self.timestamp_signer.sign(token)
        context['token_signed'] = token_signed
        return render(request, self.template_name, context)
```

本記事のキモとなるのが、
`TimestampSigner`モジュールです。
早速コンソール上で動作を確認してみましょう。

```bash
$ docker run --rm -it \
--mount type=bind,src=$(pwd),dst=/opt/apps \
django2.1:latest \
python manage.py shell
>>> from django.core.signing import TimestampSigner
>>> timestamp_signer = TimestampSigner()
>>> token_signed = timestamp_signer.sign('hoge')
>>> token_signed
'hoge:1gTokx:KLGAVyLSEA0ZF6r9FV3GNQsmqfY'
```
`TimestampSigner`のインスタンスメソッド`sign()`に何らかの文字列を渡すとそれが署名された形で返ってくるようです。

得られた文字列を検証するには`TimestampSigner`のインスタンスメソッド`unsign()`を使います。

```bash
>>> token_unsigned = timestamp_signer.unsign(token_signed)
>>> token_unsigned
'hoge'
>>> token_signed += 'abc' #署名文字列を改変！
>>> timestamp_signer.unsign(token_signed)
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/usr/local/lib/python3.7/site-packages/django/core/signing.py", line 187, in unsign
    result = super().unsign(value)
  File "/usr/local/lib/python3.7/site-packages/django/core/signing.py", line 170, in unsign
    raise BadSignature('Signature "%s" does not match' % sig)
django.core.signing.BadSignature: Signature "1gTokx:KLGAVyLSEA0ZF6r9FV3GNQsmqfYabc" does not match
```

一つ目の例では検証に成功し元の文字列`'hoge'`が返ってきましたが、
二つ目の改変後の文字列では`BadSignature`エラーが発生していることが確認できました。

続いて`token_signed`に有効期限を設けてみます。

```bash
>>> from datetime import timedelta
>>> token_signed = timestamp_signer.sign('hoge')
>>> timestamp_signer.unsign(token_signed,max_age=timedelta(seconds=10))
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/usr/local/lib/python3.7/site-packages/django/core/signing.py", line 197, in unsign
    'Signature age %s > %s seconds' % (age, max_age))
django.core.signing.SignatureExpired: Signature age 22.781551599502563 > 10.0 seconds
>>> timestamp_signer.unsign(token_signed,max_age=timedelta(seconds=1000))
'hoge'
```
一つ目の例では`max_age`に10秒を指定したところ、`SignatureExpired`エラーが発生し、
二つ目の例では`max_age`に1000秒を指定したところ、検証に成功したことが確認できました。

つまり、秒数を指定した`timedelta`オブジェクトを、
`unsign`メソッドの`max_age`に指定することで、生成した署名文字列がどれくらい前に発行されたを判定できることが分かりました！

ここでもう一度先程の`get`及び`post`メソッドを見てみましょう。

```python
def get(self,request,token=None):
    context = {}
    context['expired_seconds'] = EXPIRED_SECONDS

    if token:
        try:
            #URLに含まれる文字列を検証
            unsigned_token = self.timestamp_signer.unsign(
                token,
                max_age=timedelta(seconds=EXPIRED_SECONDS)
            )
            context['message'] = '有効なトークンです!!!'
        except SignatureExpired:
            context['message'] = 'このトークンは期限切れです。'
        except BadSignature:
            context['message'] = 'トークンが正しくありません。'

    return render(request, self.template_name, context)
```
URLに含まれる文字列を検証し、エラーの種類と有無によってメッセージを切り替えているのがわかると思います。

```python
def post(self,request):
    context = {}
    context['expired_seconds'] = EXPIRED_SECONDS
    token = self.get_random_chars()　#ランダムな文字列を生成
    token_signed = self.timestamp_signer.sign(token) #文字列を署名
    context['token_signed'] = token_signed
    return render(request, self.template_name, context)
```

POSTリクエストを受け取った時点で署名文字列を生成し、テンプレート側に渡しています。

##テンプレートで仕上げ！

さくっと行きましょう。
今回はCSSフレームワークに[Bulma](https://bulma.io/)を使いました。

```html:./timestamp_signer/templates/timestamp_signer/index.html
{% load static %}
<!DOCTYPE html>
<html>
 <head>
   <meta charset="utf-8">
   <meta name="viewport" content="width=device-width, initial-scale=1">
   <title>timestamp_signer_sample</title>
   <link href="{% static 'vendor/bulma/css/bulma.min.css' %}" rel="stylesheet" type="text/css">
 </head>
 <body>
   <section class="section">
     
    <div class="container">
      <form action="{% url 'timestamp_signer:index' %}" method="post">
        {% csrf_token %}
        <p style="margin-bottom:10px;">{{ expired_seconds }}秒間だけ有効なURLを生成します。</p>
        <button class="button is-primary" type="submit">Create URL</button>
      </form>

      <div style="margin-top:20px;">
        {% if token_signed %}
          <a href="{% url 'timestamp_signer:index' token_signed %}">{{token_signed}}</a>
        {% endif %}

        {% if message %}
          <h1> {{ message }} </h1>
        {% endif %}
      </div>

    </div>
  </section>
 </body>
</html>
```

##終わりに

お疲れ様でした！
これを早速ローカルで起動しましょう！

```bash
$ docker run --rm -it \
--mount type=bind,src=$(pwd),dst=/opt/apps \
-p 8000:8000 \
django2.1:latest \
python manage.py runserver 0.0.0.0:8000
```
[http://0.0.0.0:8000/](http://0.0.0.0:8000/)にアクセスすると...

<img width="358" alt="スクリーンショット 2018-12-05 22.18.44.png" src="https://qiita-image-store.s3.amazonaws.com/0/94505/50237009-4b1f-1556-2b43-b8f3a85008f4.png">
ボタンを押すと署名文字列のリンクが生成されます。
<img width="697" alt="スクリーンショット 2018-12-05 22.18.53.png" src="https://qiita-image-store.s3.amazonaws.com/0/94505/f42d4f11-7274-4546-9679-1297bdd488e0.png">
適当なタイミングでリンクにアクセスすると
<img width="308" alt="スクリーンショット 2018-12-05 22.19.26.png" src="https://qiita-image-store.s3.amazonaws.com/0/94505/3e9896d8-8577-ce46-54a6-1753faa8cdf9.png">

成功です！！！
今回は５秒でしたがViewファイルの`EXPIRED_SECONDS`を書き換えることで
任意の有効期限を持ったリンクを生成できます。

本記事の詳しい内容はDjango公式の[ここ](https://docs.djangoproject.com/en/2.1/topics/signing/)を参照いただければと思います。

ありがとうございました！