広告 プログラミング

【解説】DjangoとFIDO2を用いたPasskeyの実装

※本ページには、プロモーション(広告)が含まれています。

【解説】DjangoとFIDO2を用いたPasskeyの実装

最近、セキュアな方法でDjangoのログインを行う必要がありました。

このようなケースに対応するため、django-passkeysを用いて実現予定でしたが、私の環境では上手く動作しませんでした。

このため、今回は、上記のGitHubの実装を参考にしながら、FIDO2を用いてパスキーによるログイン処理を実装します。

ライブラリを組み合わせていくだけなので、実装難易度は低かったです。

ゆると
ゆると

GitHub上に実際に動かすことができるコード一式も用意したので、手元で動かしながら確認してみてください。

パスキーとは?

まずは、パスキーの概要について解説します。

パスワードを用いない認証方式

パスキーは、IPA(独立行政法人 情報処理推進機構)にて、以下のように説明されています。

パスキーとは、パスワードの代わりに生体認証(指紋認証や顔認証など)やデバイスのロック解除機能(PINコードなど)を使って、サービスのアカウントにログインする新しい認証方式のことです。パスワードの漏えいリスクが無く、より安全にログインできます。

https://www.ipa.go.jp/security/anshin/attention/2025/mgdayori20250828.html

具体的には、後述する「公開鍵暗号方式の仕組み」と「生体認証などの多要素認証」を組み合わせた認証方式になります。

公開鍵暗号方式の仕組みを採用

パスキーは、公開鍵暗号方式と呼ばれる方式を用いてユーザ認証を行います。

具体的には、事前に秘密鍵(下図の署名用の鍵)と公開鍵(下図の検証用の鍵)を用意し、公開鍵をサーバに保存します。

そして、ログイン時に、サーバから送られてきた乱数(下図のDATA)を秘密鍵で署名し、公開鍵で検証することでユーザ認証を行う仕組みです。

秘密鍵を取り出す際に、指紋認証や顔認証が用いられることが多いです。

ゆると
ゆると

パスワード認証との違い

パスワード認証とパスキーによる認証には、認証に用いる資格情報(credential)を外部に送信する/送信しないという違いがあります。

それぞれの違いと安全性の高さをまとめると以下のようになります。

認証方法資格情報の送信有無安全性
パスワード認証資格情報を送信する低い
パスキー認証資格情報を送信しない高い

特に、パスワード認証の場合は、盗聴やフィッシングにより、資格情報が盗まれる可能性があります。

ゆると
ゆると

パスキーによる認証の安全性

ここで、パスキーによる認証の安全性を整理しておきたいと思います。

パスキーによる認証の安全性

  1. 機密情報を外部に送信しない
  2. 誤認証が発生しない
  3. サイバー攻撃による漏洩リスクが低い

機密情報を外部に送信しない

パスキーで認証を行う場合、外部に送信するデータは「署名済みデータ」となります。

このため、認証時に機密情報を外部に送信することは、ありません

事前に公開鍵もサーバに送付しますが、こちらは読み取られても問題ないデータです。

ゆると
ゆると

このような仕組みのため、機密情報が外部に流出する可能性がない点がメリットとなります。

誤認証が発生しない

パスキーは、認証先のドメイン名と紐づけて管理されます。

このため、無関係なサイトでパスキーによる認証は、発生しません

これは、フィッシングサイトなど本家サイトと似せて作られたページの場合、パスキーではログインできなくなることを意味します。

この仕組みによって、詐欺サイトから情報が流出してしまう可能性が低くなるでしょう。

詐欺サイトでパスワード認証をした場合は防ぎきれないので、日頃から対策が必要になる点は変わりません。

ゆると
ゆると

サイバー攻撃による漏洩リスクが低い

パスキーで認証する場合、資格情報(ハッシュ化されたパスワードなど)が保存されないため、サイバー攻撃を受けた場合でも情報漏洩のリスクが低いです。

これは、認証時の秘密鍵が、端末(デバイス)の中に保存される仕組みとなっているためです。

また、公開鍵が漏洩する可能性が漏洩する可能性がありますが、公開鍵暗号方式を用いている以上、公開鍵のみで悪用するのは困難です。

このような仕組みで動くため、パスキーによる認証は安全性が高い方法となっています。

Djangoによるパスキーの実装

全体像は、以下に示す通りです。

極めてシンプルな構成ですが、詳細がややこしいので実装とともに紹介します。

また、ディレクトリ構成とそれぞれのファイルの意味は、以下に示す通りです。

passkey/
|-- static/passkey/js/
|   `-- passkey_api.js
|-- templates/passkey/
|   |-- passkey_base.html
|   |-- login.html
|   `-- passkey_list.html
|-- tests/
|   |-- __init__.py # テストコード
|   |-- conftest.py
|   |-- factories.py
|   |-- test_backends.py
|   |-- test_forms.py
|   |-- test_models.py
|   `-- test_views.py
|-- __init__.py
|-- admin.py
|-- apps.py
|-- backends.py  # 認証時のバックエンドの実装
|-- forms.py   # ログイン用のForm
|-- models.py  # FIDO2を用いたPasskeyの認証処理の実装
|-- urls.py   # パスキー一覧やログイン用のリンクへのパス
`-- views.py   # viewの実装

# 他のファイルは省略

あわせて、今回用いたライブラリも紹介しておきます。

ライブラリ名バージョン
django5.2
django-sslserver-v21.0
ua-parser1.0.1
user-agents2.2.0
fido22.0.0
sqlparse0.5.3

テスト用のライブラリなどは、docker/pyproject.tomlに記載しています。

ゆると
ゆると

以降では、サーバ側の実装とクライアント側の実装をそれぞれ紹介します。

サーバ側の実装

サーバ側は、以下の6つから構成されます。

モデル定義

パスキーのモデルは、以下を満たすように作成します。

パスキーのモデルの制約

  1. 登録済みユーザを外部キーとして持つ
  2. 資格情報(credential id)を持つ
  3. 登録済みユーザと資格情報のペアがユニークであることを保証する(同じユーザと資格情報のペアはレコードに1つのみ存在)
  4. 有効/無効を切り替えられる
  5. 作成日と最終使用日が読み取れる

これを踏まえると、テーブル設計は、以下のようになります。

カラム名意味備考
idレコードのID-
user登録済みユーザの外部キーcredential_idと組み合わせは、ユニーク制約を満たす。
nameパスキーの名称-
platformプラットフォーム名-
credential_id資格情報のIDuserカラム参照
token認証器の正当性を表すデータAttestedCredentialData型に従った形式で保持
is_enabled有効/無効-
date_joined作成日時-
last_used最終使用日-

この部分をDjangoで実装すると、以下のようになります。

class Passkey(models.Model):
  id = models.UUIDField(
    primary_key=True,
    default=uuid.uuid4,
    editable=False,
  )
  user = models.ForeignKey(
    settings.AUTH_USER_MODEL,
    verbose_name=_('User'),
    on_delete=models.CASCADE,
    related_name='passkeys',
  )
  name = models.CharField(
    _('Passkey name'),
    max_length=255,
  )
  platform = models.CharField(
    _('Platform'),
    max_length=255,
    default='',
  )
  credential_id = models.CharField(
    _('Credential ID'),
    max_length=255,
  )
  token = models.CharField(
    _('Token'),
    max_length=255,
    null=False,
    help_text=_('Attested credential data which is encoded by websafe-base64 string'),
  )
  is_enabled = models.BooleanField(
    _('Passkey status'),
    default=True,
  )
  date_joined = models.DateTimeField(
    _('Date joined'),
    auto_now_add=True,
  )
  last_used = models.DateTimeField(
    _('Last used'),
    null=True,
    default=None,
  )

  class Meta:
    verbose_name = _('passkey')
    verbose_name_plural = _('passkeys')
    constraints = [
      models.UniqueConstraint(fields=['user', 'credential_id'], name='passkey_unique_user_credential'),
    ]

パスキー登録開始機能

サーバがパスキーの登録依頼を受けた際は、WebAuthnで定義されるPublicKeyCredentialCreationOptionsの仕様に基づいて、資格情報を生成するためのオプションを返す必要があります。

実際にオプション生成は、FIDO2ライブラリがやってくれるので、ライブラリの利用者は、以下の情報を定義すればOKです。

用意するデータ(WebAuthn仕様で定義される名称)コード上の名称FIDO2で定義される型データの概要
useruser_entryPublicKeyCredentialUserEntity以下の3つをdict形式で定義
・name
・id
・display_name
credentialscredentialsSequence[AttestedCredentialData | PublicKeyCredentialDescriptor] | NoneAttestedCredentialDataクラスのインスタンスのリストを作成
・aaguid
・credential_id
・public_key
authenticatorAttachmentauthenticator_attachmentAuthenticatorAttachment | Nonesettings.pyにて、以下の3つのいずれかを指定
・PLATFORM
・CROSS_PLATFORM
・None

データの用意とライブラリの呼び出し方をpythonで実装すると以下のようになります。

  @classmethod
  def register_begin(cls, request):
    user = request.user
    server = cls.get_server(request)
    authenticator_attachment = getattr(settings, 'KEY_ATTACHMENT', None)
    username = user.get_username()
    user_entity = {
      'id': _pk_bytes(user.pk),
      'name': username,
      'displayName': str(user),
    }
    credentials = cls.get_credentials(user)
    data, state = server.register_begin(
      user_entity,
      credentials,
      resident_key_requirement=ResidentKeyRequirement.PREFERRED,
      authenticator_attachment=authenticator_attachment,
    )
    options = dict(data)
    request.session['fido2_state'] = state

    return options

最終的に、以下のようなJSONデータが返却されます。

{
  "publicKey": {
    "challenge": 0x00112233,  # サーバにて適当な乱数を発生させ、byte型データとして格納される
    "rp": {
      "id": "acme.com",  # 通常はFQDN
      "name": "ACME Corporation"
    },
    "user": {
      "id": 0x123450abcdef, # ユーザを一意に識別するためのID(byte型)
      "name": "jamiedoe",
      "displayName": "Jamie Doe"
    },
    "pubKeyCredParams": [
      {
        "type": "public-key",
        "alg": -7
      }
    ]
  }
}

上記のデータに関するクライアント側の処理は、PasskeyAPI.RegisterPasskeyメソッドを参照してください。

ゆると
ゆると

パスキー登録完了機能

クライアント側でWebAuthnのPublicKeyCredential仕様に従ったデータが送信されてくるので、このデータに基づいてパスキーを登録します。

こちらも、細かい処理はFIDO2ライブラリがやってくれるので、今回は、クライアント側から受け取ったデータをそのまま渡すスタイルで実装しました。

  @classmethod
  def register_complete(cls, request):
    logger = logging.getLogger(__name__)

    try:
      if 'fido2_state' not in request.session:
        status_code, message = 401, _('FIDO Status can’t be found, please try again.')
      else:
        server = cls.get_server(request)
        state = request.session.pop('fido2_state')
        data = json.loads(request.body)
        authenticator_data = server.register_complete(state, response=data)
        platform = cls.get_platform(request)
        # Create the passkey record
        cls.objects.create(
          user=request.user,
          name=data.get('key_name', platform),
          platform=platform,
          credential_id=data.get('id'),
          token=websafe_encode(authenticator_data.credential_data),
          is_enabled=True,
        )
        status_code, message = 200, _('Complete the registeration.')
    except Exception as ex:
      logger.error(str(ex))
      status_code, message = 500, _('Error on server, please try again later.')

    return status_code, message

以上で、パスキーの登録処理は完了です。

続いて、登録したパスキーを用いて、認証を行う為の処理を実装していきましょう。

パスキー認証開始機能

認証開始時は、FIDO2ライブラリが必要な処理をすべてになってくれるため、ライブラリを使う側はほとんどやることがないです。

FIDO2ライブラリのAPI仕様に沿って、authenticate_beginメソッドを呼び出します。

  @classmethod
  def authenticate_begin(cls, request):
    if request.user.is_authenticated:
      credentials = cls.get_credentials(request.user)
    else:
      credentials = []
    server = cls.get_server(request)
    data, state = server.authenticate_begin(credentials)
    options = dict(data)
    request.session['fido2_state'] = state

    return options

返却されるデータは、WebAuthnのPublicKeyCredentialRequestOptions仕様に従ったものとなります。

今回は省略しますが、具体的なデータ仕様が気になる方は、コチラをご覧ください。

パスキー認証完了機能

クライアントから送られてきた署名済みデータを検証し、ログイン処理を行うための機能となります。

認証処理を実行する都合上、今回は、こちらの機能を後述するバックエンドから呼び出されるようにしました。

  @classmethod
  def authenticate_complete(cls, request):
    logger = logging.getLogger(__name__)
    data = json.loads(request.POST.get('passkey'))
    user_pk = _pk_value(websafe_decode(data['response']['userHandle']))
    credential_id = data['id']

    try:
      instance = cls.objects.get(
        user__pk=user_pk,
        credential_id=credential_id,
        is_enabled=True,
      )
      server = instance.get_server(request)
      credentials = [AttestedCredentialData(websafe_decode(instance.token))]
      state = request.session.pop('fido2_state')
      # Authentication
      server.authenticate_complete(state, credentials=credentials, response=data)
      # Update current instance data
      instance.last_used = timezone.now()
      instance.save()
    except (cls.DoesNotExist, ValueError):
      instance = None
    except Exception as ex:
      logger.error(str(ex))
      instance = None

    return instance

処理自体は単純で、登録済みのユーザ情報をもとにパスキーを取り出し、パスキーをもとに送信された署名済みデータを検証します。

細かい検証処理もFIDO2ライブラリがやってくれるため、ライブラリ利用者は該当メソッドを呼び出すだけで済みます。

パスキー認証バックエンド

サーバ側の最後の機能として、ユーザ名/パスワード認証にパスキー認証を追加したバックエンドについて解説します。

実装自体はシンプルで、ユーザ名とパスワードが設定されていない場合、署名済みデータを用いてログイン処理を行う仕組みとなっています。

Djangoでの実装結果は、以下に示す通りです。

from django.contrib.auth.backends import ModelBackend
from django.utils.translation import gettext_lazy as _
from .models import Passkey

class PasskeyModelBackend(ModelBackend):
  def update_passkey_session(self, request, instance=None):
    if instance is None:
      request.session['passkey'] = {
        'use_passkey': False,
        'name': None,
        'id': None,
        'platform': None,
        'cross_platform': None,
      }
      user = None
    else:
      request.session['passkey'] = {
        'use_passkey': True,
        'name': instance.name,
        'id': str(instance.pk),
        'platform': instance.platform,
        'cross_platform': instance.get_platform(request) == instance.platform,
      }
      user = instance.user

    return user

  def authenticate(self, request, username='', password='', **kwargs):
    if request is None:
      raise Exception(_('`request` is required for passkey.backends.PasskeyModelBackend.'))

    if username and password:
      self.update_passkey_session(request)
      user = super().authenticate(request, username=username, password=password, **kwargs)
    else:
      passkey = request.POST.get('passkey')

      if passkey is None:
        raise Exception(_('`passkey` is required in request.POST.'))
      elif passkey != '':
        instance = Passkey.authenticate_complete(request)
        user = self.update_passkey_session(request, instance)
      else:
        user = None

    return user

さらに、セッション情報としてパスキーを用いてログインしたかどうかも分かるようにしています。

具体的なデータ構造は、以下に示す通りです。

要素ユーザ名/パスワード認証の場合パスキー認証の場合
use_passkeyFalseTrue
nameNoneパスキー登録時に設定した名称(パスキーモデルのnameが該当)
idNoneパスキーモデルのインスタンスID(UUID)
platformNoneプラットフォーム名(Apple, Amazon, Microsoft, Google, Unknownのいずれか)
cross_platformNoneクロスプラットフォーム対応か否か(True/False

以上で、サーバ側の機能実装は完了です。

省略した機能も含めて、すべての機能を掲載しておきます。

#
# passkey/models.py
#
import json
import logging
import uuid

from django.db import models
from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from fido2.server import Fido2Server
from fido2.utils import websafe_decode, websafe_encode
from fido2.webauthn import (
  PublicKeyCredentialRpEntity,
  AttestedCredentialData,
  ResidentKeyRequirement,
)
from user_agents.parsers import parse as ua_parse

def _pk_bytes(pk):
  if isinstance(pk, int):
    ret = pk.to_bytes(8)
  elif isinstance(pk, uuid.UUID):
    ret = pk.bytes
  else:
    ret = str(pk).encode('utf-8')

  return ret

def _pk_value(data):
  UserModel = get_user_model()
  field = UserModel._meta.pk

  if isinstance(field, models.IntegerField):
    ret = int.from_bytes(data)
  elif isinstance(field, models.UUIDField):
    ret = uuid.UUID(bytes=data)
  else:
    ret = data.decode('utf-8')

  return ret

class Passkey(models.Model):
  id = models.UUIDField(
    primary_key=True,
    default=uuid.uuid4,
    editable=False,
  )
  user = models.ForeignKey(
    settings.AUTH_USER_MODEL,
    verbose_name=_('User'),
    on_delete=models.CASCADE,
    related_name='passkeys',
  )
  name = models.CharField(
    _('Passkey name'),
    max_length=255,
  )
  platform = models.CharField(
    _('Platform'),
    max_length=255,
    default='',
  )
  credential_id = models.CharField(
    _('Credential ID'),
    max_length=255,
  )
  token = models.CharField(
    _('Token'),
    max_length=255,
    null=False,
    help_text=_('Attested credential data which is encoded by websafe-base64 string'),
  )
  is_enabled = models.BooleanField(
    _('Passkey status'),
    default=True,
  )
  date_joined = models.DateTimeField(
    _('Date joined'),
    auto_now_add=True,
  )
  last_used = models.DateTimeField(
    _('Last used'),
    null=True,
    default=None,
  )

  class Meta:
    verbose_name = _('passkey')
    verbose_name_plural = _('passkeys')
    constraints = [
      models.UniqueConstraint(fields=['user', 'credential_id'], name='passkey_unique_user_credential'),
    ]

  def __str__(self):
    return f'{self.user}({self.platform})'

  def has_update_permission(self, user):
    return self.user.pk == user.pk

  def has_delete_permission(self, user):
    return self.has_update_permission(user) and not self.is_enabled

  def toggle_passkey_status(self):
    self.is_enabled = not self.is_enabled

  @staticmethod
  def get_server(request=None):
    fido_server_id = getattr(settings, 'FIDO_SERVER_ID')
    fido_server_name = getattr(settings, 'FIDO_SERVER_NAME')
    # Get server id and server name
    server_id = fido_server_id(request) if callable(fido_server_id) else str(fido_server_id)
    server_name = fido_server_name(request) if callable(fido_server_name) else str(fido_server_name)
    # Get relying party and server
    relying_party = PublicKeyCredentialRpEntity(id=server_id, name=server_name)
    server = Fido2Server(relying_party)

    return server

  @staticmethod
  def get_platform(request):
    user_agent = ua_parse(request.META['HTTP_USER_AGENT'])
    device = user_agent.device.family
    os = user_agent.os.family
    browser = user_agent.browser.family

    if any([device in ['iPhone', 'iPad', 'iPod', 'AppleTV'], os in ['iOS', 'Mac OS X'], browser in ['Chrome Mobile iOS', 'Safari']]):
      platform = 'Apple'
    elif any([key in device for key in ['Kindle', 'AFTS', 'AFTB', 'AFTM', 'AFTT']]):
      platform = 'Amazon'
    elif 'Windows' in os:
      platform = 'Microsoft'
    elif any(['Android' in os, 'Linux' in os and 'Chrome' in browser, 'Chrome OS' in os]):
      platform = 'Google'
    else:
      platform = 'Unknown'

    return platform

  @staticmethod
  def get_credentials(user):
    tokens = Passkey.objects.filter(user=user).values_list('token', flat=True)
    credentials = [AttestedCredentialData(websafe_decode(token)) for token in tokens]

    return credentials

  @classmethod
  def register_begin(cls, request):
    user = request.user
    server = cls.get_server(request)
    authenticator_attachment = getattr(settings, 'KEY_ATTACHMENT', None)
    username = user.get_username()
    user_entity = {
      'id': _pk_bytes(user.pk),
      'name': username,
      'displayName': str(user),
    }
    credentials = cls.get_credentials(user)
    data, state = server.register_begin(
      user_entity,
      credentials,
      resident_key_requirement=ResidentKeyRequirement.PREFERRED,
      authenticator_attachment=authenticator_attachment,
    )
    options = dict(data)
    request.session['fido2_state'] = state

    return options

  @classmethod
  def register_complete(cls, request):
    logger = logging.getLogger(__name__)

    try:
      if 'fido2_state' not in request.session:
        status_code, message = 401, _('FIDO Status can’t be found, please try again.')
      else:
        server = cls.get_server(request)
        state = request.session.pop('fido2_state')
        data = json.loads(request.body)
        authenticator_data = server.register_complete(state, response=data)
        platform = cls.get_platform(request)
        # Create the passkey record
        cls.objects.create(
          user=request.user,
          name=data.get('key_name', platform),
          platform=platform,
          credential_id=data.get('id'),
          token=websafe_encode(authenticator_data.credential_data),
          is_enabled=True,
        )
        status_code, message = 200, _('Complete the registeration.')
    except Exception as ex:
      logger.error(str(ex))
      status_code, message = 500, _('Error on server, please try again later.')

    return status_code, message

  @classmethod
  def authenticate_begin(cls, request):
    if request.user.is_authenticated:
      credentials = cls.get_credentials(request.user)
    else:
      credentials = []
    server = cls.get_server(request)
    data, state = server.authenticate_begin(credentials)
    options = dict(data)
    request.session['fido2_state'] = state

    return options

  @classmethod
  def authenticate_complete(cls, request):
    logger = logging.getLogger(__name__)
    data = json.loads(request.POST.get('passkey'))
    user_pk = _pk_value(websafe_decode(data['response']['userHandle']))
    credential_id = data['id']

    try:
      instance = cls.objects.get(
        user__pk=user_pk,
        credential_id=credential_id,
        is_enabled=True,
      )
      server = instance.get_server(request)
      credentials = [AttestedCredentialData(websafe_decode(instance.token))]
      state = request.session.pop('fido2_state')
      # Authentication
      server.authenticate_complete(state, credentials=credentials, response=data)
      # Update current instance data
      instance.last_used = timezone.now()
      instance.save()
    except (cls.DoesNotExist, ValueError):
      instance = None
    except Exception as ex:
      logger.error(str(ex))
      instance = None

    return instance
#
# passkey/backends.py
#
from django.contrib.auth.backends import ModelBackend
from django.utils.translation import gettext_lazy as _
from .models import Passkey

class PasskeyModelBackend(ModelBackend):
  def update_passkey_session(self, request, instance=None):
    if instance is None:
      request.session['passkey'] = {
        'use_passkey': False,
        'name': None,
        'id': None,
        'platform': None,
        'cross_platform': None,
      }
      user = None
    else:
      request.session['passkey'] = {
        'use_passkey': True,
        'name': instance.name,
        'id': str(instance.pk),
        'platform': instance.platform,
        'cross_platform': instance.get_platform(request) == instance.platform,
      }
      user = instance.user

    return user

  def authenticate(self, request, username='', password='', **kwargs):
    if request is None:
      raise Exception(_('`request` is required for passkey.backends.PasskeyModelBackend.'))

    if username and password:
      self.update_passkey_session(request)
      user = super().authenticate(request, username=username, password=password, **kwargs)
    else:
      passkey = request.POST.get('passkey')

      if passkey is None:
        raise Exception(_('`passkey` is required in request.POST.'))
      elif passkey != '':
        instance = Passkey.authenticate_complete(request)
        user = self.update_passkey_session(request, instance)
      else:
        user = None

    return user

WebAPI定義

認証周りの機能が実装できたので、後はDjangoのviewの定義を行えば、クライアント側から利用できるようになります。

今回、ログイン処理とパスキーの有効/無効を切り替えるために、formを用意しました。

#
# passkey/forms.py
#
from django import forms
from django.contrib.auth import authenticate
from django.contrib.auth.forms import AuthenticationForm
from django.utils.translation import gettext_lazy as _
from django.views.decorators.debug import sensitive_variables
from .models import Passkey

class BasePasskeyAuthenticationForm(AuthenticationForm):
  passkey = forms.CharField(
    label=_('Passkey'),
    required=False,
    widget=forms.HiddenInput(attrs={'id': 'passkey'}),
  )

  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    self.fields['username'].widget.attrs['autocomplete'] = 'username webauthn'
    self.fields['username'].required = False
    self.fields['password'].required = False

  @sensitive_variables()
  def clean(self):
    username = self.cleaned_data.get('username', '')
    password = self.cleaned_data.get('password', '')
    self.user_cache = authenticate(self.request, username=username, password=password)
    # Check authenticated result
    if self.user_cache is None:
      raise self.get_invalid_login_error()
    else:
      self.confirm_login_allowed(self.user_cache)

    return self.cleaned_data

class PasskeyAuthenticationForm(BasePasskeyAuthenticationForm):
  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)

    for field in self.fields.values():
      classes = field.widget.attrs.get('class', '')
      field.widget.attrs['class'] = f'{classes} form-control'

class PasskeyStatusUpdateForm(forms.ModelForm):
  class Meta:
    model = Passkey
    fields = []

  def save(self, commit=True):
    instance = super().save(commit=False)
    instance.toggle_passkey_status()

    if commit:
      instance.save()

    return instance

ログインフォームをカスタマイズする場合、BasePasskeyAuthenticationFormクラスを継承すれば良い構成としました。

ゆると
ゆると

上記のformを用いてviewを定義した結果は、以下に示す通りです。

#
# passkey/views.py
#
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.auth.views import LoginView, LogoutView
from django.http import JsonResponse
from django.urls import reverse_lazy, reverse
from django.views.generic import (
  View,
  ListView,
  UpdateView,
  DeleteView,
)
from .models import Passkey
from .forms import PasskeyStatusUpdateForm, PasskeyAuthenticationForm

class PasskeyListView(LoginRequiredMixin, ListView):
  model = Passkey
  template_name = 'passkey/passkey_list.html'
  paginate_by = 50
  context_object_name = 'passkeys'

  def get_queryset(self):
    return self.request.user.passkeys.all().order_by('-date_joined')

class PasskeyStatusUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
  raise_exception = True
  http_method_names = ['post']
  model = Passkey
  form_class = PasskeyStatusUpdateForm
  success_url = reverse_lazy('passkey:passkey_list')

  def test_func(self):
    instance = self.get_object()
    is_valid = instance.has_update_permission(self.request.user)

    return is_valid

class PasskeyDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView):
  raise_exception = True
  http_method_names = ['post']
  model = Passkey
  success_url = reverse_lazy('passkey:passkey_list')

  def test_func(self):
    instance = self.get_object()
    is_valid = instance.has_delete_permission(self.request.user)

    return is_valid

class RegisterPasskey(LoginRequiredMixin, View):
  raise_exception = True
  http_method_names = ['get', 'post']

  def get(self, request, *args, **kwargs):
    options = Passkey.register_begin(request)
    response = JsonResponse(options, json_dumps_params={'ensure_ascii': False})

    return response

  def post(self, request, *args, **kwargs):
    status_code, message = Passkey.register_complete(request)
    response = JsonResponse({'message': message}, json_dumps_params={'ensure_ascii': False}, status=status_code)

    return response

class BeginPasskeyAuthentication(View):
  raise_exception = True
  http_method_names = ['get']

  def get(self, request, *args, **kwargs):
    options = Passkey.authenticate_begin(request)
    response = JsonResponse(options, json_dumps_params={'ensure_ascii': False})

    return response

class PasskeyLoginView(LoginView):
  redirect_authenticated_user = True
  form_class = PasskeyAuthenticationForm
  template_name = 'passkey/login.html'

class PasskeyLogoutView(LogoutView):
  pass

登録済みパスキーを一覧表示するためのviewもあわせて定義しています。

ゆると
ゆると

urlの定義は、以下のようにしました。

#
# passkey/urls.py
#
from django.urls import path
from . import views

app_name = 'passkey'

urlpatterns = [
  path('passkey-list', views.PasskeyListView.as_view(), name='passkey_list'),
  path('update-passkey/', views.PasskeyStatusUpdateView.as_view(), name='update_passkey_status'),
  path('delete-passkey/', views.PasskeyDeleteView.as_view(), name='delete_passkey'),
  path('register-passkey', views.RegisterPasskey.as_view(), name='register_passkey'),
  path('begin-passkey-authentication', views.BeginPasskeyAuthentication.as_view(), name='begin_passkey_authentication'),
  path('login', views.PasskeyLoginView.as_view(), name='login'),
  path('logout', views.PasskeyLogoutView.as_view(), name='logout'),
]

クライアント側の実装

続いて、クライアント側の実装を行います。

クライアント側の実装

今回、極力ページ構成に依存しないようにするため、Passkeyによる処理をAPIとして分離しました。

パスキー登録機能

処理自体は単純で、サーバ側のregister-passkeyにGETメソッドでリクエスト、POSTメソッドで公開鍵の登録を要求するだけです。

素直に実装すると、以下のようになると思います。

  PasskeyAPI.RegisterPasskey = (registerURL, keyName, csrftoken, callback) => {
    fetch(registerURL, { method: 'GET' }).then((response) => {
      if (!response.ok) {
        throw new Error('Cannot get registration data.');
      }

      return response.json();
    }).then((data) => {
      const options = makeCredOptions(data);

      return navigator.credentials.create(options);
    }).then((credential) => {
      const headers = {
        'Content-Type': 'application/json',
        'X-CSRFToken': csrftoken,
      };
      const jsonData = publicKeyCredentialToJSON(credential);
      jsonData['key_name'] = keyName;

      return fetch(registerURL, {
        method: 'POST',
        headers: headers,
        body: JSON.stringify(jsonData),
      });
    }).then((response) => {
      return response.json().then((data) => {
        callback(response.ok, data.message);
      });
    }).catch((err) => {
      callback(false, err);
    });
  };

Djangoの場合、POSTメソッドでリクエストを送信する際はcsrf_tokenが必要になるため、引数で渡しています。

ゆると
ゆると

また、APIの呼び出し側の実装例は、以下に示す通りです。

{# templates/passkey/passkey_base.html #}
{% extends 'base.html' %}
{% block header %}
{{ block.super }}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
{% endblock %}
{% block content %}
{{ block.super }}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.min.js" crossorigin="anonymous" integrity="sha384-fbbOQedDUMZZ5KreZpsbe1LCZPVmfTnH7ois6mU1QK+m14rQ1l2bGBq41eYeM/fS"></script>
{% endblock %}

デザイン部分を簡略化するため、Bootstrap5.3を利用しています。

ゆると
ゆると
{# templates/passkey/passkey_list.html #}
{% extends 'passkey/passkey_base.html' %}
{% load static %}
{% load i18n %}

{% block content %}
{{ block.super }}
<div class="row justify-content-center">
  <div class="col">
    <div class="row row-cols-1 g-2">
      <div class="col">
        <button> type="button" class="btn btn-success w-100" data-bs-toggle="modal" data-bs-target="#register-passkey-modal">
          {% trans "Register a new passkey" %}
        </button>
      </div>
      {% if passkeys %}
      <div class="col">
        <div class="table-responsive">
          <table class="table">
            <thead>
              <tr class="align-middle">
                <th> scope="col">{% trans "Name" %}</th>
                <th> scope="col">{% trans "Platform" %}</th>
                <th> scope="col">{% trans "Date joined" %}</th>
                <th> scope="col">{% trans "Last used" %}</th>
                <th> colspan="2">{% trans "Operation" %}</th>
              </tr>
            </thead>
            <tbody class="table-group-divider">
            {% for instance in passkeys %}
              <tr class="align-middle">
                {% with table_css=instance.is_enabled|yesno:',table-secondary' %}
                <td> scope="row" class="{{ table_css }}">{{ instance.name }}</td>
                <td> class="{{ table_css }}">{{ instance.platform }}</td>
                <td> class="{{ table_css }}">{{ instance.date_joined|date:"Y/m/d H:i:s" }}</td>
                <td> class="{{ table_css }}">{% if instance.last_used %}{{ instance.last_used|date:"Y/m/d H:i:s" }}{% else %}-{% endif %}</td>
                {% endwith %}
                <td>
                  <form action="{% url 'passkey:update_passkey_status' pk=instance.pk %}" class="js-update-passkey-status-form" method="POST">
                    {% csrf_token %}
                    <button> type="submit" class="btn btn-outline-{{ instance.is_enabled|yesno:'danger,primary' }} w-100 text-nowrap">
                      {% if instance.is_enabled %}{% trans "Disabled" %}{% else %}{% trans "Enabled" %}{% endif %}
                    </button>
                  </form>
                </td>
                <td>
                  {% if instance.is_enabled %}
                  <button> type="button" class="btn btn-outline-danger w-100 text-nowrap" data-name="{{ instance.name }}" data-url="#" disabled>
                    {% trans "Delete" %}
                  </button>
                  {% else %}
                  <button> type="button" class="btn btn-outline-danger w-100 text-nowrap js-delete-passkey" data-name="{{ instance.name }}" data-url="{% url 'passkey:delete_passkey' pk=instance.pk %}">
                    {% trans "Delete" %}
                  </button>
                  {% endif %}
                </td>
              </tr>
            {% endfor %}
            </tbody>
          </table>
        </div>
      </div>
      <div class="col">
        <nav aria-label="Page navigation">
          <ul class="pagination justify-content-center">
            {% if page_obj.has_previous %}
            <li class="page-item">
              <a> href="?page=1">«</a>
            </li>
            <li class="page-item">
              <a> href="?page={{ page_obj.previous_page_number }}"><</a>
            </li>
            {% endif %}

            <li> class="page-item">
              {{ page_obj.number }} / {{ page_obj.paginator.num_pages }}
            </li>

            {% if page_obj.has_next %}
            <li class="page-item">
              <a> href="?page={{ page_obj.next_page_number }}">></a>
            </li>
            <li class="page-item">
              <a> href="?page={{ page_obj.paginator.num_pages }}">»</a>
            </li>
            {% endif %}
          </ul>
        </nav>
      </div>
      {% else %}
      <div class="col">
        <p>{% trans "There is no passkey." %}</p>
      </div>
      {% endif %}
    </div>
  </div>
</div>

{# Register a new passkey modal #}
<div class="modal" id="register-passkey-modal" tabindex="-1" aria-labelledby="register-passkey-label" aria-hidden="true">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <p> class="modal-title fs-5" id="register-passkey-label">{% trans "Register a new passkey" %}</p>
         <button> type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <div class="modal-body">
        {% csrf_token %}
        <p>{% trans "Enter a new passkey name" %}</p>
        <div>
          <input type="text" id="key-name" class="form-control" />
        </div>
        <div> id="registration-result" class="mt-2"></div>
      </div>
      <div class="modal-footer">
        <div class="row g-2 w-100">
          <div class="col-12 col-md-6">
            <button> type="button" id="register-passkey" class="btn btn-primary w-100">
               {% trans "Register" %}
            </button>
          </div>
          <div class="col-12 col-md-6">
            <button> type="button" class="btn btn-secondary w-100" data-bs-dismiss="modal">
              {% trans "Close" %}
            </button>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

{# Delete the passkey modal #}
<div class="modal" id="delete-passkey-modal" tabindex="-1" aria-labelledby="delete-passkey-label" aria-hidden="true">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <p> class="modal-title fs-5" id="delete-passkey-label">{% trans "Confirmation of deletion" %}</p>
        <button> type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <div class="modal-body">
        <p class="text-danger"><strong>{% trans "May I really delete this record?" %}</strong></p>
        <p>{% trans "Target" %}: <span> id="target-name" class="text-break"></span></p>
      </div>
      <div class="modal-footer">
        <form method="POST" action="" id="delete-passkey-form" class="w-100">
          {% csrf_token %}
          <div class="row g-2">
            <div class="col-12 col-md-6">
              <button> type="submit" class="btn btn-danger w-100">
                {% trans "OK" %}
              </button>
            </div>
            <div class="col-12 col-md-6">
              <button> type="button" class="btn btn-secondary w-100" data-bs-dismiss="modal">
                {% trans "Close" %}
              </button>
            </div>
          </div>
        </form>
      </div>
    </div>
  </div>
</div>

<script> type="application/javascript" src="{% static 'passkey/js/passkey_api.js' %}"></script>
<script>>
(function () {
  // Define modal event
  const registerDeleteModalEvent = () => {
    const deleteBtns = document.querySelectorAll('.js-delete-passkey');
    const deleteForm = document.querySelector('#delete-passkey-form');
    const targetField = document.querySelector('#target-name');

    for (const btn of deleteBtns) {
      btn.addEventListener('click', (event) => {
        deleteForm.action = btn.dataset.url;
        targetField.textContent = btn.dataset.name;
        const modal = new bootstrap.Modal('#delete-passkey-modal');
        modal.show();
      });
    }
  };
  // Define passkey registration event
  const registerPasskeyEvent = () => {
    const registerPasskeyBtn = document.querySelector('#register-passkey');
    const inputKey = document.querySelector('#key-name');
    inputKey.addEventListener('keyup', (event) => {
      if (event.key === 'Enter') {
        registerPasskeyBtn.click();
      }
    }, false);
    registerPasskeyBtn.addEventListener('click', () => {
      const registerURL = '{% url "passkey:register_passkey" %}';
      const keyName = inputKey.value;
      const csrftoken = document.querySelector('[name="csrfmiddlewaretoken"]').value;
      const callback = (done, message) => {
        const createNode = (text, msgType) => {
          const node = document.createElement('div');
          node.textContent = text;
          node.classList.add('alert', msgType);

          return node;
        };
        const result = document.querySelector('#registration-result');
        while (result.firstChild) {
          result.removeChild(result.firstChild);
        }

        if (done) {
          const text = '{% trans "Registration successfully. After three seconds, refresh the page." %}';
          const node = createNode(text, 'alert-success');
          result.appendChild(node);
          setTimeout(() => {
            window.location.href = '{% url "passkey:passkey_list" %}';
          }, 3 * 1000);
        }
        else {
          const node = createNode(message, 'alert-danger');
          result.appendChild(node);
        }
      };
      PasskeyAPI.RegisterPasskey(registerURL, keyName, csrftoken, callback);
    });
  };

  document.addEventListener('DOMContentLoaded', () => {
    registerDeleteModalEvent();
    registerPasskeyEvent();
    // Initialize passkey library
    PasskeyAPI.Init();
  });
})();
</script>
{% endblock %}

193行目~224行目あたりで、PasskeyAPI.RegisterPasskeyメソッドの呼び出し処理を行っています。

ゆると
ゆると

パスキー認証機能

こちらも登録時とほぼ同様で、begin-passkey-authenticationにGETメソッドでリクエストし、署名済みデータをログイン時にPOSTメソッドで送信するだけです。

今回、ログイン処理は別のviewで定義したため、callback側で処理させるようにしています。

  PasskeyAPI.Authentication = (authURL, callback) => {
    fetch(authURL, { method: 'GET' }).then((response) => {
      if (!response.ok) {
        throw new Error('No credential available to authenticate.');
      }

      return response.json();
    }).then((data) => {
      const options = getAssertOptions(data);

      if (window.conditionalUI) {
        options.mediation = 'conditional';
        options.signal = window.conditionUIAbortSignal;
      }
      else {
        window.conditionUIAbortController.abort('Abort manually');
      }

      return navigator.credentials.get(options);
    }).then((assertion) => {
      const jsonData = publicKeyCredentialToJSON(assertion);
      callback(true, jsonData);
    }).catch((err) => {
      callback(false, err);
    });
  };

また、APIの呼び出し側の実装例は、以下に示す通りです。

{# templates/passkey/login.html #}
{% extends 'passkey/passkey_base.html' %}
{% load static %}
{% load i18n %}

{% block header %}
{{ block.super }}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.0/css/all.min.css" integrity="sha512-DxV+EoADOkOygM4IR9yXP8Sb2qwgidEmeqAEmDKIOfPRQZOWbXCzLC6vjbZyy0vPisbH2SyW27+ddLVCN+OMzQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
{% endblock %}

{% block content %}
{{ block.super }}
<div class="row justify-content-center">
  <div class="col">
    <form class="col" action="{% url 'passkey:login' %}" method="POST" id="login-form">
      {% csrf_token %}

      <div class="row row-cols-1 g-3">
        {% if form.non_field_errors %}
        <div class="col text-danger">
          <p> class="fs-5">{% trans "Errors" %}</p>
          {{ form.non_field_errors }}
        </div>
        {% endif %}
        {% for field in form %}
        <div class="col">
          <div class="row row-cols-1 g-2">
            {% if field.label %}
            <div class="col"><label> class="form-label fw-bold"{% if field.id_for_label %} for="{{ field.id_for_label }}"{% endif %}>{{ field.label }}</label></div>
            {% endif %}
            <div> class="col">{{ field }}</div>
            {% if field.errors %}
            <div> class="col text-danger fs-5">{{ field.errors }}</div>
            {% endif %}
            {% if field.help_text %}
            <div> class="col helptext"{% if field.auto_id %} id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</div>
            {% endif %}
          </div>
        </div>
        {% endfor %}
      </div>

      <div class="mt-2 row row-cols-1 row-cols-md-2 g-2">
        <div class="col">
          <button> type="submit" class="btn btn-primary w-100">{% trans "Login" %}</button>
        </div>
        <div class="col">
          <a> href="/" class="btn btn-secondary w-100">{% trans "Cancel" %}</a>
        </div>
      </div>
      {# Login by passkey #}
      <div class="mt-2">
        <button id="passkey-login" type="button" class="btn btn-dark w-100">
          <i> class="fa-solid fa-key"></i>   {% trans "Login by passkeys" %}
        </button>
      </div>
    </form>
  </div>
</div>

<script> type="application/javascript" src="{% static 'passkey/js/passkey_api.js' %}"></script>
<script>>
(function () {
  document.addEventListener('DOMContentLoaded', () => {
    const passkeyBtn = document.querySelector('#passkey-login');
    const authURL = '{% url "passkey:begin_passkey_authentication" %}';
    const callback = (done, result) => {
      if (done) {
        const element = document.querySelector('#passkey');
        const form = document.querySelector('#login-form');
        element.value = JSON.stringify(result);
        form.submit();
      }
      else {
        console.error(result);
      }
    };
    // Initialize passkey library
    PasskeyAPI.Init();
    // Check conditional UI
    PasskeyAPI.CheckConditionalUI((isAvailable, err) => {
      if (isAvailable) {
        PasskeyAPI.Authentication(authURL, callback);
      }
      else {
        console.error(err);
      }
    });
    // Add click event
    passkeyBtn.addEventListener('click', () => {
      PasskeyAPI.Authentication(authURL, callback);
    });
  });
})();
</script>
{% endblock %}

callback関数内で、passkeyとして定義されたDOM idに「署名済みデータ」を格納した上で、submitしています。

これにより、form.pyで定義したBasePasskeyAuthenticationFormpasskeyにデータが渡され、backends.pyauthenticateメソッド内で認証処理が行われます。

呼び出し関係が少しややこしいですが、通常のログイン機能を拡張する形式で作成したため、このような構成になりました。

ゆると
ゆると

先ほど同様に、passkey_api.jsのうち省略した機能も含め、すべての機能を掲載しておきます。

'use strict';

const PasskeyAPI = {};

(function () {
  const base64url = (() => {
    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
    // Create lookup table
    const lookupTable = new Uint8Array(256);
    for (let idx = chars.length - 1; idx >= 0; idx--) {
      lookupTable[chars.charCodeAt(idx)] = idx;
    }

    const encodeFromBuffer = (buf) => {
      const bytes = new Uint8Array(buf);
      const len = bytes.length;
      let base64string = '';

      for (let idx = 0; idx < len; idx += 3) {
        base64string += chars[bytes[idx] >> 2];
        base64string += chars[((bytes[idx] & 3) << 4) | (bytes[idx + 1] >> 4)];
        base64string += chars[((bytes[idx + 1] & 15) << 2) | (bytes[idx + 2] >> 6)];
        base64string += chars[bytes[idx + 2] & 63];
      }

      if ((len % 3) === 2) {
        base64string = base64string.substring(0, base64string.length - 1);
      }
      else if (len % 3 === 1) {
        base64string = base64string.substring(0, base64string.length - 2);
      }

      return base64string;
    };

    const decodeFromString = (base64string) => {
      const len = base64string.length;
      const bytes = new Uint8Array(len * 0.75);
      let pos = 0;

      for (let idx = 0; idx < len; idx += 4) {
        const encoded1 = lookupTable[base64string.charCodeAt(idx)];
        const encoded2 = lookupTable[base64string.charCodeAt(idx + 1)];
        const encoded3 = lookupTable[base64string.charCodeAt(idx + 2)];
        const encoded4 = lookupTable[base64string.charCodeAt(idx + 3)];
        bytes[pos++] = (encoded1 << 2) | (encoded2 >> 4);
        bytes[pos++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
        bytes[pos++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
      }

      return bytes.buffer;
    };

    const methods = {
      encode: encodeFromBuffer,
      decode: decodeFromString,
    };

    return methods;
  })();

  const publicKeyCredentialToJSON = (cred) => {
    const _convertClientExtension = (extension) => {
      const obj = {};

      for (const key of Object.keys(extension)) {
        obj[key] = base64url.encode(extension[key]);
      }

      return obj;
    };
    const _convertResponse = (response) => {
      const _convertor = (arr) => Object.fromEntries(arr.map((val, idx) => [idx, val]));

      if (response instanceof AuthenticatorAttestationResponse) {
        const obj = {
          attestationObject: base64url.encode(response.attestationObject),
          authenticatorData: base64url.encode(response.getAuthenticatorData()),
          clientDataJSON: base64url.encode(response.clientDataJSON),
          publicKey: base64url.encode(response.getPublicKey()),
          publicKeyAlgorithm: response.getPublicKeyAlgorithm(),
          transports: _convertor(response.getTransports()),
        }

        return obj;
      }
      else if (response instanceof AuthenticatorAssertionResponse) {
        const obj = {
          authenticatorData: base64url.encode(response.authenticatorData),
          clientDataJSON: base64url.encode(response.clientDataJSON),
          signature: base64url.encode(response.signature),
          userHandle: base64url.encode(response.userHandle),
        };

        return obj;
      }
      else {
        return undefined;
      }
    };

    // main process
    if ('toJSON' in cred) {
      return cred.toJSON();
    }
    else {
      const obj = {
        authenticatorAttachment: cred.authenticatorAttachment || undefined,
        clientExtensionResults: _convertClientExtension(cred.getClientExtensionResults()),
        id: cred.id,
        rawId: base64url.encode(cred.rawId),
        response: _convertResponse(cred.response),
        type: cred.type,
      };
      const ret = {};
      // Delete `undefined` element
      for (const key of Object.keys(obj)) {
        if (obj[key]) {
          ret[key] = obj[key];
        }
      }

      return ret;
    }
  };

  const makeCredOptions = (data) => {
    data.publicKey.challenge = base64url.decode(data.publicKey.challenge);
    data.publicKey.user.id = base64url.decode(data.publicKey.user.id);

    for (const excludeCred of data.publicKey.excludeCredentials) {
      excludeCred.id = base64url.decode(excludeCred.id);
    }

    return data;
  };

  const getAssertOptions = (data) => {
    data.publicKey.challenge = base64url.decode(data.publicKey.challenge);

    for (const allowCred of data.publicKey.allowCredentials) {
      allowCred.id = base64url.decode(allowCred.id);
    }

    return data;
  };

  // ===========
  // Define APIs
  // ===========
  PasskeyAPI.Init = () => {
    window.conditionalUI = false;
    window.conditionUIAbortController = new AbortController();
    window.conditionUIAbortSignal = window.conditionUIAbortController.signal;
  };

  PasskeyAPI.CheckConditionalUI = (callback) => {
    if (window.PublicKeyCredential && PublicKeyCredential.isConditionalMediationAvailable) {
      PublicKeyCredential.isConditionalMediationAvailable().then((isAvailable) => {
        window.conditionalUI = isAvailable;
        callback(isAvailable, 'Cannot use conditional UI');
      }).catch((err) => {
        callback(false, err);
      });
    }
  };

  PasskeyAPI.CheckPasskeys = (callback) => {
    if (window.PublicKeyCredential && PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) {
      PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then((isAvailable) => {
        callback(isAvailable, 'Cannot use passkey on your device.');
      }).catch((err) => {
        callback(false, err);
      });
    }
  };

  PasskeyAPI.RegisterPasskey = (registerURL, keyName, csrftoken, callback) => {
    fetch(registerURL, { method: 'GET' }).then((response) => {
      if (!response.ok) {
        throw new Error('Cannot get registration data.');
      }

      return response.json();
    }).then((data) => {
      const options = makeCredOptions(data);

      return navigator.credentials.create(options);
    }).then((credential) => {
      const headers = {
        'Content-Type': 'application/json',
        'X-CSRFToken': csrftoken,
      };
      const jsonData = publicKeyCredentialToJSON(credential);
      jsonData['key_name'] = keyName;

      return fetch(registerURL, {
        method: 'POST',
        headers: headers,
        body: JSON.stringify(jsonData),
      });
    }).then((response) => {
      return response.json().then((data) => {
        callback(response.ok, data.message);
      });
    }).catch((err) => {
      callback(false, err);
    });
  };

  PasskeyAPI.Authentication = (authURL, callback) => {
    fetch(authURL, { method: 'GET' }).then((response) => {
      if (!response.ok) {
        throw new Error('No credential available to authenticate.');
      }

      return response.json();
    }).then((data) => {
      const options = getAssertOptions(data);

      if (window.conditionalUI) {
        options.mediation = 'conditional';
        options.signal = window.conditionUIAbortSignal;
      }
      else {
        window.conditionUIAbortController.abort('Abort manually');
      }

      return navigator.credentials.get(options);
    }).then((assertion) => {
      const jsonData = publicKeyCredentialToJSON(assertion);
      callback(true, jsonData);
    }).catch((err) => {
      callback(false, err);
    });
  };

  Object.freeze(PasskeyAPI);
})();

動作確認結果

最後にexampleを用いて、動作確認を行います。

動作確認結果

準備

exampleを使用する場合、必要に応じてexample/config/settings.pyの以下を修正してください。

# settings.py
# ...
CSRF_TRUSTED_ORIGINS = ['https://localhost:8000'] # localhost以外または8000以外に変更する場合
# ...
FIDO_SERVER_ID = 'localhost' # localhost以外に変更する場合
# ...

その後、下記のコマンドを実行し、migrationを行ってください。

cd example
python manage.py makemigrations
python manage.py migrate

後は、コマンドラインからサーバを起動するだけです。

python manage sslrunserver 0.0.0.0:8000

詳細

まず、トップページにアクセスします。

その後、ログインページに移動してください。

ログインページに移動後、下記のアカウントで、パスワード認証によりログインします。

項目内容
ユーザ名test-user@sample.local
パスワードtestUser002@password

ログインに成功するとトップページに戻ってくるので、パスキー一覧ページに移動してください。

一番上のボタンを押下し、パスキーの登録を行います。

モーダルウィンドウが表示されたら、登録するパスキーの名称を決めた上で、登録ボタンを押下します。

Google Chromeブラウザを使っている方は、パスキー登録に関するポップアップが表示されると思うので、「作成」を押下してください。

公開鍵と秘密鍵の作成と登録を行う上で、多要素認証を求められるため、本人確認を行います。

指紋認証の仕組みがない方は、パスワード認証や顔認証を求められますよ。

ゆると
ゆると

本人確認が終わると、パスキーが登録されるので、確認してください。

以降では、パスキーでログインできるかを確認します。

このため、トップページに戻り、一度、ログアウトしてください。

先ほど同様に、ログインページに移動します。

ユーザ名部分にカーソルを当てると、登録したパスキーの利用を促されるため、該当するパスキーを選択してください。

今度は、ログイン認証のための本人確認が行われるので、対応します。

本人確認が成功するとログインできた状態になります。

ログイン状態が分からないのが、イマイチですね...

ゆると
ゆると

以上で、パスキーによるログインの動作確認は完了です。

まとめ

今回は、Djangoにパスキー機能を導入し、ログインするところまで実装・動作確認をしました。

パスキーを用いることで、Webサイトの安全性が高まるため、可能であれば、導入することをオススメします。

他にもDjangoであれば、django-allauthというライブラリがあるため、こちらを用いてソーシャルログインを行うのも良いでしょう。

認証周りは複雑なため、便利なライブラリを活用することも検討したいと思います。

ゆると
ゆると

スポンサードリンク

-プログラミング
-, ,