Python と iOS でパスキー認証の実装をする
はじめに
パスキー (Passkeys) の実装をサーバサイドは Python (Django) でクライアントは iOS で実装してみたので備忘録がてらの紹介です。
パスキー自体の説明は省略します。
Apple のサイトでもそれっぽいことが書かれていたのでリンクだけ
挙動デモ
実装サンプル
いいねと思ったら Star ください⭐️⭐️
環境
- Python 3.12.0
- Django 5.0.1
- webauth 1.11.1
- Xcode 15.1
- iOS 16.0 以上
サーバサイドでは Django の他に webauthn
を使用しています。
Django を選んだのはとりあえずデータベース付きの Web フレームワークが欲しかったので選んでいるだけで流行りの FastAPI だったり Flask だったりなんだっていいです。
また、今回は Django REST framework 等は使わずに Django の機能のみで実装しています。
基本的にサーバサイドと iOS アプリとの通信は Web API による通信を想定しているのでリクエストボディに流れてきた JSON ペイロードを request
オブジェクトから json
プロパティでアクセスできるようにデコレータを実装しています。
デコレータ実装
import json
from functools import wraps
from django.http.response import JsonResponse
def request_body_json(func):
def decorator(view_func):
def wrapper(request, *args, **kwargs):
try:
data = json.loads(request.body)
setattr(request, "json", data)
return view_func(request, *args, **kwargs)
except json.decoder.JSONDecodeError as e:
return JsonResponse({"error": str(e)}, status=400)
return wrapper
return wraps(func)(decorator(func))
流れ
結構適当に作ってるのとまだまだ知識不足なので間違った部分があれば指摘いただけると幸いです...
認証器部分が iOS だとブラックボックス化されてるような感じがあるので端折ってます。
パスキー登録
パスキー認証
流れはどちらともサーバサイドにチャレンジを要求して、ローカルでユーザ検証をしてからパスキーに登録もしくはアクセスをするという流れになります。
パスキーの登録
登録しなければ認証もなにもないので登録をする作業から始めます。
登録時のベストプラクティスがまだ定まっていないということなので、とりあえずメールアドレスを入力してもらってサーバサイドに送信してユーザ作成をしてチャレンジと user_id をサーバサイドから返却してもらうように実装をしました。
クライアントの実装
登録と認証どちらとも Relying Party を static let で定義しています。
static let relyingPartyIdentifier = "example.com"
一般的にはこの値はサービスのドメイン名になります。今回は特にパブリックに公開していないものの iOS アプリでパスキーを使用する際に apple-app-site-association
の設定が必要になるため ngrok を使用して開発時に外部からアクセスできるようにしていました。
apple-app-site-association の定義
{
"webcredentials": {
"apps": [
"{{ YOUR_TEAM_ID }}.{{ IOS_APP_BUNDLE_ID }}"
]
}
}
という感じで定義をしています。
iOS アプリ側にも Associated domains の追加が必要になります。
webcredentials:example.com
let response = try await requestRegistrationBegin(email: email) // サーバサイドからチャレンジと user_id を取得する
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: Self.relyingPartyIdentifier)
let registrationRequest = provider.createCredentialRegistrationRequest(
challenge: response.challenge,
name: email,
userID: Data(base64Encoded: response.userID) ?? .init()
)
let controller = ASAuthorizationController(authorizationRequests: [registrationRequest])
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()
メールアドレスをサーバサイドに送信してチャレンジを要求します。
その取得されたチャレンジと user_id
を使用して登録リクエストを作成し Sign in with Apple 等でおなじみの ASAuthorizationController
に渡してリクエストを表示します。[1]
また登録リクエスト作成時には userID
は Swift の Data
型で渡す必要があります。 Swift では Int
型から Data
型への変換がちょっと面倒[2] なのでサーバサイドで予め Python の bytes
に変換し Base64 エンコードしレスポンスに含めています。
Touch ID や Face ID 等でユーザの認証が成功すると AuthorizationControllerDelegate
の authorizationController(controller:didCompleteWithAuthorization:)
[3] メソッドが呼び出されるので第2引数の authorization: ASAuthorization
を使用してサーバサイドに認証情報を送信します。
この時点ですでに iCloud Keychain [4]にパスキーが登録されます。
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
switch authorization.credential {
case let credential as ASAuthorizationPlatformPublicKeyCredentialRegistration:
let credentialID = credential.credentialID // Data 型
let attestationObject = credential.rawAttestationObject // Optional<Data> 型
let clientDataJSON = credential.rawClientDataJSON // Data 型
let jsonObject: [String: Any] = [
"user_id": userID // チャレンジで取得した String 型の user_id,
"credential_id": credentialID.base64EncodedString(),
"attestation_object": attestationObject?.base64EncodedString(),
"client_data_json": clientDataJSON.base64Encoded()
].compactMapValues { $0 }
let httpBody = try? JSONSerialization.data(withJSONObject: jsonObject)
// サーバサイドに送信
default:
break
}
}
API 通信においては JSON ペイロードに Data
型を含めて送信することはできないので Base64 エンコードして JSON ペイロードにしています。本来であれば URL セーフな Base64 エンコードがいいような気がしますが Swift ではこの操作も面倒なのでちょっとズルしました。
サーバサイドの実装
チャレンジ作成 API は今回は詳しい説明は省略します。以下のコード部分です。
In order to prevent replay attacks, the challenges MUST contain enough entropy to make guessing them infeasible. Challenges SHOULD therefore be at least 16 bytes long.
引用: https://www.w3.org/TR/webauthn-2/#sctn-cryptographic-challenges
ということなので 16バイト以上のチャレンジが必要とのことなので今回は以下のようにとりあえず 32バイトでチャレンジを生成するようにしました。
import secrets
challenge = secrets.token_bytes(32)
レスポンスに含める際にこちらも Base64 エンコードしています。
続いて、クライアント側でパスキー登録完了後に認証情報を受け取る API の実装です。
とりあえず受け取った JSON ペイロードの中身はすべて Base64 エンコード済みの bytes
なので Base64 デコードして元の形に戻します。
ペイロード内の user_id
と保存済みのチャレンジのユーザが一致するなど軽く確認したあとに認証情報の検証に進みます。
認証情報の検証自体は、 webauthn
パッケージが全てよしなにやってくれているようなのでお任せします。
また、 webauthn
パッケージ以外にも fido2
があります。こちらでも同様に検証が可能です。
webauthn
パッケージの verify_registration_response
関数(以下、登録検証関数)を使用します。
ここで渡す credential
は JavaScript の navigator.credentials.create()
で取得されるレスポンスを受け取る前提として定義されているようなので JSON ペイロードで受け取ったデータをその形に整形する必要があります。
from webauthn.helpers import bytes_to_base64url
credential = {
"id": bytes_to_base64url(credential_id),
"rawId": bytes_to_base64url(credential_id),
"response": {
"attestationObject": bytes_to_base64url(attestation_object),
"clientDataJSON": bytes_to_base64url(client_data_json),
},
"type": "public-key",
"clientExtensionResults": {},
"authenticatorAttachment": "platform",
}
webauthn
パッケージのヘルパー関数 bytes_to_base64_url
を使用して再度 Base64 エンコードを施すようにします。
authenticatorAttachment
は cross-platform
になる可能性もあるので JSON ペイロードに含めて受け取るようにしても良いかもしれません。
次に上で生成した credential
と期待値情報を含めて登録検証関数に渡します。
registration_verification = verify_registration_response(
credential=credential,
expected_challenge=base64url_to_bytes(challenge),
expected_origin="https://example.com",
expected_rp_id="example.com",
require_user_verification=True,
)
challenge
もデータベースに保存する際に Base64 エンコードしていたのでヘルパー関数 base64url_to_bytes
を使って bytes
に戻して登録検証関数に渡します。あとは origin とか rp_id とか適当に突っ込む。
検証に失敗すると内部で何らかの例外が発生するので発生せずに VerifiedRegistration
オブジェクトが取得できれば検証成功となります。
検証成功したら、 VerifiedRegistration
オブジェクトの credential_id
と public_key
をデータベースに保存して検証できるようにしておきます。 credential_id
は Base64 エンコードし public_key
は cryptography
のオブジェクトを使用しているので復元が面倒なので pickle
を使って bytes
に変換したあとにデータベースに保存しておきました。
パスキーでの認証
クライアント実装
let response = try await requestAuthenticateBegin(email: email) // サーバサイドからチャレンジを取得する
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: Self.relyingPartyIdentifier)
let assertionRequest = provider.createCredentialAssertionRequest(challenge: response.challenge)
let controller = ASAuthorizationController(authorizationRequests: [assertionRequest])
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()
サーバサイドからチャレンジを取得したあとにパスキーが登録済みの Relying Party でアサーションリクエストを作成をします。
ここでは登録済みですでに user_id
はアサーションリクエストに渡す必要はありません。(渡せない)
パスキーの登録時と同様に Touch ID や Face ID 等でユーザの認証が成功すると AuthorizationControllerDelegate
の authorizationController(controller:didCompleteWithAuthorization:)
メソッドが呼び出されるので第2引数の authorization: ASAuthorization
を使用してサーバサイドに認証情報を送信します。
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
switch authorization.credential {
case let credential as ASAuthorizationPlatformPublicKeyCredentialRegistration:
// 登録時の実装のため省略
case let credential as ASAuthorizationPlatformPublicKeyCredentialAssertion:
let userID = credential.userID // Data 型
let credentialID = credential.credentialID // Data 型
let authenticatorData = credential.rawAuthenticatorData // Data 型
let signature = credential.signature // Data 型
let clientDataJSON = credential.rawClientDataJSON // Data 型
let jsonObject: [String: Any] = [
"user_id": userID.base64EncodedString(),
"credential_id": credentialID.base64EncodedString(),
"authenticator_data": authenticatorData.base64EncodedString(),
"signature": signature.base64EncodedString(),
"client_data_json": clientDataJSON.base64EncodedString()
]
let httpBody = try? JSONSerialization.data(withJSONObject: jsonObject)
// サーバサイドに送信
default:
break
}
}
ここではサーバサイドでパスキーを特定するために user_id
と credential_id
を追加しています。
また、 ASAuthorizationPlatformPublicKeyCredentialAssertion
オブジェクトから取得してサーバサイドに送信するプロパティはすべて Data
型なので Base64 エンコードを施して JSON ペイロードに含めて送信しています。
サーバサイド実装
チャレンジ作成 API はここでも省略します。実装は以下です。
至極簡単ですね。
続いて、クライアント側でパスキー認証後に認証情報を受け取る API の実装です。
ちょっと長いですが、パスキー登録の API 実装とやってることはほとんど変わりありません。
チャレンジがユーザものかどうかを検証して、ユーザに登録済みのパスキーを JSON ペイロードに含まれる credential_id
から取得し、認証情報を検証する流れです。
webauthn
パッケージの verify_authentication_response
関数(以下、認証検証関数)を使用します。
ここでも credential
は JavaScript の navigator.credentials.get()
で取得されるレスポンスを受け取る前提として定義されているようなので JSON ペイロードで受け取ったデータをその形に整形する必要があります。
from webauthn.helpers import bytes_to_base64url
credential = {
"id": bytes_to_base64url(credential_id),
"rawId": bytes_to_base64url(credential_id),
"response": {
"authenticatorData": bytes_to_base64url(authenticator_data),
"clientDataJSON": bytes_to_base64url(client_data_json),
"signature": bytes_to_base64url(signature),
},
"type": "public-key",
"authenticatorAttachment": "platform",
"clientExtensionResults": {},
}
authentication_verification = verify_authentication_response(
credential=credential,
expected_challenge=base64url_to_bytes(challenge),
expected_rp_id="example.com",
expected_origin="https://example.com",
credential_public_key=passkey.authenticate_data.credential_public_key,
credential_current_sign_count=passkey.sign_count,
require_user_verification=True,
)
passkey.authenticate_data.credential_public_key
では pickle
で bytes
に変換した cryptography
のオブジェクトを元の形に戻しています。[5]
ここでも認証検証関数内で検証に失敗すると何らかの例外が発生し、成功したら VerifiedAuthentication
オブジェクトが取得されます。
このオブジェクト自体の内容は大したことはないと思っているんですが、 JSON ペイロードで受け取った credential_id
と VerifiedAuthentication
オブジェクトの credential_id
が一致するかの確認と、 new_sign_count
を使ってデータベース上の該当パスキーのカウンターを変更しておくと良いかもしれません。
あとは、自分のサービスの認証基盤に沿うような API キー等を発行する流れに持っていけば良いでしょう!
最後に
iOS のパスキーの実装自体は、サーバサイドがなくても[6]確認程度ぐらいはできるのでサンプルプロジェクト[7]をダウンロードして確認することができます。
ただそのときに取得される情報を使ってどうやって検証を行うのかを自分なりに学習できたのでとても良かったです。
Apple がパスキーを発表したとき[8][9]に「めっちゃすごいやん!!」ってなってたんですが、でも実際に実装するとなると「クライアント実装は簡単だけどサーバサイドはどうなん?」という疑問がなんとなく解決できました。
おまけ
iOS 17 以上であれば iCloud Keychain ではなく 1Password でパスキーが使用ができるようになりました。[10]
しかしながら、今回の検証で使用した端末が iOS 17.1.1 でパスキー登録時に iOS アプリから取得できる clientDataJSON
が空で取得されるので登録が行えませんでした。
詳しいことはわからないのですが、普段から 1Password を使ってパスキーを使用している自分にとってはちょっと厄介です。
We are working with our partners at Apple to find a resolution to this issue and early reports indicate that iOS 17.2 Beta 1 includes a fix for the issue. Are you able to test saving and using a passkey with 1Password and your app using the latest beta of iOS?
上記より、 iOS 17.2 Beta 1 で修正がされたということで iOS 17.2.1 にアップデートをして再度検証をしてみたところ無事パスキーの登録及び認証を行うことができました。
iOS 17.2 未満を使用しているユーザにはアップデートを促すか iCloud Keychain のみが使用できることを表示するなど何らかのアプローチを取ると良いかもしれませんね。
参考
-
AuthenticationService フレームワークのインポートが必要 ↩︎
-
Int 型から String 型を通して Data 型はすぐなのでそちらを使うのでもアリ ↩︎
-
authorizationController(controller:didCompleteWithAuthorization:) | Apple Developer Documentation ↩︎
-
ここで使用しているのは iCloud Keychain ですが 1Password 等でも同様 ↩︎
-
実際の実装では webauthn パッケージの VerifiedRegistration を保存しています ↩︎
-
AASA は必要 ↩︎
-
Now available: Save and sign in with passkeys using 1Password in the browser and on iOS ↩︎
Discussion