なんの記事?
-
自作したDjangoで開発したweb appにソーシャルログインを追加するために、django-allauthモジュールを調べたので、本記事ではデモを動かしつつ、内部構造を理解していく記事です。
-
今回は、LINEでソーシャルログインした後、取得したUIDを元にメッセージを送信する処理を実装しました。
-
Django-allauthのお話をしつつ、途中、五月雨にdjangoの復習を挟みます。
- ログイン後、"accounts/profile"にredirectしているけど、どこで設定している?
- Djangoのdefaultで設定される
settings.LOGIN_REDIRECT_URL
- Djangoのdefaultで設定される
- Appconfig.ready()関数内でsignals.post_migrate()を使った、マイグレ時の初期データの読み込み
- djangoがどのようにapplicationを読み込んでいるか?(公式doc)
- クラスベース汎用ビューの1つ:TemplateView
- 汎用VIewクラス、TemplateVIewクラスの解説記事を参照にどんな実装になっているのか確認する。
- ログイン後、"accounts/profile"にredirectしているけど、どこで設定している?
-
実装したコードはhttps://github.com/Jumpo-523/allauth-line-example に格納してあります。
環境
Python 3.7.4
Django==3.1.2
django-allauth==0.44.0
準備
-
moduleのsource codeに格納されている
example.demo
appを利用します。 -
LINE側でLINEログインを作成する。
セットアップ
- LINE DEVELOPERサイトで、LINE LOGINアプリの登録
- 認証結果をweb applicationに返す際のcallback urlの設定をしておきます。
- django adminで、LINEで作成したアプリのhook, callback関数の登録作業を実施する。
- https://github.com/pennersr/django-allauth のexampleをforkする。
-
example/settings.py
をいじる- INSTALLED_APPS に
'allauth.socialaccount.providers.line',
を追加
- INSTALLED_APPS に
- データ構造仕様をDBに反映させます。
python manage.py migrate
上記まで出来たら、実際にstep by stepで開発していきたいと思います。
開発
LINEログインを利用して認証する
- ユーザ情報・UIDをちゃんと保存するところまで解説します。
まずは動作確認
-
python manage.py runserver
でサーバーを起動し実行させます。 - http://localhost:8000/ にアクセスすると以下のページがレンダリングされます。
次にLINE DEVELOPERサイト登録したLINE ログインを登録します。
Django admin siteを見てみると、
すでにいろいろなDummy XXX appが存在しますね..
....なぜでしょう。example/demo/apps.pyを見てみると、
# example.demo > apps.py
class DemoConfig(AppConfig):
name = 'example.demo'
verbose_name = _('Demo')
def ready(self):
post_migrate.connect(setup_dummy_social_apps, sender=self)
どうやら、AppConfigのready()
メソッド内で呼び出されるsetup_dummy_social_apps
のおかげみたいです。
django.db.models.signals.post_migrate
を利用することで、DBマイグレーション実行後にsetup_dummy_social_apps
が呼び出されます。
setup_dummy_social_apps
関数は、INSTALLED_APPSで設定したallauth.socialaccount.providers
で登録される各ソーシャルアプリに対して、ダミーアプリを作成します。
※ちなみに、
-
example.com
というドメイン名はDjangoがデフォルトで用意するサイト名です。(公式doc)
django.contrib.sites
registers apost_migrate
signal handler which creates a default site namedexample.com
with the domainexample.com
.
- DemoConfigはアプリケーション構成クラスといい、AppConfigクラスのサブクラスです。
- djangoがどのようにアプリケーションをloadしているのかに関しては公式docに詳しく記載されています。
localhost:8000
(及び、127.0.0.1:8000
)を新規追加します。
LINE LOGINのChannel ID/ Channel secretをClient id/ Secret keyに設定します。
Lineでログインしてみる
http://localhost:8000/ にアクセスして、Sign inリンクを押下→Lineリンクを押下して、ログインを試してみます。
あれ、失敗した。。なんでや
SocialApp matching query does not exist
- どうやらweb app内部で持っている SocialAppとSitesの紐付けができていないことが原因らしいです。。
- Site_id=1はexample.comが設定されているが、SocialAppに紐付けたSItesに含まれていないことで怒られてしまいました。
- そもそも、Siteってなんのためにあるのかよくわかってないですが、ここは後日勉強します。
Siteを設定したし、もう大丈夫だろうと、Lineをクリックし、LINEログインを実行してみたところ...
Social Netowork Login Failure...うーむなんでや???
返されるcallback urlを見てみると、
"GET /accounts/line/login/callback/?error_description=%27scope%27+is+not+specified.&state=lka8qKd4yOVF&error=invalid_scope HTTP/1.1" 200 731
とあり、「scopeが特定されていない」とある。
原因:setttings.py
でSOCIALACCOUNT_PROVIDERS
変数を追加してない
django-allauthはSOCIALACCOUNT_PROVIDERS
変数で、各PROVIDER(SOCIALLOGINで利用したいアプリ)ごとに認可の範囲を指定できます。
指定しない場合、default値として空dictが渡されてしまい、providerは認証することができなくなってしまいます。
例えば今回のLINEのケースであれば「LINE経由で登録してくれたユーザに個別メッセージを送りたい」ので、プロフィール情報と共にIDトークンを取得できるように、 ['profile','openid']
を指定します。
SOCIALACCOUNT_PROVIDERS = {
'line': {
'SCOPE': ['profile','openid'],
}
}
その他、細かい仕様に関しては、公式ドキュメントを参照してください。
ちなみに、
-
SOCIALACCOUNT_PROVIDERS
という変数でsettings.pyで設定された変数はどうやって使われているか?-
app_settings.py
でSOCIALACCOUNT_
というprefixが付いているもののみ取り出され、app_settings moduleとしてDjango-allauthに使われています。(app_settings.py fileで未設定の変数のdefault値も確認できます。)
-
-
SOCIALACCOUNT_PROVIDERS
で設定した情報は、Providerクラスのget_settings()
で呼び出されるように設計されています。
SCOPEをprofile
とopenid
に設定して、再度チャレンジしましょう。
無事、自分のLINEアカウントを利用して、ログインすることができました。
ですが、LINEのユーザ名がうまく、djangoに渡せておらず、Successfully signed in as user.
と表示されてしまいます。
LINEのユーザ名をdjangoに渡したい。
なんで
user
という名前でdjangoに渡されてしまうのか。
-
結論から言うと、
_process_signup
でclean_username処理で引っかかってしまい、ユーザ登録処理の途中で、空文字に置き換えられてしまうからです。- 空文字に置き換えられた場合、
allauth.account.adapter.DefaultAccountAdapter
クラスのpopulate_username
メソッドによって、[first_name, last_name, email, username, "user"]のいずれかの値がusernameとして保存されます。 - ['profile','openid']のみをSCOPEに設定したLineログインの場合、first_name, last_name, emailの情報は抽出されない為空文字になり、結果usernameとして有効な文字列は消去法で"user"となってしまうのです。
- 空文字に置き換えられた場合、
-
breakpointを貼って処理を追ってみるとわかるのですが、LINEから
displayName
として、ユーザ名("Jumpei Takubo")は渡されています。しかし、苗字と名前の間にスペースが入っており、これがUsernameValidatorに引っかかってしまうのです。- ただ、extra_data fieldにJSON形式で
displayName
がキーに含まれてはいます。
- ただ、extra_data fieldにJSON形式で
どうやって対処する?
-
対処方法は思いつく限りで2通りあるかと思います。
-
SignUp時にユーザに修正してもらう。
-
validatorの処理をカスタマイズして、素通りさせる(あまり良くない?)
-
- SignUp時にユーザに修正してもらう。
- ソーシャルログイン機能を利用しつつ、必要な情報はユーザに手入力させるようにform画面を作るのが一つ逃げ道かなと思います。
- validatorの処理をカスタマイズして、素通りさせる(あまり良くない?)
- 一番手取り早いのはvalidatorの処理をカスタマイズして、許容するパターンを予め用意しておくのが良いかなと思います。
- 本記事では、validation checkを全て無効化します。下記、その実装になります。
settings.py
ACCOUNT_USERNAME_VALIDATORS = 'example.demo.validators.custom_username_validators'
demo/validators.py
import re
from django.core import validators
from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext_lazy as _
@deconstructible
class CustomUsernameValidator(validators.RegexValidator):
# regex = r'^[\w.@+-]+\Z' # defaultで用意される異常パターン
regex = r'' # 異常パターンを全部許容する
message = _(
'Enter a valid username. This value may contain only English letters, '
'numbers, and @/./+/-/_ characters.'
)
flags = re.ASCII
custom_username_validators = [CustomUsernameValidator()]
adminページにて、登録したuserを削除して、再度LINEログインを実施してみると...
見事、LINEのdisplayNameをusernameとして保存することができました!
無事Successfully signed in as Jumpei Takubo.
と表示されています。
最後に、登録したユーザに個別メッセージを送信してみたいと思います。
取得したUIDを元にメッセージを送信するまで
- まず、ログインしたユーザのuidが保存されていることを確認します。
- 次に、ユーザ情報をもとにLINE message APIを利用してメッセージを送信する
-
Line Developpersで、Messaging APIを作成
-
push messageの仕様を確認(公式ドキュメント)
-
views.pyに関数を作成
def notify_by_message_api(request): user = get_social_account_user(request) if user is None: return JsonResponse({"error": "You don't have social account."}, status=400) # TODO: develop push notifications using user_id. # https://developers.line.biz/ja/reference/messaging-api/#send-push-message from linebot import LineBotApi from linebot.models import TextSendMessage from linebot.exceptions import LineBotApiError # import pdb; pdb.set_trace() try: line_bot_api = LineBotApi(os.environ["LINE_CHANNEL_ACCESS_TOKEN"]) line_bot_api.push_message(f'{user.uid}', TextSendMessage(text='初めまして、webサイトへの登録ありがとうございます。')) except (LineBotApiError, KeyError) as e: # error handle logger.error(f'Something went wrong! {e}') return JsonResponse({"success": False, "error": str(e)}, status=400) # social_account.uid # messageだけclientに返したい。 # ajaxの使い方を復習する。 return JsonResponse({"success": True, 'message':"I correctly sent messages"})
4.profile.htmlにnotify ボタンと、ボタン押下時のajax処理のコードを追加する。
- ```html
{% extends "base.html" %}
{% block content %}
<script src="https://code.jquery.com/jquery-3.5.0.js"
integrity="sha256-r/AaFHrszJtwpe+tHyNi/XCfMxYpbsRg2Uqn0x3s2zc="
crossorigin="anonymous"></script>
<script type="text/javascript">
var hoge = function(){
$.ajax(
{
type:"GET",
url: "/notify/",
data:{},
'dataType':'json',
'success': function(response){
alert("Okay, I sent a message to your LINE account.")
},
'error':function(response){
alert("you failed to send a message to your LINE account. ")
},
})
};
</script>
<h1>Profile</h1>
<p>Your profile</p>
{% if social_account %}
<h1> Hello {{ social_account.extra_data.displayName }}</h1>
<input type="button" class="notify" value="Notify to {{ social_account.extra_data.displayName }}"
onclick="hoge()"/>
{% endif %}
<!-- button to push a notification to uid . -->
{% endblock %}
```
5. `demo/urls.py`で押下時`http://localhost:8000/notify/`にアクセスされた際に上記`notify_by_message_api`にroutingできるように設定
```python
urlpatterns = [
...
path("notify/", views.notify_by_message_api),
...
]
これで実装は完了です!
あとは、web上でボタンを押して、自分のアカウントを確認して、作成したmessage API botから、下記のようにコメントが届いているか、確認してみてください!
追記:jinja2に渡したsocial_account
と言う変数に付いて
-
{% if social_account %}
は追加実装しているけど、これは何?どうやって渡している?- social_accountは、著者がTemplateViewを継承したクラスにcontextとして渡したソーシャルアカウントのobjectです。
- login後のredirect urlである
accounts/profile
で呼び出されるviewとしてTemplateView
を継承したProfileTemplateView
のget_context_data
メソッドで追加しています。 -
get_context_data
メソッドをoverrideすることで、templateに渡す変数を追加することができます。
class ProfileTemplateView(TemplateView):
form_class = None
social_account = None
template_name = "profile.html"
# ...
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["social_account"] = self.social_account
return context
profile = ProfileTemplateView.as_view()