この記事はフラー株式会社 Advent Calendar 2022 12日目の記事です。
11日目の記事は @ManabuSeki さんで「X-Accel-RedirectとgoでS3ファイルに認証をつけて配信する方法」でした。
(10日目と12日目を担当することにしたので完全に囲む形になってしまった。)
はじめに
Sign in with Apple (以下、 SiwA
) 使用していますか?
SiwA は WWDC2019 で発表された Apple ID によるシングルサインオンの仕組みです。
iOS アプリや macOS アプリ以外にも Web や Android にも実装ができ、実装自体も簡単にできるようになっています。
しかしながら2021年10月6日、突如としてアプリ内での退会機能の提供が必須となるレビューガイドラインが追加されることが明らかになりました。*1
実際適用されるまでに延期がありましたが2022年6月30日からそのガイドラインが適用されました。*2
その中に以下のような表記があります。
Appleでサインインに対応したAppでは、AppleでサインインのREST APIを使用して、アカウントの削除時にユーザーのトークンを無効化する必要があること。
おっと...これどうすんだ...
少なくと私はそうなりました。
すでにマネーフォワードさんなどが記事に書かれていますが、私自身でも理解するために記事にすることにしました。
環境
環境は以上のようになっていますが結局は対サーバなので問題ないかと思います。
今回使用したソースコードは秘匿情報は隠していますがすべて以下にプッシュしています。参考までに
準備
まずは準備が必要ですね。アプリを作ったり、秘密鍵を作ったりと。
今回の記事での流れ
極端なところがありますが、今回は別にシステムを作っているわけではないので、サインイン == 会員登録でサインアウト == 退会処理とします。
ナンスの作成がユーザがサインイン開始より前に来ているのは次の節で理由を書いています。
アプリ側の動きとしては以下
REST API に使用する秘密鍵の生成
節タイトルの通り、 SiwA REST API にアクセスするためには Apple から発行される秘密鍵が必要です。
https://developer.apple.com/account/resources/authkeys/list
上記のプラスボタンより生成します。
Sign in with Apple
にチェックをつけ Configure
ボタンより Primary App ID を選択します。
生成できたら PKCS 8 の秘密鍵がダウンロードされます。ファイル名は AuthKey_XXXXXXXXXX.p8
となっていて、ダウンロード画面に表示される Key ID
と XXXXXXXXXX
と一緒になっているはずです。
client_secret
を作る共通メソッド
SiwA REST API では APNs Provider API のように Authorization ヘッダーに JWT をつけるのではなくフォームデータとして生成した JWT を投げます。
今回も例のごとく Python での開発を想定しています。
JWT の生成についての公式ドキュメントは以下です。
https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens#3262048
$ pip install "pyjwt[crypto]"
また、 requests
もよく使うので入れておきます。
$ pip install requests
from datetime import datetime, timedelta from pathlib import Path import jwt def create_client_secret() -> str: key_path = Path("/") / "path" / "to" / "AuthKey_XXXXXXXXXX.p8" iat = datetime.now() exp = iat + timedelta(minutes=10) return jwt.encode( payload={ "iss": "YOUR_TEAM_ID", "iat": int(iat.timestamp()), "exp": int(exp.timestamp()), "aud": "https://appleid.apple.com", "sub": "YOUR_BUNDLE_ID", }, key=key_path.read_text(), algorithm="ES256", headers={ "alg": "ES256", "kid": "XXXXXXXXXX", # AuthKey_XXXXXXXXXX.p8 の Key ID }, )
アプリを作る
めっちゃシンプルなんですが、 Sign in with Apple
ボタンだけを配置する画面を作りました。
import SwiftUI import _AuthenticationServices_SwiftUI struct SignInPage: View { var body: some View { SignInWithAppleButton( .signIn, onRequest: { request in request.requestedScopes = [.fullName] }, onCompletion: { result in // 後で処理を追加する } ) .signInWithAppleButtonStyle(.black) .frame(width: 256, height: 48) } }
雑ですがこんな感じです。 SwiftUI で Apple が提供する _AuthenticationServices_SwiftUI
を使うことで簡単にボタンを提供することができます。
ただ問題があります。
ナンスが非同期で追加できない!!!
onRequest: { request in request.requestedScopes = [.fullName] request.nonce = // ここに追加したい },
UIKit ベースだとボタン自体はボタンだけの形で提供されているので特に問題はなかったので良かったのですが _AuthenticationServices_SwiftUI
での SignInWithAppleButton
はタップすると onRequest
のクロージャにスコープが流れてきてしまいます。
どうするかなと考えた結果...画面自体をサインインのセッションとみなす方法を選びました。
ナンス自体はアプリ側で生成することもいいと思うのですがやはりサーバと通信することを考えるとサーバサイドに生成してもらったものを利用することがセキュリティ的に良いかなと考えています。(リプレイ攻撃対策)
なので今回は、以下のようにしていい感じに逃しました。
var body: some View { WithViewStore(store, observe: { $0 }, content: { viewStore in Group { if let nonce = viewStore.nonce { SignInWithAppleButton( .signIn, onRequest: { request in request.requestedScopes = [.fullName] request.nonce = nonce }, onCompletion: { result in // 後で処理を追加する } ) .signInWithAppleButtonStyle(.black) } else { RoundedRectangle(cornerRadius: 6, style: .continuous) .overlay { ActivityIndicator( // これは UIActivityView を UIViewRepresentable でラップしたもの style: .medium, color: .white, isAnimation: .constant(true) ) } } } .frame(width: 256, height: 48) } }
サインイン画面は一旦できました。ロジックはともかく。
SiwA 自体のサインイン結果は onCompletion
クロージャに流れてきます。
onCompletion: { result in viewStore.send(.signInResponse(result)) }
という感じで Reducer に逃します。
switch action { case let .signInResponse(.success(authorization)): guard let nonce = state.nonce, let authorization = authorization.credential as? ASAuthorizationAppleIDCredential, let authorizationCodeData = authorization.authorizationCode, let authorizationCode = String(data: authorizationCodeData, encoding: .utf8) else { return .none } return .task { await .sendAuthorizationCode( TaskResult { // Repository に通信処理等を記述しています try await repository.sendAuthorizationCode( authorizationCode: authorizationCode, nonce: nonce ) } ) } case let .sendAuthorizationCode(.success(response)): // アクセストークンの保存 return .none }
重要な部分は、 authorization_code
を取得することなので特に詳しい説明はしません。
onCompletion
で受け取れる Result<ASAuthorization, Error>
を処理して authorization_code
を入手しましょうという感じです。
また Firebase Auth 等を使う場合は同時に identityToken
が取得できるので同じフローに入れておくと良いでしょう。
これで一旦、サインイン画面のロジックもできました。
続いてサインアウト画面です。
こちらもめっちゃ雑ですが、サインアウトボタンだけを配置しています。
var body: some View { WithViewStore(store, observe: { $0 }, content: { viewStore in VStack { Button( action: { viewStore.send(.signOut, animation: .default) }, label: { ZStack(alignment: .center) { Color.red Text("Sign Out") .bold() .foregroundColor(.white) } .frame(maxWidth: .infinity, maxHeight: .infinity) } ) .frame(width: 256, height: 48) .cornerRadius(6) } }) }
という感じです。
SiwA のリフレッシュトークンの取得
クライアントから送られてきた authorization_code
を使用してリフレッシュトークンを取得します。
これまた例のごとく djangorestframework の環境を想定します。
$ pip install django djangorestframework
ナンスの生成
POST /nonce
エンドポイントを想定します。
ナンスを管理するモデルを定義します。
import secrets from django.db import models def _create_code() -> str: return secrets.token_urlsafe(120) class Nonce(models.Model): code = models.CharField(default=_create_code, max_length=500) active = models.BooleanField(default=True) class Meta: db_table = "nonce"
続いて Serializer の実装です。 view 側はこの Serializer を追加するだけなので省略します。
from rest_framework import serializers class NonceSerializer(serializers.Serializer): nonce = serializers.CharField(read_only=True) class Meta: fields = ("nonce",) def create(self, validated_date): nonce = Nonce.objects.create() return { "nonce": nonce.code, }
リクエストごとに毎回ナンスを発行するということにしました。
REST API からリフレッシュトークンを取得する
この章の本題です。
SiwA REST API と通信をして SiwA のリフレッシュトークンを取得します。
実際に取得される値は id_token
や access_token
や expires
なども含まれています。今回は SiwA REST API のアクセスはこのエンドポイントと Revoke 用のエンドポイントなので無視しておきます。
あとで SiwA のリフレッシュトークンは Revoke したりユーザの確認をしたりしなければならないのでナンスと同様にモデルを定義します。
from django.conf import settings from django.db import models class RefreshToken(models.Model): token = models.CharField(default="", max_length=500) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) class Meta: db_table = "refresh_token"
簡単ですが、リフレッシュトークン本体を登録する token
と誰に紐付いているかを確認するため user
を外部キーで持つようにしました。
今回はサインインごとにユーザが生成される仕組みなので user
に unique の制約をつけておいてもいいかもしれませんね。
ここではサービス用のリフレッシュトークンとアクセストークンを発行するとするので djangorestframework-simplejwt
を導入します。
$ pip install djangorestframework-simplejwt
クライアントから authorization_code
を受け取り REST API にリクエストします。
リクエストエンドポイントは POST https://appleid.apple.com/auth/token
です。
https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
import requests import uuid from django.contrib.auth import get_user_model from django.db import transaction from rest_framework import serializers from rest_framework.exceptions import AuthenticationFailed, ValidationError from rest_framework_simplejwt.tokens import RefreshToken as AuthRefreshToken from .models import Nonce, RefreshToken class AuthorizeSerializer(serializers.Serializer): nonce = serializers.CharField(write_only=True) authorization_code = serializers.CharField(write_only=True) refresh_token = serializers.CharField(read_only=True) access_token = serializers.CharField(read_only=True) class Meta: fields = ("nonce", "authorization_code", "refresh_token", "access_token",) def create(self, validated_data): nonce_code = validated_data.pop("nonce") try: nonce = Nonce.objects.get(code=nonce_code, active=True) except Nonce.DoesNotExist: raise ValidationError(detail={"nonce": "不明な値です"}) code = validated_data.pop("authorization_code") res = requests.post( url="https://appleid.apple.com/auth/token", data={ "client_id": "BUNDLE_ID", # client_secret のペイロードの sub と一致させる "client_secret": create_client_secret(), "code": code, # クライアントから受け取った authorization_code "grant_type": "authorization_code" }, headers={ "content-type": "application/x-www-form-urlencoded", } ) if res.status_code != 200: raise AuthenticationFailed() # 成功 data = res.json() with transaction.atomic: # ナンスの無効化 & ユーザの生成 & SiwA リフレッシュトークンの生成 nonce.active = False nonce.save() user = get_user_model().objects.create_user(username=str(uuid.uuid4())) # 雑した RefreshToken.objects.create( token=data["refresh_token"], user=user, ) token = AuthRefreshToken.for_user(user) return { "refresh_token": str(token), "access_token": str(token.access_token), }
ここまでで、 SiwA REST API を使用して SiwA のリフレッシュトークンを取得することがきました。
リフレッシュトークンの検証
公式ドキュメントに図で示されているのですが、このリフレッシュトークンを使用して1日1回リフレッシュトークンを検証しなければなりません。
from .models import RefreshToken for refresh_token in RefreshToken.objects.all(): res = requests.post( url="https://appleid.apple.com/auth/token", data={ "client_id": "BUNDLE_ID", "client_secret": create_client_secret(), "refresh_token": refresh_token.token, "grant_type": "refresh_token", }, headers={ "Content-Type": "application/x-www-form-urlencoded", } ) if res.status_code == 200: continue # エラーハンドリング
また、ここでは省略しましたがすでに無効になっているリフレッシュトークンで検証をすると BadRequest で以下のようなエラーが返ってきます。
{ "error": "invalid_grant", "error_description": "The token has expired or has been revoked." }
error_description
にもちゃんと入ってきてるのありがたい。
また、他のエラーについても公式ドキュメントにて定義されています。
https://developer.apple.com/documentation/sign_in_with_apple/errorresponse
ここで上記のようなエラーが取得されたら、ユーザが設定アプリから故意的にアプリの SiwA を取り消した操作をしたので SiwA のリフレッシュトークンを削除するなりしておきましょう。
また、ここではサービスからユーザを退会させなくてもよいような記述があります。
あくまでも退会時に SiwA REST API を使用して取り消しをしなければならないということなのでこれは退会と見做されないようですね。
SiwA トークンの取り消し
本題ですね。実際これは簡単。
POST https://appleid.apple.com/auth/revoke
エンドポイントにリクエストを送信するのみです。
また、ここでのサーバサイドのエンドポイントは DELETE /signout
としています。
このエンドポイントはサービスへの認可を必要としています。 SiwA のリフレッシュトークンをユーザに対して紐付けているのでそれを取り出すためもありますね。
import requests from django.db import transaction from rest_framework.exceptions import ValidationError from rest_framework.generics import DestroyAPIView from .models import RefreshToken class SignOutDestroyAPIView(DestroyAPIView): def get_object(): return RefreshToken.objects.get(user=self.request.user) def perform_destroy(self, instance): res = requests.post( url="https://appleid.apple.com/auth/revoke", data={ "client_id": "BUNDLE_ID", "client_secret": create_client_secret(), "token": instance.token, "token_type_hint": "refresh_token", }, headers={ "Content-Type": "application/x-www-form-urlencoded", } ) if res.status_code != 200: raise ValidationError(detail=res.json()) # 成功 with transaction.atomic(): instance.delete() self.request.user.delete()
ユーザの操作による取り消しへの対応
ここまでで退会導線でリフレッシュトークンの取り消しはできるようになりました。
ただ、前章で少しだけ触れましたが以下のような操作をすることでユーザは SiwA のアクセス権を取り消すことができます。
Server to Server Notification Endpoint の設定
前章の最終節でも触れたのですが、1日1回のリフレッシュトークンの検証でエラーが取得されるので結局はリフレッシュトークンはデータベースから消えたりすると思うのですが、どうせならリアルタイムに処理をしたいですよね。
ユーザの操作による Apple ID の操作によって Apple サーバから API を叩きに来てくれる仕組みが提供されています。
今回は、 ngrok
を使用してローカルのサーバを外部に公開できるようにしました。 Django のデフォルトポートは 8000 です。
$ ngrok http 8000
Apple Developer のサイト から App ID を選択し Sign in with Apple
の横の Configure から今回の URL を設定することができます。
リクエストのペイロード
{ "payload": "eyJraWQiOiJmaDZCczhDIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoibW9lLm5uc25vZG5iLlNpd0FSZXZva2UiLCJleHAiOjE2NzA4Mjg1MzUsImlhdCI6MTY3MDc0MjEzNSwianRpIjoiTzk0eURORkx5ZWRDWVMzYzRwb(省略)OjE2NzA3NDIxMjkyOTF9In0.YxSIiHtkYdfyY9wcahH49lQQoU4z9onBwSBGt9s11JTJDPo2kfnIb9IeWgW7xXFvpohqPffqbkhwkLmAae0GP98iNgpb6-b0mv7auK3kwpI13HewJfZImAQY2qkIUOq6XFhyTk7H4lh3zq47J3fr7ptexocTJsQvuyWRYcy-(省略)P1v9MrftPBG8Yc1POZKSKwN0pBMoOIL4k0j0Q" }
のようなペイロードが飛んできます。 payload
は JWS 形式の暗号で署名されいるので検証しましょう。もちろん検証をしないでもいいですがしておくべきですね。
import jwt jwt.decode( jwt=request.data["payload"], options={ "verify_signature": False } )
ちなみにこんな感じにすることで署名検証をせずにスルーすることができますね。
Apple の署名を検証するための公開鍵の取得
こいつは client_secret
とかいらないですね。
import requests res = requests.get( url="https://appleid.apple.com/auth/keys" ) keys = res.json()["keys"]
これで取得可能です。
また、データは以下の様な感じになっています。
{ "keys": [ { "kty": "RSA", "kid": "YuyXoY", "use": "sig", "alg": "RS256", "n": "1JiU4l3YCeT4o0gVmxGTEK1IXR-Ghdg5Bzka12tzmtdCxU00ChH66aV-4HRBjF1t95IsaeHeDFRgmF0lJbTDTqa6_VZo2hc0zTiUAsGLacN6slePvDcR1IMucQGtPP5tGhIbU-HKabsKOFdD4VQ5PCXifjpN9R-1qOR571BxCAl4u1kUUIePAAJcBcqGRFSI_I1j_jbN3gflK_8ZNmgnPrXA0kZXzj1I7ZHgekGbZoxmDrzYm2zmja1MsE5A_JX7itBYnlR41LOtvLRCNtw7K3EFlbfB6hkPL-Swk5XNGbWZdTROmaTNzJhV-lWT0gGm6V1qWAK2qOZoIDa_3Ud0Gw", "e": "AQAB" } ] }
keys
自体は複数入っています。
続いて、 Apple からの送られきたペイロードのヘッダー部を見て上記で取得した JWK のどれを使うかを決定します。
import jwt headers = jwt.get_unverified_header(jwt=request.data["payload"]) kid = headers["kid"] # こいつを特定に使用する alg = headers["alg"] # 補助要員で使用することにした jwk_json = None for key in keys: if key["kid"] == kid and key["alg"] == alg: jwk_json = key break
これで使用するキーが確定しました。
では JWK を公開鍵の形に変換してあげましょう!!
from cryptography.hazmat.primitives import serialization from jwt.algorithms import RSAAlgorithm if jwt_json: public_key = RSAAlgorithm.from_jwk(jwt_json) pem = public_key.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo )
これで JWT の署名検証に使える公開鍵が取得できました。
また、このキーは、時間によって変更されることがあるとのことなので保存などせずに都度取得する、もしくは、保存しておいた場合は使用する JWK が見つからなかったときに再取得するなどの実装を施しておく必要があります。
署名の検証
前節で取得した公開鍵を使用して検証をします。
import jwt payload = jwt.encode( jwt=request.data["payload"], key=pem, audience="BUNDLE_ID", algorithms=alg )
これで以下のようなペイロードが取得できるようになりました。
{ "iss": "https://appleid.apple.com", "aud": "BUNDLE_ID", "exp": 1670831934, "iat": 1670745534, "jti": "wtgIE-CRkuqojt8989FGOw", "events": "{\"type\":\"consent-revoked\",\"sub\":\"SUBJECT\",\"event_time\":1670745528321}" }
イベントについて
公式ドキュメントにも書かれてあるとおり、ここではイベントが以下のものが取得できます。
email-disabled
email-enabled
consent-revoked
account-delete
今回は、 consent-revoked
にのみフォーカスします。
まずは前節で取得したペイロードから events のみを取得しましょう。本当は aud とか iat < exp とかも検証したほうがいいけど PyJWT が検証してくれてた気がする。
また、上で SUBJECT
としている部分が Apple ID の識別子となっています。(あとでクライアント側とサーバも修正をします。)
import json event = json.loads(payload["events"]) if event["type"] == "consent-revoked": print(event["sub"]) # こいつで特定できるようにする
サーバサイド側の修正
はい。直します。
AuthorizeSerializer
の実装をちょっと変えます
-import uuid ... class AuthorizeSerializer(serializers.Serializer): + user_id = serializers.CharField(write_only=True) ... class Meta: + fields = ("user_id", "nonce", ...,) - fields = ("nonce", ...,) def create(self, validated_data): ... with transaction.atomic(): ... + user_id = validated_data.pop("user_id") + user = get_user_model().objects.create(username=user_id) - user = get_user_model().objects.create(username=str(uuid.uuid4()))
POST /authorize
エンドポイントに user_id: str
を必須パラメータとします。
その上で、雑にユーザ生成時に UUID4 を投げていた部分に受け取った user_id
を設定することにします。
クライアント側の修正
続けてやります。
前節で実装した通りに ASAuthorizationAppleIDCredential
から user
を取得します。この user
が今回修正分の user_id
と一致します。
await .sendAuthorizationCode(
TaskResult {
try await userRepository.sendAuthorizationCode(
authorizationCode: authorizationCode,
+ userID: authorization.user,
nonce: nonce
)
}
)
SiwA リフレッシュトークンの削除
話はイベントの取得まで戻ります。
イベントの取得で取得できた consent-revoked
の中にある sub
で SiwA のリフレッシュトークンを検索して削除します!!
from .models import RefreshToken RefreshToken.objects.filter(user__username=event["sub"]).delete()
削除😆😆😆😆
最後に
今回は、 SiwA REST API によるトークン取り消しを実際にアプリとサーバサイドを開発して一連の流れを組んでみました。
クライアント側の開発自体は本当に大したことがないんですが、サーバサイドがやること多すぎて...
Server to Server Notification について今まで実装したことがなかったのでいい機会になりました。
飛んでくるリクエストヘッダーを見たら User-Agent に Java
って書いてあったので「あ、やっぱり」ってなりました。
また、 REST API にてユーザのトークンを取り消した場合、以前に確認したときは来なかったのですが、今ではユーザに対してメールで通知しているようです。
(これ知ってるユーザがいたら、メール飛んでこなかったら「あ、このサービスちゃんと対応してないんだ」って思われちゃいますね😅)
(さすがに3記事は疲れた...)
それでは良い SiwA ライフを
明日は @Juliennu さんで「インスタなどでよく見る重なり合うアイコンをUIStackViewで作ってみた」です!
お楽しみにノシ