DjangoでPasskeyを実装する【実装編】

皆さん、お疲れ様です。
プラットフォーム事業部のTです。
前回、「DjangoでPasskey実装」という記事を投稿しました。
今回はその続きとして、パスキーを使った認証処理の実装を紹介します。
この記事は以下の方を対象としています。
★5 Djangoの開発経験が3年以上あります。
★4 Djangoの開発経験が1年以上あります。
★3 Webサイトの開発経験があり、これからDjangoを学習します。
★2 Python初級者で、簡単なプログラムコードが書けます。
★1 プログラミング未経験です。
django-otp-webauthnの選定理由
前回のパスキー導入の記事で、使用したdjango-otp-webauthnというライブラリについて選定理由を説明していなかったため、簡単に補足します。
まず、django-otp-webauthn はdjango-otpの拡張ライブラリであるため、TOTPやメールOTPといった他のMFA方式を後から追加しやすいという利点があります。これはdjango-otp-webauthn自体というよりdjango-otpの仕組みによる利点ですが、後述するdjango-passkeysでは得られないメリットです。
また、暗号処理やフロントエンドの実装まで一通り用意されているため、自前でWebAuthnを実装するよりも簡単に導入できます。
比較対象となる代表的な選択肢も、簡単に紹介します。
- django-passkeys
- django-allauth & allauth.mfa
1のdjango-passkeysは、django-otpに依存しないパスキー専用のパッケージです。
依存関係がシンプルな分、パスキー機能だけが必要な場合は導入コストを抑えられます。
2のdjango-allauth & allauth.mfaは、v64.0.0以降TOTPに加えてWebAuthn(パスキー)にも対応しているため、すでにallauthを使っているプロジェクトなら、django-otp系を持ち込まずにallauth.mfaだけでパスキーまでカバーできます。
今回は、将来的にTOTPなど他のMFA方式も併用する前提で、
django-otpのプラグイン構成に乗せられるdjango-otp-webauthnを選択しました。
前提となるプロジェクト
こちらも前回のパスキー導入の記事で触れておくべき内容でしたが、説明していなかったため、簡単に補足します。次のような構成のDjangoプロジェクトを前提としています。
認証はDjango標準のdjango.contrib.authによるセッション認証を利用しています。django-otp-webauthnは、この標準の認証基盤の上にパスキーを追加する形で動作します。そのため、独自のトークン認証(JWTなど)だけでセッションを使っていない構成では、本記事の手順がそのままでは適用できない点にご注意ください。
また、すでにユーザー登録とパスワードによるログインが実装済みであることを前提とします。本記事で扱う構成では、パスキーは「最初のログイン後に登録し、次回以降のログイン手段として使う」形で実装します。既存のパスワードログインを置き換える、または併用する形で組み込みます。そのため、本記事の構成では、事前にパスワードによるログインができる状態を前提としています。ユーザーモデルは、標準のUserでも、AUTH_USER_MODELで差し替えたカスタムユーザーモデルでも動作します。
実装
ここからは前回のパスキー導入で紹介しきれなかった、ビュー(View)やテンプレートの実装について解説します。
前回と同様、本記事ではログイン時の処理のみを扱います。パスキーの登録(registration)に関わる画面や処理は割愛し、「すでにパスキーが登録済みのユーザーがログインする」場面に絞って解説します。
モデル
WebAuthn(パスキー)のために独自で追加・変更したモデルはありません。ログインで使う公開鍵クレデンシャルは、django-otp-webauthnが同梱するモデルに保存されます。
パスキーの公開鍵は、ライブラリのWebAuthnCredentialモデルに保存されます。ログイン処理に関係する主なフィールドは次のとおりです。
- credential_id … どのパスキーかを特定するIDです。
- public_key … 署名の検証に使う公開鍵です。
- sign_count … 署名回数のカウンタで、リプレイ検知に使われます。
- user … その公開鍵を保有するユーザーです。
前回のフロー図でいう「9. 署名の検証」では、ブラウザから送られてきた署名を、このモデルに保存済みのpublic_keyで検証します。テーブル・カラム・マイグレーションはすべてライブラリが提供します。独自でmakemigrationsを実行する必要はなく、migrateだけで適用できます。
コード上でこのモデルを扱うときは、モデルを直接importせず、get_credential_model()経由で取得するのが、ライブラリで推奨される方法です。
from django_otp_webauthn.helpers import get_credential_model
Credential = get_credential_model() # ライブラリの WebAuthnCredential が返る
ビュー
ログイン処理のビューは、ほとんどがライブラリ側で完結しており、自前で書くビューはありません。 前回、config/urls.pyにincludeしたdjango_otp_webauthn.urlsが、ログインに必要な2つのエンドポイントを提供します。いずれもDRFベースで、ビューの中身はすべてライブラリ実装です。ログインに関係するのは次の2つです。
| URL名(otp_webauthn:) | 役割 |
| credential-authentication-begin /webauthn/authentication/begin/ | ログインchallengeを発行する |
| credential-authentication-complete /webauthn/authentication/complete/ | 署名を検証してログインを成立させる |
ポイントは、authentication-beginが未ログインの状態でも、HTTPステータスコード200を返すことです。パスワードを使わずにログインするため、challengeの発行にログインを要求しません。
ログイン画面そのものは、Django標準のLoginViewをそのまま利用します。自前のログインビューは作らず、accounts/urls.pyでテンプレートと認証フォームを指定するだけです。
path(
"login/",
LoginView.as_view(
template_name="accounts/login.html",
authentication_form=PasskeyAuthenticationForm,
),
name="login",
),
パスキーでのログインは、このパスワードフォームを通りません。ブラウザ側のライブラリJSがbegin/completeを呼び出し、complete側でWebAuthnBackendが署名を検証し、ログインセッションを確立します。前編フロー図の「8. 署名をサーバーへ送信 → 9. 署名の検証、セッションIDの発行」が、このcompleteエンドポイントに対応します。
フォーム
ログイン画面のusername入力欄に、パスキーの自動候補表示(autofill / conditional UI)を有効にするためのフォームのみを用意します。認証ロジック自体はDjango標準のままです。
from django.contrib.auth.forms import AuthenticationForm
class PasskeyAuthenticationForm(AuthenticationForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# autofill / conditional UI 用に webauthn を付与
self.fields["username"].widget.attrs["autocomplete"] = "username webauthn"
このフォームで行うのは、username入力欄のautocomplete属性に「username webauthn」を付与する処理だけです。これにより、ブラウザは保存済みのパスキーをusername欄の入力候補として提示します。
このフォームは、前章のとおりaccounts/urls.pyでLoginViewのauthentication_formとして差し込みます。認証の中身(パスワード照合)は標準のAuthenticationFormのままで、パスキーログインはこのフォームを経由しません。あくまで「入力欄にパスキーの候補を表示する」ためのフォームです。
テンプレート
ログイン画面のテンプレートに、既存のパスワードフォームを残したまま、その下にパスキーでログインするためのUIを追加します。<template>や<button>のidは、ライブラリのJSが探す固定のキーであるため、表示文言(日本語ラベル)は自由に変更できます。ただし、idは変更できません。
ライブラリのJSは、起動時に次の順で動きます。
- ブラウザがWebAuthnに対応しているか判定します。
- 対応していれば、available-templateの中身をplaceholderに複製して表示します。
- 非対応の場合は、unavailable-templateを表示します。
- ボタンのクリックまたはページ読み込みでWebAuthn のceremony(認証処理)を実行します。
- status-messageに進捗やエラーを表示します。
ログイン画面(login.html)の、パスキー部分の実装は次のとおりです。
{% load otp_webauthn %}
...
{# 既存のパスワードログイン form はそのまま残す #}
...
<div class="passkey-login">
<span id="passkey-verification-placeholder"></span>
<template id="passkey-verification-available-template">
<button type="button" id="passkey-verification-button">パスキーでログイン</button>
<div id="passkey-verification-status-message"></div>
</template>
<template id="passkey-verification-unavailable-template">
<p>お使いのブラウザはパスキーに対応していません。</p>
</template>
</div>
...
{% block extra_js %}
{% render_otp_webauthn_auth_scripts username_field_selector="input[name='username']" %}
{% endblock %}
認証用のidは次の5つです。HTML 上には自身で記述しますが、id 名はライブラリ側で固定されています。
- passkey-verification-placeholder
- passkey-verification-available-template
- passkey-verification-button
- passkey-verification-status-message
- passkey-verification-unavailable-template
render_otp_webauthn_auth_scriptsに渡しているusername_field_selectorは、autofill / conditional UIをどの入力欄に紐付けるかをライブラリJSに伝える引数です。ここではusername入力欄を指定しています。
このテンプレートタグはinclusiontagで、レンダリング時には次のようなHTMLに展開されます。
{{ configuration|json_script:"otp_webauthn_config" }}
{# begin/complete の URL や CSRF トークン #}
<script src="{% url 'otp_webauthn:js-i18n-catalog' %}"></script>
<script type="module" src=".../otp_webauthn_auth.js"></script>
configurationにはbegin/completeのURLやCSRFトークンが入るため、自前でAjaxやCSRFの設定を書く必要はありません。なお、このタグはrequestを参照するため、django.template.context_processors.requestが必要です(Djangoデフォルトで有効)。
まとめ
本記事では、登録済みのパスキーを使ったログイン処理を、django-otp-webauthnで実装しました。要点は以下のとおりです。
- WebAuthnの主要な処理(challengeの発行・署名検証・ブラウザ側のceremony JS・認証バックエンド)はライブラリが担当するため、これらを独自に実装する必要はありません。
- 独自で用意したのは、ログイン画面のフォーム(username欄にautocomplete="username webauthn"を付与)と、テンプレートのパスキーUI(固定idの枠とテンプレートタグの差し込み)だけです。
- モデルとビューはライブラリ提供のものをそのまま使い、ログイン画面自体もDjango標準のLoginViewのままです。
- パスキーでのログインはパスワードフォームを通らず、WebAuthnBackendが署名を検証してセッションを発行します。既存のパスワード認証と併用する形で組み込めます。
最後までお読みいただき、ありがとうございました。
今回はログイン処理を中心に紹介しました。パスキーの登録処理についても、機会があればあらためて解説します。
※本記事のサムネイル画像は、生成AIを使用して作成しています。

