COBAKURA.COM

Python Social Authで知っておくと便利な仕様

python-social-authdjango-social-authを使うときに知っておくと便利なことをまとめています。

前提

バージョン

  • social-auth-app-django==5.0.0
  • social-auth-core==4.3.0

この記事は、 SOCIAL_AUTH_PIPELINE の設定を以下のようにした場合の挙動。 associate_by_email を有効にするかどうか等で挙動が変わるものもある。

settings.py
SOCIAL_AUTH_PIPELINE = (
    "social_core.pipeline.social_auth.social_details",
    "social_core.pipeline.social_auth.social_uid",
    "social_core.pipeline.social_auth.social_user",
    "social_core.pipeline.user.get_username",
    "social_core.pipeline.social_auth.associate_by_email",
    "social_core.pipeline.user.create_user",
    "social_core.pipeline.social_auth.associate_user",
    "social_core.pipeline.social_auth.load_extra_data",
    "social_core.pipeline.user.user_details",
    "apps.users.pipeline.set_user_data",
)

認証したUserが新規登録かどうかの判定

def pipeline_function(backend, strategy, details, response, user=None, *args, **kwargs):kwargsis_new の値で判定できる。

SOCIAL_AUTH_SLUGIFY_USERNAMES

認証時のusername保存時にSlug変換した文字列を保存してくれる。

デフォルトでは[email protected]でGoogle認証すると、taro.yamadaがusernameになる。
path("<slug:username>/", views.detail, name="detail")のような感じでusernameをslugとして使用している場合はusernameにドットが含まれるとエラーになる
SOCIAL_AUTH_SLUGIFY_USERNAMES=Trueとすることで taroyamada のようにSlug化されたusernameが保存されるようになり、エラーを回避できる。

https://python-social-auth.readthedocs.io/en/latest/configuration/settings.html#username-generation

usernameが重複したときにどうなるか

@taro というTwitterアカウントで認証したとき、usernameは自動的に taro で保存されます。
taro というユーザーが既に存在していた場合は、ランダムな文字列を自動でつけて登録してくれますtaro01ef29 のようなイメージです。usernameのmax_lengthに応じて良い感じにusernameを生成してくれます。

username周りの挙動は "social_core.pipeline.user.get_username" を参照。

登録時にmax_lengthは無視される

独自のpipeline関数内でUserのフィールドに対して何か値を保存しようとするとき、そのフィールドにmax_lengthが設定されていたとしてもそれ以上の文字数の値を保存できてしまいます
以下のようにpipeline関数内で値を保存するときに、フィールドに設定されたmax_lengthを超えない文字数にして保存すると良いでしょう。

pipeline.py
def set_user_data(backend, strategy, details, response, user=None, *args, **kwargs):
    # max_length以上の長さの文字列が保存されないようにスライスする.
    display_name_max_length = user._meta.get_field('display_name').max_length
    if backend.name == "twitter":
        user.display_name = response["name"][:display_name_max_length]

自動で割り当てられるusernameをカスタマイズしたい

デフォルトだと、SNSのアカウント名から自動でusernameを決めてくれます。
usernameの生成をpython-social-authに任せるのではなく、独自実装で割り当てたい場合は "social_core.pipeline.user.get_username" をコメントアウトして代わりに自分で実装した関数へのパスを指定します。

以下はランダムな文字列をusernameに指定する例です。

pipeline.py
from django.utils.crypto import get_random_string

def get_username(backend, strategy, details, response, user=None, *args, **kwargs):
    """
    social_core.pipeline.user.get_usernameのカスタム用関数.
    新規ユーザーのusernameは `user_xxxxxxxxxx` を割り当てる. 
    """
    if not user:
        # 最大文字数以上にならないようにスライスする.
        username = f"user_{ get_random_string(10) }"[:usernameフィールドのmax_length]
    else:
        username = strategy.storage.user.get_username(user)
    return {"username": username}

メールアドレスが重複したときにどうなるか

[email protected] というemailを登録したユーザーがいたとします。
その状態で、 [email protected] でGoogle認証すると新しいユーザーは作られずに元々のユーザーでログイン処理され、Googleアカウントが紐づきます。(PROVIDER=google-auth2のuser social authデータが作られます。)

ログイン中のユーザーが認証するとどうなるか

ログイン中のUserが「Googleでログインする」ボタンを押した時の挙動
ログイン中のUserに紐づいたuser social authデータが作られます。Google認証ではユーザー新規作成時にはメアドが保存される仕様になっていますが、この場合は既存ユーザーのemailフィールドにメアドは保存されず空のままです。
これは、emailフィールドが更新時に保護される設定になっているからです。更新時に保護するフィールドは SOCIAL_AUTH_PROTECTED_USER_FIELDS で定義できます。

もう1つのGoogleアカウントで認証するとどうなるか

上の例でGoogleアカウントが1つ紐づいた状態になりますが、同じユーザーがさらに別のGoogleアカウントで「Googleでログインする」をした場合は、もう1つのuser social authデータが作られます

同ユーザーにGoogleアカウントが2つ紐づいていることになり、どちらのGoogleアカウントでもログインできることになります。

同一のSNSアカウントを複数のUserに紐付けようとするとどうなるか

ユーザーAにGoogleアカウントAが紐づいているとします。
この時、ユーザーBでログインしてGoogleアカウントAを紐付けようとすると AuthAlreadyAssociated at /complete/google-oauth2/ This account is already in use. とエラーが出ます。

当然ですが、GoogleアカウントAユーザーAユーザーBに紐づいてしまうと、GoogleアカウントAで認証したときにどちらのユーザーでログインされるべきかがわからなくなるのでこの状態は許容されません

登録処理だけではなく登録後のSNS連携機能も提供する場合はこのエラーが発生する可能性が考えられるので、この状況が発生した際はユーザーに「このSNSアカウントは既に他アカウントに連携されています。」といったメッセージを表示するように実装する必要があります。

「認証に使用したSNSアカウントが既に他ユーザーに連携済かどうか」は "social_core.pipeline.social_auth.social_user" で判定されています。

social_core/pipeline/social_auth.py
def social_user(backend, uid, user=None, *args, **kwargs):
    provider = backend.name
    social = backend.strategy.storage.user.get_social_auth(provider, uid)
    if social:
        # user(ログイン中のUser) と social.user(プロバイダとuidの組み合わせで取得したUser) が異なる場合はAuthAlreadyAssociatedのエラー
        if user and social.user != user:
            raise AuthAlreadyAssociated(backend)
        elif not user:
            user = social.user
    return {
        'social': social,
        'user': user,
        'is_new': user is None,
        'new_association': social is None
    }

このpipeline関数を独自実装した関数に置き換えることで対応できます。

パイプラインの関数は基本的にdictNoneを返し、それ以外は response インスタンスとして扱われる仕様です。

Each pipeline entry can return a dict or None, any other type of return value is treated as a response instance and returned directly to the client, check Partial Pipeline below for details. If a dict is returned, the value in the set will be merged into the kwargs argument for the next pipeline entry, None is taken as if {} was returned.

以下のようにカスタム関数を作ります。

pipeline.py
def social_user(backend, uid, user=None, *args, **kwargs):
    """
    social_core.pipeline.social_auth.social_userのカスタム用関数.
    """
    provider = backend.name
    social = backend.strategy.storage.user.get_social_auth(provider, uid)

    if social:
        # 連携しようとしたSNSアカウントが、リクエストユーザーとは別のアカウントと既に紐付け済だった場合.
        if user and social.user != user:
            # SNSアカウント連携処理を中断し、エラーメッセージを表示する.
            provider_names = {
                "github": "GitHub",
                "google-oauth2": "Google",
                "twitter": "Twitter",
            }
            messages.add_message(
                kwargs['strategy'].request,
                messages.ERROR,
                f"この{ provider_names[backend.name] }アカウントは既に登録されているため連携できません。"
            )
            redirect("settings:account")
        elif not user:
            user = social.user
    return {'social': social,
            'user': user,
            'is_new': user is None,
            'new_association': social is None}

同プロバイダで1アカウントのみ連携可能にする

「連携ボタン」を表示するようになると、場合によっては1アカウントに複数のGoogleアカウントを連携されるケースがある。(UI的に複数アカウントの連携を出来ないようにしていても、ユーザーが複数タブで操作している場面などでは既にGoogleアカウントを連携済にも関わらずさらに別のGoogleアカウントで連携リクエストを送ることはできる。)

複数の連携を拒否するには、以下のように対応できる。

pipeline.py
def social_user(backend, uid, user=None, *args, **kwargs):
    """
    social_core.pipeline.social_auth.social_userのカスタム用関数.
    """
    provider = backend.name
    social = backend.strategy.storage.user.get_social_auth(provider, uid)

    provider_names = {
        "github": "GitHub",
        "google-oauth2": "Google",
        "twitter": "Twitter",
    }

    # 同プロバイダのソーシャルアカウントを複数連携できないようにするバリデーション.
    if user and user.social_auth.filter(provider=provider).exclude(uid=uid).exists():
        messages.add_message(
            kwargs['strategy'].request,
            messages.ERROR,
            f"連携に失敗しました。既に他の{ provider_names[provider] }アカウントを連携済です。"
        )
        return redirect("settings:account")

連携解除

ソーシャルアカウントとの連携を解除する方法です。

「2つ以上のソーシャルアカウントを持つかパスワードを設定しているユーザー」のみ、連携解除処理を実行できます。1つのソーシャルアカウントしか連携していなくてパスワードもないユーザーが連携解除してしまうと、ログインできる手段を失ってしまうからです。
連携解除権限のないユーザーが連携処理を実行しようとすると例外が発生します。

連携解除時の挙動はSOCIAL_AUTH_DISCONNECT_PIPELINEでカスタムできます。

ユーザーに紐づく認証情報の取得・活用例

sample.py
# ユーザーに紐づく認証情報一覧
>>> user.social_auth.all()
<QuerySet [<UserSocialAuth: taro>, <UserSocialAuth: taro>]>

# プロバイダーを指定して認証情報を取得
>>> user.social_auth.filter(provider='google-oauth2')
<QuerySet [<UserSocialAuth: taro>, <UserSocialAuth: taro>]>

# 認証プロバイダーの取得
>>> user.social_auth.first().provider
'google-oauth2'

# アクセストークン等の取得
>>> user.social_auth.filter(provider='google-oauth2').first().extra_data
{'auth_time': 123456789, 'expires': 3599, 'token_type': 'Bearer', 'access_token': 'XXXXXXXXXX'}

# APIを利用する例
>>> social = user.social_auth.get(provider='google-oauth2')
>>> response = requests.get(
    'https://www.googleapis.com/plus/v1/people/me/people/visible',
    params={'access_token': social.extra_data['access_token']}
)
>>> response.json()['items']

認証キャンセル、失敗時にリダイレクトさせる

例えば、Twitterでの認証画面で「キャンセル」を押してユーザーがログインを中断したとき、例外が発生して500エラーとなってしまいます。これをハンドリングするには以下のようにします。参考

  1. MIDDLEWARE"social_django.middleware.SocialAuthExceptionMiddleware"を追加。
  2. LOGIN_ERROR_URL にエラー発生時のリダイレクト先を定義。(例.
    LOGIN_ERROR_URL = "home:login"

テンプレート

Context Processorsを設定すれば、テンプレート側から認証情報にアクセス可能。

sample.html
{{ backends }}
<!--  {'associated': <QuerySet [<UserSocialAuth: username1>]>, 'not_associated': ['twitter', 'github'], 'backends': ['github', 'google-oauth2', 'twitter']} -->

UserSocialAuthモデル

認証データは social_django.models.UserSocialAuth モデル経由でアクセスできる。

おすすめ記事

2024/1/19

Celeryタスクを更新したときにDockerコンテナを自動リスタートする