Django-allauthを使ってLINEログイン→push-messageをアカウントに送信するまで

# なんの記事？

- 自作したDjangoで開発したweb appにソーシャルログインを追加するために、django-allauthモジュールを調べたので、本記事ではデモを動かしつつ、内部構造を理解していく記事です。

- 今回は、LINEでソーシャルログインした後、取得したUIDを元にメッセージを送信する処理を実装しました。
- Django-allauthのお話をしつつ、途中、五月雨にdjangoの復習を挟みます。
  - ログイン後、"accounts/profile"にredirectしているけど、どこで設定している？
    - Djangoのdefaultで設定される[`settings.LOGIN_REDIRECT_URL`](https://docs.djangoproject.com/en/3.1/ref/settings/#std:setting-LOGIN_REDIRECT_URL)
  - Appconfig.ready()関数内でsignals.post_migrate()を使った、マイグレ時の初期データの読み込み
    - djangoがどのようにapplicationを読み込んでいるか？（[公式doc](https://docs.djangoproject.com/en/dev/ref/applications/#initialization-process)）
  - クラスベース汎用ビューの1つ：TemplateView
    - [汎用VIewクラス、TemplateVIewクラスの解説記事](https://e-tec-memo.herokuapp.com/article/85/)を参照にどんな実装になっているのか確認する。


- 実装したコードはhttps://github.com/Jumpo-523/allauth-line-example に格納してあります。

## 環境

```
Python 3.7.4
Django==3.1.2
django-allauth==0.44.0
```





## 準備

1. moduleのsource codeに格納されている`example.demo` appを利用します。
   - [source link](git@github.com:pennersr/django-allauth.git)

2. LINE側でLINEログインを作成する。
   - [ウェブアプリにLINEログインを組み込む](https://developers.line.biz/ja/docs/line-login/integrate-line-login/#making-an-authorization-request)






### セットアップ

   - 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',`を追加
   - データ構造仕様をDBに反映させます。`python manage.py migrate`



上記まで出来たら、実際にstep by stepで開発していきたいと思います。

## 開発

### LINEログインを利用して認証する
- ユーザ情報・UIDをちゃんと保存するところまで解説します。

#### まずは動作確認

- `python manage.py runserver`でサーバーを起動し実行させます。
- http://localhost:8000/ にアクセスすると以下のページがレンダリングされます。

![スクリーンショット 2020-12-28 11.12.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179708/b7d7ffc7-0233-cafc-d946-09cd9a1ef71f.png)


##### 次にLINE DEVELOPERサイト登録したLINE ログインを登録します。

Django admin siteを見てみると、

![スクリーンショット 2020-12-28 11.13.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179708/fe680e40-db3f-0101-0097-2dbf9c56adab.png)

すでにいろいろなDummy XXX appが存在しますね..

....なぜでしょう。example/demo/apps.pyを見てみると、

```python
# 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`で登録される各ソーシャルアプリに対して、ダミーアプリを作成します。

※ちなみに、

1. `example.com`というドメイン名はDjangoがデフォルトで用意するサイト名です。([公式doc](https://docs.djangoproject.com/en/3.1/ref/contrib/sites/#enabling-the-sites-framework))

> `django.contrib.sites` registers a [`post_migrate`](https://docs.djangoproject.com/en/3.1/ref/signals/#django.db.models.signals.post_migrate) signal handler which creates a default site named `example.com` with the domain `example.com`.

2. DemoConfigはアプリケーション構成クラスといい、AppConfigクラスのサブクラスです。
   - djangoがどのようにアプリケーションをloadしているのかに関しては[公式doc](https://docs.djangoproject.com/en/dev/ref/applications/#initialization-process)に詳しく記載されています。

##### `localhost:8000`(及び、`127.0.0.1:8000`)を新規追加します。
![スクリーンショット 2020-12-28 11.14.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179708/0619dee9-27b1-fbc0-a719-635ed3b9cb0b.png)



##### LINE LOGINのChannel ID/ Channel secretを**Client id/ Secret key**に設定します。

![スクリーンショット 2020-12-28 11.12.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179708/f377c57b-ef92-4fb4-7bf9-712d93a14734.png)



##### Lineでログインしてみる

http://localhost:8000/ にアクセスして、<u>Sign in</u>リンクを押下→<u>Line</u>リンクを押下して、ログインを試してみます。

![スクリーンショット 2020-12-28 11.10.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179708/d31d31c7-f90a-cf2f-6a18-adae6d76e8fd.png)

あれ、失敗した。。なんでや

> SocialApp matching query does not exist

- どうやらweb app内部で持っている SocialAppとSitesの紐付けができていないことが原因らしいです。。
- Site_id=1はexample.comが設定されているが、SocialAppに紐付けたSItesに含まれていないことで怒られてしまいました。
- そもそも、Siteってなんのためにあるのかよくわかってないですが、ここは後日勉強します。

![スクリーンショット 2020-12-28 11.09.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179708/d0eda390-d415-758e-40f4-2555f2411413.png)

Siteを設定したし、もう大丈夫だろうと、<u>Line</u>をクリックし、LINEログインを実行してみたところ...

![スクリーンショット 2020-12-28 11.07.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179708/1423b05f-4717-4400-5862-ea57e739ecb6.png)



Social Netowork Login Failure...うーむなんでや？？？

返されるcallback urlを見てみると、

``` UNIX
"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']`を指定します。

```python
SOCIALACCOUNT_PROVIDERS = {
    'line': {
        'SCOPE': ['profile','openid'],
    }
}
```

その他、細かい仕様に関しては、[公式ドキュメント](https://developers.line.biz/ja/docs/line-login/integrate-line-login/#scopes)を参照してください。



ちなみに、

- `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`に設定して、再度チャレンジしましょう。

![スクリーンショット 2020-12-28 11.07.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179708/3e96093c-897a-a4e8-b2fe-3025547fdf75.png)

無事、自分のLINEアカウントを利用して、ログインすることができました。

![スクリーンショット 2020-12-28 11.06.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179708/f3b8fdce-1576-f313-1f42-9a80d5dfb725.png)



ですが、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`がキーに含まれてはいます。

  

> どうやって対処する？

- 対処方法は思いつく限りで２通りあるかと思います。

  1. SignUp時にユーザに修正してもらう。

  2. validatorの処理をカスタマイズして、素通りさせる（あまり良くない？）

1. **SignUp時にユーザに修正してもらう。**

- ソーシャルログイン機能を利用しつつ、必要な情報はユーザに手入力させるようにform画面を作るのが一つ逃げ道かなと思います。

2. **validatorの処理をカスタマイズして、素通りさせる（あまり良くない？）**

- 一番手取り早いのはvalidatorの処理をカスタマイズして、許容するパターンを予め用意しておくのが良いかなと思います。
- 本記事では、validation checkを全て無効化します。下記、その実装になります。

`settings.py`

```python
ACCOUNT_USERNAME_VALIDATORS = 'example.demo.validators.custom_username_validators'
```

`demo/validators.py`

```python
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ログインを実施してみると...



![スクリーンショット 2020-12-28 11.05.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179708/117c340b-226d-a12c-8643-3abc58468d06.png)
![スクリーンショット 2020-12-28 11.06.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179708/ad184b42-df71-94df-9388-1da6dd06e76e.png)


見事、LINEのdisplayNameをusernameとして保存することができました！

無事`Successfully signed in as Jumpei Takubo.`と表示されています。

最後に、登録したユーザに個別メッセージを送信してみたいと思います。



### 取得したUIDを元にメッセージを送信するまで

- まず、ログインしたユーザのuidが保存されていることを確認します。


![スクリーンショット 2020-12-27 17.40.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179708/eff694ea-597b-aab8-25c4-33de43d0b605.png)


- 次に、ユーザ情報をもとにLINE message APIを利用してメッセージを送信する

1. Line Developpersで、Messaging APIを作成

2. push messageの仕様を確認（[公式ドキュメント](https://developers.line.biz/ja/reference/messaging-api/#send-push-message)）

3. views.pyに関数を作成

   ```python
     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から、下記のようにコメントが届いているか、確認してみてください！

![IMG_0165.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179708/9ee0cc4d-bcb6-bdd6-f648-f54971cf9cee.jpeg)




### 追記：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に渡す変数を追加することができます。

```python
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() 
```