7
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-12-05

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

##作ったもの
スクリーンショット 2018-12-03 20.49.37.png
「Create URL」で期限付きのURLリンクを生成して、
スクリーンショット 2018-12-03 20.49.47.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
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というイメージを作成しました。

$ docker build -t django2.1 ./

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

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

コマンドが少し長くなってしまいましたが、
各オプションについてはこちらの記事で解説しています。

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

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

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

$ 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を修正しましょう。

./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', #追加!
]
#~中略~
./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を作ってみる。

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

./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です。
ぶっちゃけここだけ読んでいただければこの記事で伝えたいことはだいたい終わります。

./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モジュールです。
早速コンソール上で動作を確認してみましょう。

$ 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()を使います。

>>> 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に有効期限を設けてみます。

>>> 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メソッドを見てみましょう。

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に含まれる文字列を検証し、エラーの種類と有無によってメッセージを切り替えているのがわかると思います。

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を使いました。

./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>

##終わりに

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

$ 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/にアクセスすると...

スクリーンショット 2018-12-05 22.18.44.png ボタンを押すと署名文字列のリンクが生成されます。 スクリーンショット 2018-12-05 22.18.53.png 適当なタイミングでリンクにアクセスすると スクリーンショット 2018-12-05 22.19.26.png

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

本記事の詳しい内容はDjango公式のここを参照いただければと思います。

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

7
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?