delhi09の勉強日記

技術トピック専用のブログです。自分用のメモ書きの投稿が多いです。あくまで「勉強日記」なので記事の内容は鵜呑みにしないでください。

勉強のためにOpen ID ConnectのIDプロバイダー側をDjangoで実装する③

以下の続き

kamatimaru.hatenablog.com

ユーザー情報提供同意画面

ユーザー情報提供同意画面(以降「同意画面」とする)を実装する。

Viewの実装

同意画面のViewと雛形のテンプレートを作成して、urlを追加する。

views.py

class ConsentView(View):
    def get(self, request, authorization_token):
        return render(request, "consent.html")

consent.html

<html>
  <head>
    <title>ユーザー情報提供同意画面</title>
  </head>
  <body>
    <form action="{% url 'sampleapp:authorize' %}" method="post">
      {% csrf_token %}
      <h1>ログイン</h1>
      {{ form.as_p }}
      <input type="submit" value="登録">
    </form>
  </body>
</html>

urls.py

path("consent/", views.ConsentView.as_view(), name="consent"),

ログイン成功後に同意画面にリダイレクトする。

class AuthorizeView(View):
    # ...省略
    def post(self, request):
        # ...省略
        login(request, user)
        return redirect("sampleapp:consent")

これでログインに成功したら同意画面が表示されるようになった。

ただし以下の課題がある。

  • ログイン画面を経由して同意画面に辿り着いたことを担保していない
  • 提供対象のユーザー情報を表示するためにscopeパラメータの値が必要だが、ConsentViewはその値を保持していない。

→ ログイン成功時に同意画面アクセス用のトークンを発行する必要がありそうである。

同意画面表示用トークンの発行

まずはModelを定義する。トークン文字列を保持するモデル本体と、それに対して紐づくscopeのモデルを定義する。無期限はセキュリティ的に良くないと思うので、expired_atもつけておく。

※ 本当はIdP側が許可しているスコープマスタがあった方がいいのだろうが、今は作らない。

class ConsentAccessToken(models.Model):
    token = models.CharField(max_length=64, unique=True)
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    expired_at = models.DateTimeField()

class ConsentAccessTokenScope(models.Model):
    token = models.ForeignKey(ConsentAccessToken, on_delete=models.CASCADE)
    scope = models.CharField(max_length=32)

ログイン画面でPOSTする際に、現状ではID/パスワードしか送信していないが、scopeも送信する必要がある。

従って、LoginFormのフィールドにscopeをhidden属性で追加する

forms.py

class LoginForm(forms.Form):
    username = forms.CharField(max_length=32)
    password = forms.CharField(widget=forms.PasswordInput)
    scope = forms.CharField(widget=forms.HiddenInput)

    def clean_scope(self):
        scope = self.cleaned_data["scope"].split()
        if "openid" not in scope:
            raise forms.ValidationError("Invalid scope")
        return scope

ログイン画面のテンプレートにformをセットする際に、scopeの値を引き回す。

class AuthorizeView(View):
    def get(self, request):
        form = AuthorizeForm(request.GET)
        if not form.is_valid():
            return HttpResponseBadRequest()
        client_id = form.cleaned_data["client_id"]
        if not RelyingParty.objects.filter(client_id=client_id).exists():
            return HttpResponseBadRequest()

        return render(request, "login.html", {"form": LoginForm(
            initial={"scope": " ".join(form.cleaned_data["scope"])}
        )})

ログイン画面にhiddenでscopeが引きまわされていることを確認できる。

ログイン成功時にトークンを発行して、同意画面にリダイレクトする際のパスパラメータに付与する。

views.py

class AuthorizeView(View):
    # ...省略
    def post(self, request):
        # ...省略
        login(request, user)
        token = ConsentAccessToken.objects.create(
            token=crypto.get_random_string(8),
            user=user,
            expired_at=datetime.now() + timedelta(minutes=10),
        )
        for scope in form.cleaned_data["scope"]:
            ConsentAccessTokenScope.objects.create(
                token=token, scope=scope
            )
        return redirect("sampleapp:consent", consent_access_token= token. token)

同意画面のパスパラメータにconsent_access_tokenを追加する。

urls.py

    path(
        "consent/<str:consent_access_token>/",
        views.ConsentView.as_view(),
        name="consent",
    ),

Viewにconsent_access_tokenを受け取る処理を追加する。 テンプレート側でurlでformのactionのURLを生成しているところにもconsent_access_tokenを渡してあげないとNoReverseMatchが発生する。

views.py

class ConsentView(View):
    def get(self, request, consent_access_token):
        
        return render(
            request, "consent.html", {"consent_access_token": consent_access_token}
        )

consent.html

<html>
  <head>
    <title>Company Registration</title>
  </head>
  <body>
    <form action="{% url 'sampleapp:consent' consent_access_token %}" method="post">
      {% csrf_token %}
      <h1>以下の提供に同意しますか?</h1>
      <input type="submit" value="同意">
    </form>
  </body>
</html>

ここまででURLのパスパラメータにトークンが入った状態で同意画面を表示できるようになった。

提供対象のユーザー情報を表示する

トークンで永続化したことで、同意画面でscopeを参照できるようになったので、それを元に提供対象のユーザー情報を表示する。

views.py

パスパラメータのトークン文字列からトークンオブジェクトを取得する。期限のチェックとユーザーの照合も取得時に同時に行う。

from django.contrib.auth.mixins import LoginRequiredMixin
# ...省略

class ConsentView(LoginRequiredMixin, View):
    def get(self, request, consent_access_token):
        token = ConsentAccessToken.objects.filter(
            token=consent_access_token,
            user=request.user,
            expired_at__gte=datetime.now(),
        ).get()
        return render(request, "consent.html", {"consent_access_token": token})

画面上ではトークンオブジェクトからスコープを取得する。今回はミニマムでスコープにemailが存在する場合に提供対象に「メールアドレス」と表示するのみの実装とする。

consent.html

<h1>以下の提供に同意しますか?</h1>
      {% for scope in consent_access_token.consentaccesstokenscope_set.all %}
          {% if scope.scope == "email" %}
          <input type="checkbox" name="scope" value="{{ scope }}" checked>メールアドレス <br>
          {% endif %}
      {% endfor %}

これで同意画面の表示までできた。

今日はここまで。 次は同意後に認可コードを発行する処理を実装していく。