もた日記

くだらないことを真面目にやる

Djangoメモ(19) : ユーザー認証を実装する〜サインアップ(ユーザー登録)

Python 3.6.4 Django 2.0.2

A Complete Beginner's Guide to Djangoのチュートリアルを参考にユーザー認証を実装してみる。


ユーザー認証

Djangoの認証システムを使用して下記機能が使えるユーザー認証を実装していく。
なお、チュートリアルはDjango 1.11で書かれているためDjango2.0ではもっと良い実装方法があるかもしれないが、まずはチュートリアル通りに実装してみる。

  • サインアップ(ユーザー登録)
  • ログイン
  • ログアウト
  • パスワードリセット
  • パスワード変更


accountsアプリ作成

掲示板アプリ用にboardsというアプリを作成しているが、ユーザー認証用のアプリを下記コマンドで作成する。

$ django-admin startapp accounts

ディレクトリ構成は以下のようになる。

├── myproject/
│  ├── accounts/   # 新しいユーザ認証アプリ
│  ├── boards/
│  ├── db.sqlite3
│  ├── manage.py
│  ├── myproject/
│  ├── static/
├── Pipfile
└── Pipfile.lock

アプリを有効にするためにsettings.pyINSTALLED_APPSに追加する。

INSTALLED_APPS = [
    'accounts.apps.AccountsConfig',
    'boards.apps.BoardsConfig',
    ...
]

なお、認証に関するアプリ、ミドルウェアはデフォルトで有効になっているので特にsettings.pyを変更する必要はない。


サインアップページ作成

サインアップページを作成するためにmyproject/urls.pysignup/の行を追加する。

from django.contrib import admin
from django.conf import settings
from django.urls import path, include
from accounts import views as accounts_views
from boards import views

urlpatterns = [
    path('', views.home, name='home'),
    path('signup/', accounts_views.signup, name='signup'),
    path('boards/<int:pk>/', views.board_topics, name='board_topics'),
    path('boards/<int:pk>/new/', views.new_topic, name='new_topic'),
    path('admin/', admin.site.urls),
]

from accounts import views as accounts_viewsのように別名を付けてimportしているが、これはboardと衝突しないようにするため(このurls.pyの書き方が悪いようなので後のチュートリアルで改善するとのこと)。

次にaccounts/views.pyを編集する。

from django.shortcuts import render

def signup(request):
    return render(request, 'signup.html')

テンプレートをレンダリングするだけの処理で、表示するtemplates/signup.htmlを作成する。

{% extends 'base.html' %}

{% block content %}
  <h2>Sign up</h2>
{% endblock %}

signup/にアクセスして表示されるか確認する。

f:id:wonder-wall:20180318221638p:plain


追加したサインアップ用のテストをaccounts/tests.pyに追加する。

from django.urls import reverse, resolve
from django.test import TestCase
from .views import signup

class SignUpTests(TestCase):
    def test_signup_status_code(self):
        url = reverse('signup')
        response = self.client.get(url)
        self.assertEquals(response.status_code, 200)

    def test_signup_url_resolves_signup_view(self):
        view = resolve('/signup/')
        self.assertEquals(view.func, signup)

テストを実行して通ることを確認する。

$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..................
----------------------------------------------------------------------
Ran 18 tests in 1.370s

OK
Destroying test database for alias 'default'...


ユーザー認証のページではパンくずリストは使わないので、パンくずリストを表示しているtemplates/base.htmlを変更する(<!-- HERE -->の部分)。

{% load static %}<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>{% block title %}Django Boards{% endblock %}</title>
    <link href="https://fonts.googleapis.com/css?family=Pacifico" rel="stylesheet">
    <link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}">
    <link rel="stylesheet" href="{% static 'css/app.css' %}">
    {% block stylesheet %}{% endblock %}  <!-- HERE -->
  </head>
  <body>
    {% block body %}  <!-- HERE -->
      <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <div class="container">
          <a class="navbar-brand" href="{% url 'home' %}">Django Boards</a>
        </div>
      </nav>
      <div class="container">
        <ol class="breadcrumb my-4">
          {% block breadcrumb %}
          {% endblock %}
        </ol>
        {% block content %}
        {% endblock %}
      </div>
    {% endblock body %}  <!-- AND HERE -->
  </body>
</html>

{% block stylesheet %}{% endblock %}はCSSを追加できるようにするためのもの。
{% block body %}{% block content %}より大きな<body>全体を含む範囲のため、全体を置き換えることができる。また、{% endblock body %}のように閉じるブロックに名前を付けるとコードが見やすくなる。

templates/signup.htmlを編集し、{% block content %}から{% block body %}に変える。

{% extends 'base.html' %}

{% block body %}
  <h2>Sign up</h2>
{% endblock %}

これでパンくずリストが表示されなくなった。

f:id:wonder-wall:20180318222707p:plain


フォーム作成

サインアップフォームを作成するためにDjangoビルトインのUserCreationFormを使用する。 accounts/views.pyを以下のように編集。

from django.contrib.auth.forms import UserCreationForm
from django.shortcuts import render

def signup(request):
    form = UserCreationForm()
    return render(request, 'signup.html', {'form': form})

続いてtemplates/signup.htmlを以下のように編集する。

{% extends 'base.html' %}

{% block body %}
  <div class="container">
    <h2>Sign up</h2>
    <form method="post" novalidate>
      {% csrf_token %}
      {{ form.as_p }}
      <button type="submit" class="btn btn-primary">Create an account</button>
    </form>
  </div>
{% endblock %}

ページにアクセスするとフォームが表示される。
UserCreationFormusername, password1, password2の3つのフィールドがある。

f:id:wonder-wall:20180318223013p:plain

Bootstrapが適用されていないのでtemplates/signup.htmlを編集し、前回作成したform.htmlincludeする。

{% extends 'base.html' %}

{% block body %}
  <div class="container">
    <h2>Sign up</h2>
    <form method="post" novalidate>
      {% csrf_token %}
      {% include 'includes/form.html' %}
      <button type="submit" class="btn btn-primary">Create an account</button>
    </form>
  </div>
{% endblock %}

f:id:wonder-wall:20180318223202p:plain

Bootstrapが適用されたが、パスワードの説明に<ul>などのHTMLタグが出力されてしまっている。
これはDjangoがデフォルトでは問題になりそうな特殊文字をエスケープしているためだが、今回のような問題のない説明文に対してはsafeフィルタでエスケープしないようにする。
templates/includes/form.htmlfield.help_textに対して|safeフィルタを追加する。

    {% if field.help_text %}
      <small class="form-text text-muted">
        {{ field.help_text|safe }}
      </small>
    {% endif %}

f:id:wonder-wall:20180318223459p:plain


ビューの処理

ビューの処理をaccounts/views.pyに記述する。

from django.contrib.auth import login
from django.contrib.auth.forms import UserCreationForm
from django.shortcuts import render, redirect

def signup(request):
    if request.method == 'POST':
        form = UserCreationForm(request.POST)
        if form.is_valid():
            user = form.save()
            login(request, user)
            return redirect('home')
    else:
        form = UserCreationForm()
    return render(request, 'signup.html', {'form': form})

基本的なフォームの処理と流れは同じで、フォームのデータに問題がなければuser = form.save()でユーザーインスタンスを作成する。
そしてlogin(request, user)メソッドでログイン処理を行い、homeページにリダイレクトする。

実際にデータを送信してみるが、最初は登録済みユーザ名とパスワードを未入力にして試してみる。

f:id:wonder-wall:20180318223844p:plain

図のようなバリデーションメッセージが表示される。
次に適切なユーザ名とパスワードを入力して送信してみる。

f:id:wonder-wall:20180318224435p:plain

homeページにリダイレクトされ登録は完了したようにみえる。
だが、これでは実際にログインできているのかわからないのでログイン中のユーザ名を表示するようにtemplates/base.htmlを変更する。

{% block body %}
  <nav class="navbar navbar-expand-sm navbar-dark bg-dark">
    <div class="container">
      <a class="navbar-brand" href="{% url 'home' %}">Django Boards</a>
      <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#mainMenu" aria-controls="mainMenu" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
      </button>
      <div class="collapse navbar-collapse" id="mainMenu">
        <ul class="navbar-nav ml-auto">
          <li class="nav-item">
            <a class="nav-link" href="#">{{ user.username }}</a>
          </li>
        </ul>
      </div>
    </div>
  </nav>

  <div class="container">
    <ol class="breadcrumb my-4">
      {% block breadcrumb %}
      {% endblock %}
    </ol>
    {% block content %}
    {% endblock %}
  </div>
{% endblock body %}

{{ user.username }}がユーザ名でナビゲーションバー(右上)にログイン中のユーザ名が表示されるようになる。

f:id:wonder-wall:20180318231224p:plain

なお、DjangoではCookieのsessionidキーでセッションIDを保持しているので、ログイン後にsessionidが追加されているのがわかる。

f:id:wonder-wall:20180321140652p:plain


データベースの確認

サインアップでユーザーが登録されているかデータベースを確認してみる。
ユーザー情報はauth_userテーブルに登録されるがスキーマ情報は以下のようになっている(見やすいようにフォーマット)。

sqlite> .schema auth_user
CREATE TABLE IF NOT EXISTS "auth_user"(
    "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "password" varchar(128) NOT NULL,
    "last_login" datetime NULL,
    "is_superuser" bool NOT NULL,
    "username" varchar(150) NOT NULL UNIQUE,
    "first_name" varchar(30) NOT NULL,
    "email" varchar(254) NOT NULL,
    "is_staff" bool NOT NULL,
    "is_active" bool NOT NULL,
    "date_joined" datetime NOT NULL,
    "last_name" varchar(150) NOT NULL
)
;

登録されているデータを見てみる(見やすいようにフォーマット)。
adminユーザはcreatesuperuserコマンドで既に作成しているユーザでis_superuseris_staff1になっているが、サインアップ画面で作成したtestuserの値は0になっている。
passwordカラムがパスワード情報でドキュメントによると<algorithm>$<iterations>$<salt>$<hash>というフォーマットで保存されている。

sqlite> select * from auth_user;
    1|
    pbkdf2_sha256$100000$yFAJbAMkVsTF$q9c97Ad5Zm5tKWdurCEg3o2ddKj7SeP7YGdX5lRC12c=|
    2018-03-07 15:29:17.736686|
    1|
    admin|
    |
    admin@example.com|
    1|
    1|
    2018-03-07 15:17:27.111360|

    2|
    pbkdf2_sha256$100000$ffdHNEg7WdJo$QEU0OkNts7VC8+6AwMFcKKZ9j10YXSsi9qn/d3kkl4o=|
    2018-03-21 04:58:45.723989|
    0|
    testuser|
    |
    |
    0|
    1|
    2018-03-21 04:58:45.568002|


まとめ

  • ユーザー認証用のアプリは別アプリとして作成
  • UserCreationFormというユーザー登録向けのフォームがある
  • |safeフィルタでエスケープしないようにできる
  • login(request, user)でログイン処理
  • CookieのsessionidキーでセッションIDを保持