nikkie-ftnextの日記

イベントレポートや読書メモを発信

Django Girls Tutorial ExtensionsをDjango2系で動かす(Part 2/3) セキュリティ編

はじめに

いつも心は虹色に! nikkieです。
Django Girls Tutorial ExtensionsのDjango2系動作確認の第2弾です。
第1弾はこちら:Django Girls Tutorial ExtensionsをDjango2系で動かす(Part 1/3) #モグモグDjango - nikkie-ftnextの日記
先日の #モグモグDjango で確認したことをブログにまとめました。

背景

Django Girls Tutorial Extensionsでは、Django Girls Tutorialで作ったブログアプリに機能追加していきます。
ExtensionsのコードはDjango 1系で書かれており、Django Girls Tutorialの翻訳メンバーの間では「Django 2系に書き換えたいね」という話が出ていました。

Django 2系に書き換える対象は、3ページあります。

今回はセキュリティのチュートリアルを進める中で気づいたことを書きます。

ブログアプリで何が問題か

Django Girls Tutorialの中で、ログインしているユーザだけに投稿作成ボタンや編集ボタンが見えるようにしました。
参照:Djangoフォーム · Django Girls Tutorial の「セキュリティ」

これは新しい投稿の作成を完全に保護するものではありませんが、それは良い第一歩です。 私たちは拡張レッスンでより多くのセキュリティをカバーします。

確かに画面上にボタンが見えなくなったのですが、URLを推測すると記事が作れてしまうという問題があります。1
ログインしていないユーザでhttp://127.0.0.1:8000/post/new/にアクセスすると、記事の作成画面が表示されます。
f:id:nikkie-ftnext:20181110200323p:plain

セキュリティ対応

ログインが求められるように修正

URLを推測されても記事が作成できないようにする対応はそれほど大変ではありませんでした。
ビューの関数に@login_requiredを設定すると、ログインが求められるように変更できます。

blog/views.py

from django.contrib.auth.decorators import login_required # 追加
from django.shortcuts import redirect
# 省略

@login_required # 追加
def post_new(request):
    # 省略
    
@login_required # 追加
def post_edit(request, pk):
    # 省略

@login_required # 追加
def post_draft_list(request):
    # 省略

@login_required # 追加
def post_publish(request, pk):
    # 省略

@login_required # 追加
def post_remove(request, pk):
    # 省略

この時点ではログイン画面のテンプレートが用意されていないため、ログインしていないユーザで、http://127.0.0.1:8000/post/new/(記事作成のURL)にアクセスするとエラーとなります。
f:id:nikkie-ftnext:20181110200403p:plain
ですが、誰でも記事が作れる状態ではひとまず修正されました。

ログイン画面を用意する

ログイン画面用のURLの設定は、プロジェクト側で(mysite/urls.pyで)行います。

# 省略
from django.contrib.auth import views # 追加

urlpatterns = [
    path('admin/', admin.site.urls),
    path('accounts/login/', views.login, name='login'), # 追加
    path('', include('blog.urls')),
]

ログイン画面のテンプレートを追加しましょう。 blog/templatesの下にregistrationというフォルダを作り、login.htmlを置きます。
ログイン機能はDjangoに用意されたものを使うために、命名規則を守る必要があると理解しました。

blog/templates/registration/login.html

{% extends "blog/base.html" %}

{% block content %}
  {% if form.errors %}
    <p>Your username and password didn't match. Please try again.</p>
  {% endif %}

  <form method="POST" action="{% url 'login' %}">
    {% csrf_token %}
    <table>
      <tr>
        <td>{{ form.username.label_tag }}</td>
        <td>{{ form.username }}</td>
      </tr>
      <tr>
        <td>{{ form.password.label_tag }}</td>
        <td>{{ form.password }}</td>
      </tr>
    </table>

    <input type="submit" value="login">
    <input type="hidden" name="next" value="{{ next }}">
  </form>
{% endblock %}

テンプレートで使われているform.username.label_tagは、HTMLのlabel要素を作る関数だそうです。
https://docs.djangoproject.com/en/2.0/topics/forms/#rendering-fields-manually

ログインしていないユーザで、http://127.0.0.1:8000/post/new/(記事作成のURL)にアクセスすると、以下のようなログイン画面が表示されます!
(ただし、画像はログイン画面に直接アクセスしたときのものです。)
f:id:nikkie-ftnext:20181110200336p:plain

ログイン画面周りでは、あと少しだけコードを修正する必要があります。
ログイン画面のURL(http://127.0.0.1:8000/accounts/login/)を直接叩いてログインした場合、ログインしたあとにページが見つからずにエラーとなります。
f:id:nikkie-ftnext:20181124162627p:plain

この対応はmysite/settings.pyで行います。
末尾に以下の1行を追加しました。

LOGIN_REDIRECT_URL = '/'

この設定により、ログイン画面のURLを直接叩いた場合、ログインに成功後、記事一覧(http://127.0.0.1:8000/)が表示されます。

ログイン・ログアウトのためのリンクを追加

ログイン機能を使うためのリンクを追加します。

blog/templates/blog/base.html

    {% if user.is_authenticated %}
      <a href="{% url 'post_new' %}" class="top-menu"><span class="glyphicon glyphicon-plus"></span></a>
      <a href="{% url 'post_draft_list' %}" class="top-menu"><span class="glyphicon glyphicon-edit"></span></a>
    {% else %} <!-- 追加 -->
      <a href="{% url 'login' %}" class="top-menu"><span class="glyphicon glyphicon-lock"></span></a> <!-- 追加 -->
    {% endif %}

ログインしていない場合、ヘッダーにログイン画面に遷移するリンク(鍵のアイコン)が表示されます。 f:id:nikkie-ftnext:20181110200452p:plain

ログアウトの機能も実装します。

mysite/urls.py

# 省略

urlpatterns = [
    path('admin/', admin.site.urls),
    path('accounts/login/', views.login, name='login'),
    path('accounts/logout/', views.logout, name='logout', kwargs={'next_page': '/'}), # 追加
    path('', include('blog.urls')),
]

blog/templates/blog/base.html

    {% if user.is_authenticated %}
      <a href="{% url 'post_new' %}" class="top-menu"><span class="glyphicon glyphicon-plus"></span></a>
      <a href="{% url 'post_draft_list' %}" class="top-menu"><span class="glyphicon glyphicon-edit"></span></a>
      <p class="top-menu">Hello {{ user.username }} <small>(<a href="{% url 'logout' %}">Log out</a>)</small></p> <!-- 追加 -->
    {% else %}
      <a href="{% url 'login' %}" class="top-menu"><span class="glyphicon glyphicon-lock"></span></a>
    {% endif %}

ログインユーザの情報とログアウトのリンクがヘッダーに表示されるようになりました。
f:id:nikkie-ftnext:20181110195444p:plain

書き換えなくてもDjango2.0.9で動きました

ここまででDjango2系向けに書き換えたのは、urlpatternsのurl関数です(path関数にしました)。 Django2系でもurl関数は使えるので、Extensionsのコードのままでも動きました。
参考:Django 1.11 と 2.0 の違い (「現場で使える 基礎 Django」本の補講その1) - akiyoko blog

django.conf.urls.url() を使った書き方は、Django 2.0 でもそのまま使えるように互換性が保たれていますし、(後略)

mysite/urls.py

from django.conf.urls import url # 追加
from django.contrib import admin
from django.urls import path, include

from django.contrib.auth import views # 追加


urlpatterns = [
    path('admin/', admin.site.urls),
    url(r'^accounts/login/$', views.login, name='login'), # 追加
    url(r'^accounts/logout/$', views.logout, name='logout', kwargs={'next_page': '/'}), # 追加
    path('', include('blog.urls')),
]

mysite/urls.pyをDjango2系向けにアップデート

この記事を書く中で記述の食い違いに気づきました。

  • 原文(Django 1系):django.contrib.auth.views.login
  • セキュリティのバートで参照されているドキュメント2の書き方:django.contrib.auth.login

結論を言うと、mysite/urls.pyを以下のように書き換えた方がよいことがわかりました。

# 省略
urlpatterns = [
    path('admin/', admin.site.urls),
    path('accounts/login/', views.LoginView.as_view(), name='login'), # 変更
    path('accounts/logout/', views.LogoutView.as_view(next_page='/'), name='logout'), # 変更
    path('', include('blog.urls')),
]

これはDjango 2系で動く書き方です。
LoginView()を使う書き換えをしないと、Django 2.1.xで動かないと考えています。3
django.contrib.auth.views.loginを使った書き方は、Django 2.0.xまでで動く書き方で、2.1.xでは動かないという認識です)

LogoutView.as_view()では、引数にログアウト後に表示されるページ(記事一覧)のURLを渡しています。4
mysite/settings.pyにLOGOUT_REDIRECT_URL = '/'を指定することでも同じ動きが実現できましたが、Django Adminからログアウトした場合も記事一覧に遷移するという副作用がありました。

なお、django.contrib.auth.loginを使う書き方はビュー向けであるようで、importしてからpath('accounts/login/', login, name='login')のように使うとエラーとなりました。(TypeError: login() missing 1 required positional argument: 'user'

終わりに

Extensionsでログイン機能を触ったことで、Djangoでアプリケーションが作れそうな気がしてきました。

そして、残るはコメント機能。
Django2系の動作確認最終回でまたお会いしましょう。
最後までお読みいただき、ありがとうございました。

脚注


  1. PythonAnywhereで公開されているDjango Girls Tutorialのブログアプリは第三者が記事を作れる状態なので、なるべくExtensionsのセキュリティのパートまで進めていただくのを個人的には推奨します。

  2. ドキュメントのURLのうち、Djangoのバージョンだけ2.0に変えて参照しました:https://docs.djangoproject.com/en/2.0/topics/auth/default/#how-to-log-a-user-out

  3. django.contrib.auth.views.loginのソースを見たところ、'The login() view is superseded by the class-based LoginView().'RemovedInDjango21Warning)と記載されていました。Django 2.1.xではLoginView()を使う書き方に置き換わるようです。→リリースノートを確認したら削除されていました:https://docs.djangoproject.com/ja/2.1/releases/2.1/#features-removed-in-2-1

  4. コード例:Django 1.9 から 2.1 に上げる時の備忘録 - Qiita