はじめに
これは「エーピーコミュニケーションズのカレンダー | Advent Calendar 2021 - Qiita」20日目の記事です。
ユーザにログインを意識させない!
今年(2021年)の9月に,社外の友人とDjangoでこんなアプリを作りました(β版なのでまだまだ未完成品ですが)。
ソースも公開しています。
Nagyon-Jessica/Shinobi-Mas: Info Management application for Shinobigami
これは互いに独立したルームが複数作られ,それぞれのルームごとに複数のユーザ(オーナーとそれ以外)が所属するような,まぁ仕組みとしてはありがちなアプリになっています。
当然このタイプのアプリでは,所属していないルームに勝手に入れてしまわないよう,ルームへアクセスする際にユーザ認証を行う必要があります。
この場合,ユーザにIDとパスワードを登録してもらい,ログイン画面を用意してフォームの入力内容が登録したものと一致すればルームへのアクセスを許可するといった仕組みを作るのが一般的だと思います。
ですが,そうなるとパスワードの管理やログインフォームへの入力という形でユーザの仕事を増やすことになります。ハイスペックで多機能なアプリケーションならともかく,ちょっとした補助ツール,且つセンシティブな個人情報を扱わないツールを使うためにログインが必要となると,ユーザ的にはあまり嬉しくないのではと設計の時に思いました。
そこで,ユーザ側からはログイン処理を意識することなく,且つルーム間の独立性が担保されるような仕組みを考えてみました。
バージョン情報
Django: 3.1
Python: 3.7
こんなんできました
①ルームを作成すると,オーナー用のログインURLが払い出される(ブラウザのアドレスバーには共通のパスが表示される)
②ルームに入り直す際は,ログイン用URLにアクセスすると操作なしで再入室できる
(セッションが残っているうちは共通のパスにアクセスしても再入室できるが,セッションが切れた後ではトップページにリダイレクトされる)
③オーナーがユーザを作成すると,ユーザ用のログインURLが払い出される
④ユーザはログイン用URLにアクセスするとオーナーが作成したルームに操作なしでアクセスできる(権限は制限される)
仕組み
画像を見ていただければわかると思いますが,ルームに対してUUIDを,ユーザにはp_codeという8文字のランダム文字列を割り当て,{UUID}?p_code={p_code}
がログイン用のパスになっています。
ルームはシステム全体で極力重複が起きないようにする必要があるため,UUIDを採用しました。UUIDがどのくらい重複しないかについてはこちらの記事をご参照ください。
UUID(v4) がぶつかる可能性を考えなくていい理由 - Qiita
それに比べてユーザIDの方はルーム内で一意であればよく,且つ一つのルームにはせいぜい10人程度しか所属しないため,短めのランダム文字列になっています。8文字という長さについては特に根拠はありませんが,あまりに短いとルーム内の他のユーザが予測できてしまい,なりすましが可能になってしまうので,ある程度の長さは必要と判断しました。
{UUID}/{p_code}
ではなくクエリパラメータにした理由は忘れてしまいました……。別に{UUID}/{p_code}
でもいい気がします。
どのくらい安全?
- 攻撃者がルームのUUIDもp_codeも知らない場合
- 特定のルームに特定のユーザとしてログインできるか
- 不可能
- 何かしらのルームの誰かしらのユーザとしてログインできるか
- たぶん不可能
- 「このUUIDは存在するけどp_codeが間違ってるよ」という情報は得られないので,結局UUIDとp_codeの総当たりになる
- 特定のルームに特定のユーザとしてログインできるか
- 攻撃者がUUIDを知っている場合
- あるルームのユーザが,オーナーや他のユーザとしてログインできるか
- [a-zA-Z0-9]×8のランダム文字列をオンラインでクラッキングするのは結構難しそう
- 参考:パスワードは何桁以上にするのが良いですか?に対する徳丸 浩さんの回答 - Quora
- あるルームのユーザが,オーナーや他のユーザとしてログインできるか
- URLにログイン情報含まれちゃってるけど大丈夫?
- HTTPSならURLも暗号化されるので,途中で盗み見られることはない
- 今の仕様だとオーナーのルーム画面にログイン用のURLが常に表示されているので,画面ロックせずに離席……とかはマズい
- 勝手にログインされちゃったらどうなる?
- ルームに登録した情報が見れてしまう(オーナーの権限を奪った場合は編集や削除ができてしまう)
- ユーザ名を本名にしていたら個人名は見られてしまう
- オーナーのメールアドレスはシステムに登録されているが,どの画面にも表示されないため勝手に見られることはない
- ルームに登録した情報が見れてしまう(オーナーの権限を奪った場合は編集や削除ができてしまう)
ということで,安全性はそこそこ担保できているのではないかと思います。
とはいえ,ユーザの大事な個人情報を扱うようなシステムで使うことは当然オススメしません。あくまで「そんなに重大な情報は扱わないけど設計上認証が必要」という場合のオプションとしてみていただけたらと。
実装のポイント
基本的にはカスタムユーザとカスタム認証バックエンドを組み合わせたものですが,ログイン画面を経由しないようにするためのポイントもあります。
settings.py
...(前略)...
AUTHENTICATION_BACKENDS = ['homaster.backends.PlayerAuthBackend']
...(中略)...
AUTH_USER_MODEL = "homaster.Player"
LOGIN_URL = "index"
...(後略)...
AUTHENTICATION_BACKENDS
: 認証バックエンドをリストで指定します。
AUTH_USER_MODEL
: ユーザ認証に用いるユーザのモデルを指定します。
LOGIN_URL
: login_required()
やLoginRequiredMixin
などを使用したアクセス時にログインを要求するビューが存在する場合は,ユーザが未ログインだとログインページ(デフォルトでは/accounts/login/
)へリダイレクトさせます。ただし今回はログインページが存在しないので,トップページのパスであるindex
を指定しています。
models.py
...(前略)...
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
# マネージャ
class MyManager(BaseUserManager):
pass
# ルーム
class Engawa(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
scenario_name = models.CharField(verbose_name="シナリオ名", max_length=100)
# ユーザ
class Player(AbstractBaseUser):
email = models.EmailField(verbose_name="メールアドレス", null=True, blank=True)
engawa = models.ForeignKey(Engawa, on_delete=models.CASCADE, editable=False)
handout = models.OneToOneField(Handout, on_delete=models.CASCADE, null=True, blank=True, editable=False)
p_code = models.CharField(max_length=8, editable=False)
role = models.PositiveSmallIntegerField(default=0)
...(後略)...
ユーザ認証にユーザ名以外を使いたい場合は,AbstractBaseUser
を継承したカスタムユーザを定義する必要があります。ユーザやスーパーユーザの作成にcreate_user()
,create_superuser()
を使いたい場合はBaseUserManager
を継承したマネージャのクラス定義でそれらのメソッドをオーバーライドしたりする必要がありますが,そうでなければマネージャを自分で定義する必要はありません。
カスタムユーザの作り方を紹介しているサイトに「マネージャも必要」とあったので一応書いてみたんですが,試しに開発環境でコメントアウトしたら普通に動いたので書かなくても大丈夫そうです。写経の功罪ですね。
Djangoのモデルは,デフォルトでは1始まりの連番のid
カラムが主キーになっているんですが,ルームはUUIDで必ず一意に定まるので,uuid
カラムをprimary_key=True
とすることによってid
カラムを作らずにuuid
を主キーとすることができます。
また,ユーザは外部キー制約によって必ずどれか一つのルームに所属するようになっているので,ルームのUUIDとp_codeでユーザテーブル内をAND検索すれば結果はゼロイチになるはずです。
backends.py
from django.contrib.auth.backends import ModelBackend
from .models import Player, Engawa
class PlayerAuthBackend(ModelBackend):
def authenticate(self, request, uuid=None, p_code=None):
try:
player = Player.objects.get(engawa=Engawa(uuid=uuid), p_code=p_code)
except Player.DoesNotExist:
return None
return player
ユーザ名とパスワード以外で認証したい場合は,認証バックエンドをカスタマイズする必要があります。普通はsettings.AUTH_USER_MODEL
に対して認証を行うと思うので,django.contrib.auth.backends.ModelBackend
を継承したクラスを定義して,authenticate()
をオーバーライドすれば取り敢えず目的は達成できると思います。
authenticate()
は一定の条件を満たせば認証するユーザのオブジェクトを,そうでなければNone
を返す関数である必要があります。ここではリクエストURLから抽出したルーム(Engawa
がそれです)のUUID1とp_codeでDBを検索し,レコードが見つかれば認証するというシンプルな設計になっています。
引数のrequest
は使っていないから書かなくても良さそうに見えますが,これは書かないとちゃんと認証できなくなるっぽいです。理由は謎です。追々調べようと思います。
views.py
...(前略)...
from django.shortcuts import get_object_or_404, redirect, render
...(中略)...
from django.contrib.auth import authenticate, login
...(中略)...
def signin(request, **kwargs):
if "p_code" in request.GET:
p_code = request.GET.get("p_code")
else:
logging.error("There is not p_code in query string.")
return redirect('homaster:index')
# アクセスユーザの存在確認
uuid = kwargs['uuid']
player = authenticate(request, uuid=uuid, p_code=p_code)
if player:
...(中略)...
# 存在するユーザならログイン
login(request, player)
request.user = player
else:
# ユーザが存在しなければトップページへリダイレクト
logging.error(f"There is not a player with p_code {p_code} in ENGAWA {uuid}")
return redirect('homaster:index')
# ハンドアウト一覧画面へ遷移
return redirect('homaster:engawa')
特に変わった点はないと思います。authenticate()
を呼び出し,返り値がNone
でなければ認証OKなのでログイン処理を行った上でルームにリダイレクトさせ,None
だった場合は認証NGなのでトップページにリダイレクトさせています。
なんでdjango.contrib.auth
のauthenticate()
なんだってとこだけ気になると思いますが,django.contrib.auth.__init__.py
のauthenticate()
を見ると,settings.py
で指定した認証バックエンドのauthenticate()
を呼んでいるのでこれで問題ないわけです。逆にそうしないと認証バックエンドが複数ある場合が面倒ですからね。
urls.py
from django.urls import path
...(中略)...
from . import views
app_name = 'homaster'
urlpatterns = [
path('index', views.IndexView.as_view(), name='index'),
...(中略)...
path('detail/<int:pk>', views.HandoutDetailView.as_view(), name='detail'),
...(中略)...
path('<uuid>', views.signin, name='signin'),
path('', RedirectView.as_view(pattern_name="homaster:index")),
]
パスがUUIDの場合は上述のsignin()
を実行するようにパスコンバータを使って定義しています。マッチした際,UUIDはsingin()
にキーワード引数として渡されます。
ポイント(というか問題)は,例えばパスコンバータで定義したパスを一番上に持って行くと,何故かUUIDでないパスにもマッチしてしまい,アプリの挙動がおかしくなります。理由は謎です。これも調べておきます。
なのでパスコンバータが頭にくるパスは,パスコンバータを使わないか後にくるパスの下に置いておく必要があります。
おわりに
以上でログイン操作不要のログイン処理が実装できました。何か参考になる部分があれば幸いです。
やり方がわかってしまえばそこまで難しくもないですが,Djangoでこういった認証を採用したという情報がネットで見つからなかったので,自分で実装方法を編み出さないといけなかったのは大変でした。
筆者はセキュリティに関してまるっきりの素人なので,この実装について見落としている脆弱性があったら後学のためにも是非教えてください。
最後までご覧いただきありがとうございました。
その他参考文献
Djangoの認証システムを使用する | Django ドキュメント | Django
Django の認証方法のカスタマイズ | Django ドキュメント | Django
設定 | Django ドキュメント | Django
Django AbstractBaseUserでカスタムユーザー作成
-
正確には与えられたUUIDを主キーとするルームのオブジェクトです。 ↩