元ネタ
@localdisk さんの記事です。
こちらで概ね適切に説明されているものの,文章のみで図が無くて直感的に把握しづらいので,初心者にもすぐ飲み込ませられるように図に描き起こしてみました。
図
解説
illuminate/auth
: 最小限の認証認可コアロジック
コアコンポーネント群の laravel/framework
に含まれているものです。 Socialite 以外のすべてのパッケージが,実質このコアに依存していることになります。
以下の記事でこのパッケージの詳細について説明しているので,ここでは端折って説明します。
伝統的 Cookie ベースのセッション認証
こちらでも解説している, 「Cookie に識別子を載せ,それに対応する情報はサーバ側のファイルに記録する」 という手法に近いものです。 実装は illuminate/session
にあり, PHP ネイティブのセッションの挙動を上書きする SessionHandler インタフェースを実装しています。PHP デフォルトだとファイル書き込み一択ですが, Laravel ではドライバとしてファイル以外にも,データベース,Redis あるいは Cookie 自体にデータも暗号化して含める,なども選択できるようになっています。
Laravel デフォルトの設定で Auth
ファサードを使うと,このセッション機能を使用する SessionGuard が選択されます。
// 新規ログイン
$user = Auth::attempt(['email' => '...', 'password' => '...']);
// ログインしている場合は取得できる
$user = Auth::user();
BASIC 認証
SessionGuard
が持っている別の機能です。 Request
は内部的に保持しているようなので,引数なしで呼ぶだけで Auth::attempt()
と同様の認証できます。
// https://user:password@host:port 形式で URL が指定されたときに user と password を抽出できる
$user = Auth::basic();
任意ロジック認証
上記で解説しているように, Auth::viaRequest()
を使って RequestGuard
を使うと,任意の認証ロジックに対応することができます。基本は簡易的にコールバックで済ませますが,自前で callable
クラスとして定義してもいいし, UserProvider
だけカスタムで作ってもいいし,あるいは Guard
ごと自分で作ってもよいです。
UserProviderを無視する場合Auth::viaRequest('custom-token', function (Request $request): ?User { return User::where('token', $request->token)->first(); });
UserProviderの仕組みに乗る場合Auth::viaRequest('custom-token', function (Request $request, UserProvider $provider): ?User { return $provider->retrieveByCredentials($request->only('token')); });
メールアドレス検証
以下のクラスが内包されています。
-
auth/MustVerifyEmail.php
-
User
モデルに実装する簡易的なトレイト(対応するコントラクトもある)
-
-
auth/Notifications/VerifyEmail.php
- 認証用メール(Laravel の Notification 機能を利用している)
- メールをカスタムしたい場合は VerifyEmail::toMailUsing() を利用する
- 認証用 URL をカスタムしたい場合は VerifyEmail::createUrlUsing() を利用する
-
auth/Middleware/EnsureEmailIsVerified.php
- メールアドレスが検証済みかどうかをチェックする HTTP ミドルウェア
-
auth/Listeners/SendEmailVerificationNotification.php
- ユーザの登録完了(
Registered
)イベントに呼応してVerifyEmail
メールを送るイベントリスナー - デフォルトで EventServiceProvider に登録されている
- ユーザの登録完了(
これらのソースを読み解く上での一番の問題は
「デフォルトでメールに記載される URL の行き先ってどうなってるの?」
という点。上記の VerifyEmail
のソースを読むと…
/**
* Get the verification URL for the given notifiable.
*
* @param mixed $notifiable
* @return string
*/
protected function verificationUrl($notifiable)
{
if (static::$createUrlCallback) {
return call_user_func(static::$createUrlCallback, $notifiable);
}
return URL::temporarySignedRoute(
'verification.verify', // ←これなに????
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
[
'id' => $notifiable->getKey(),
'hash' => sha1($notifiable->getEmailForVerification()),
]
);
}
突然 verification.verify
という謎の指定が現れますが,これは他の UI を提供するためのパッケージで生成されるルートの名前です。
- laravel/ui の実装
- laravel/breeze の実装
-
laravel/fortify の実装
(JetStream は Fortify を利用しているのでこれに含まれる)
暗黙の了解で飛び道具が出現するので,これはしっかりソースを読んでいないと追うのが大変…
パスワードリセット
以下の記事で PasswordBroker
という概念について詳しく解説されています。
このパッケージの文脈においては,「トークン」は 「パスワードリセットトークン」 を意味することに注意してください。つまり,言い方を変えれば,
illuminate/auth
にはパスワードリセットトークン以外のトークンは出現しない
ということになります。また,パスワードリセットにおいても password.reset
というルートの名前が現れます。
- laravel/ui の実装
-
laravel/breeze の実装
- なんでここだけオレオレやねん!w ↓
ResetPassword::createUrlUsing(function ($notifiable, $token) { return config('app.frontend_url')."/password-reset/$token?email={$notifiable->getEmailForPasswordReset()}"; });
-
laravel/fortify の実装
(JetStream は Fortify を利用しているのでこれに含まれる)
シンプルな認可
Gate
と Policy
を使った認可です。これについては,最初にも引用した以下の記事で詳しく解説しています。
laravel/breeze
: 基本的な MPA 認証
Breeze, Fortify, JetStream などが,コアの illuminate/auth
の機能を利用して実装している部分です。
最小限の認証ロジックに対するバックエンドの HTTP 層実装
既に「ルーティングで飛び道具が使われているよ」と述べた通りです。メールに記載される URL のルーティングだけについて触れましたが,メール送信よりも前のログイン画面などもこちらに含まれます。
フロントエンド
歴史的なものは除外し,最新の Breeze についてのみ触れます。 Breeze は 4 個のスタック から実装を選ぶことができるようになっています。
-
Default
- 簡易的・最小限の機能を提供する実装。既に示している通り,
illuminate/auth
にはフロントエンドと HTTP ルーティング・コントローラなど末端部分は含まれていないので,それを実際に動作させるために必要になります。 CSS は Tailwind ベース。
- 簡易的・最小限の機能を提供する実装。既に示している通り,
-
API
-
laravel/sanctum
を任意でインストールすることができ,その場合に限定して使われます。 Sanctum 自体についての説明は後述します。
-
-
Inertia-React
Inertia-Vue- 「インテリア」っぽく空目しますが正しくは「イナーシャ」らしいです。モノリスらしく React/Vue の面倒は全部 PHP で見ようや!という思想。ページロード時のバックエンドからフロントエンドへのデータの流し込みをシームレスに行うことにフォーカスしているライブラリです。 しかしフロントエンドはフロントエンドで独立して時代が進んでいくものなので,今更バックエンド側に全部含めようという思想はあまり時代にマッチしていないでしょう。バックエンドに完全にロックインしてもいい,プロトタイプや管理画面で割り切って使うぐらいにとどめておくのがよいと思います。
「MPA」と言いましたが,ここでいう MPA には 「Laravel で全て面倒を見る一部 SPA なアプリケーション」 が含まれます。
(Inertia でフル SPA を作ることは困難で,あくまで部分的なユースに限られると思われます)
laravel/fortify
: カスタマイズ性の高い拡張認証認可コアロジック
Fortify, JetStream でのみ利用できる機能です。 JetStream は Fortify を利用して構築されているので,共通の機能に関しては Fortify 視点で解説します。
細部の実装を自由に差し替えられるコントラクト
Fortify の特徴を掴むためには,以下のファイル群を見ると最もわかりやすいでしょう。
ずらーっとコントラクトばっかり並んでいますね。これら対応した 実装はすべてサービスコンテナ経由で作られる …これが意味するのは あらゆる実装を簡単にカスタムできる ことです!
拡張認証認可コアロジックに対するバックエンドの HTTP 層実装
アプリケーションの要件に依存する フロントエンドのビューは全く含まれない パッケージですが,ビューを制御するレスポンスクラスである SimpleViewResponse
ベースの実装は既にされているため, パス通りにビューファイルを配置するだけでいい ようになっています。
/** * Register the views for Fortify using conventional names under the given prefix. * * @param string $prefix * @return void */ public static function viewPrefix(string $prefix) { static::loginView($prefix.'login'); static::twoFactorChallengeView($prefix.'two-factor-challenge'); static::registerView($prefix.'register'); static::requestPasswordResetLinkView($prefix.'forgot-password'); static::resetPasswordView($prefix.'reset-password'); static::verifyEmailView($prefix.'verify-email'); static::confirmPasswordView($prefix.'confirm-password'); }
また,ビューを含まないリダイレクトレスポンスはデフォルト実装そのままで使用できます。
また, ユーザ登録などに関する HTTP リクエストを受けて,その情報をデータベースに保存する部分など関して。アプリケーションによってユーザ情報の持ち方は様々であり,最も自由度が求められる部分です。ここをカスタマイズしやすくするために,一部のファイルはコマンドによって 書き換える前提でスタブから生成されるようになっています。
これらのスタブを自分のアプリケーションに合うように編集し,ビューファイルを設置するところまでが完全にアプリケーション開発者に委譲されています。
使用する機能の選択
以下の設定ファイルから分かる通り,使う機能のみを有効化することができる仕組みがあります。
/* |-------------------------------------------------------------------------- | Features |-------------------------------------------------------------------------- | | Some of the Fortify features are optional. You may disable the features | by removing them from this array. You're free to only remove some of | these features or you can even remove all of these if you need to. | */ 'features' => [ Features::registration(), Features::resetPasswords(), // Features::emailVerification(), Features::updateProfileInformation(), Features::updatePasswords(), Features::twoFactorAuthentication([ 'confirm' => true, 'confirmPassword' => true, // 'window' => 0, ]), ],
- ユーザ登録
- パスワードリセット
- メールアドレス検証
- ユーザ情報変更
- 「プロフィール」とあるので何やらリッチな情報がありそうですが,スタブで提供される実装は 「名前」「メールアドレス」 だけ です
- パスワード変更
- 多要素認証
MFA (Multi-Factor Authentication, 2-Factor Authentication)
何が二要素認証じゃ多要素認証と言え
Fortify を採用する最も大きなメリットの 1 つです。今どき自前で認証を吸えるなら, MFA の実装はセキュリティ上必須に近いと言ってよいでしょう。
- TwoFactorAuthenticationProvider
-
TwoFactorAuthenticatable
- ここがロジックのコアですが, Eloquent Model にミックスインするトレイトとなっているので,めちゃくちゃデータベースと密結合になります。
ぶっちゃけ設計めっちゃ汚いです。このあたりが気になる場合は, Fortify に依存せずに自前で実装してみてもよいでしょう。中身ライブラリ丸投げでペラッペラだし全然自前で書けるやろこのぐらい
- ここがロジックのコアですが, Eloquent Model にミックスインするトレイトとなっているので,めちゃくちゃデータベースと密結合になります。
- RecoveryCode
Fortify 最高!…と言いたいのですが,更に実装にちょっとナンセンスなところがあり,それ起因で私のライブラリに不具合報告が来ている点がちょっと不満です…一応修正 PR を出したのですがマージされるかどうか…
追記: マージされませんでした。いつもの定型文で詳細不明のお断りをされました
スロットリング
LoginRateLimiter
というクラスによる処理がログインに絡む随所に仕込まれており,攻撃を受けた場合に速めに検知してロックアウトさせることができます。「メールアドレス」「IPアドレス」の組み合わせがキーになります。
これも RateLimiter
の薄いラッパークラスなので Fortify 独自機能かというと微妙なところよね
このあたりを鑑みると, Foritfy の一番の恩恵はあらゆる実装を差し替え可能なことであり, MFA やスロットリングはおまけ機能と考えてよいでしょう。
laravel/jetstream
: Fortify ベースの全部入りパッケージ
Fortify では抽象化されていた,フロントエンドのビューも何から何まで全部付いてくるパッケージです。
機能が盛り沢山ですが,個人的に使う気が起きないので細かい説明は割愛します。基本は Fortify をカスタムして使うのが一番筋がいいと思う。
RBAC (ロールベースアクセス制御)
JetStream 固有の実装の 1 つがこれ。
- Team.php
- Role.php
- HasTeams.php
- HasProfilePhoto.php
- database/migrations/2020_05_21_200000_create_team_user_table.php
この並びを見たら何となく実装の想像が付く人も多いんじゃないでしょうか?ユーザがチームに所属して,チームに所属するときにロールを持って…(なんか知らんけどプロフィール写真の機能もある…)
ただ,このテーブル構造だけで,実際の商業レベルで要求される RBAC をすべて満たすことはほぼ不可能だと思います。「Breeze よりリッチな機能を持ったお遊びパッケージ」という言葉がよく似合うかと。
Livewire フロントエンド
Breeze のときはフロントエンドは Inertia というライブラリ一択でしたが, JetStream では Livewire というものも選択できるようです。
ドメインに Laravel が入っている通り, Inertia を超えるレベルで更に Laravel とフロントエンドが密結合になるライブラリ です。その代わり, Inertia では不可能だった 双方向データバインディング を実現できる可能性を秘めています。
「全部 PHP で SPA を作れたらいいのに〜」という PHPer の夢が詰まっています。裏でめっちゃ頑張ってリアルタイム通信しているようです。超絶ブラックボックスなので使用は慎重に
laravel/sanctum
: SPA 認証 & ネイティブアプリ認証
laravel/passport
は OAuth 2 プロトコルに準拠したサーバを提供できますが,実装が複雑すぎて敬遠されることが多かったようです。現在は, トークンを発行する用途では Passport を簡略化した laravel/sanctum
が使われることが一般的です。
ところで Sanctum は,以下のように使われることを想定しています。
方式 | クライアント |
---|---|
伝統的 Cookie ベースのセッション認証 | 自社の SPA のための API |
トークン認証 | ネイティブアプリ 他社提供する API |
「SPA といえばトークンでしょ」という時代もありましたが, トークンを Web ブラウザ上の LocalStorage 配下で管理させるのはトークン漏洩リスクが高く,敬遠されるべき と言われるようになってきました。
上記の記事ではインメモリが推奨されていますが,トークン漏洩耐性がフロントエンドの実装に左右されるため,フルマネージドな Auth0 などに乗っからない場合はサーバ側で完結できていたほうが無難,という考え方もできるでしょう。一方, JavaScript から中身を覗くことができない HttpOnly な Cookie であったとしても CSRF をケアしなければならないという話は, (上記の記事では触れられていませんが) SameSite=Lax という設定によって大幅にリスクを減らすことができるようになりました。
ただ SameSite=Lax では GET リクエストに関しての CSRF は防げません。 API アクセス前提であれば GET リクエストにも Origin ヘッダ検証 や CSRF トークン検証 も加えれば完璧ということになりますが… Sanctum はまさにその方法で安全性を担保しています。個人的な意見としては, HttpOnly + SameSite=Lax を両方有効にした Cookie に CSRF トークンを組み合わせる が,自社 SPA の API を提供する上ではバランスが最も取れている選択肢だと感じています。
HttpOnly の指定によって XSS それ自体の対策として万全になるわけではありません。あくまで 「XSS されても認証情報の直接的な流出は避けられる」 だけ,即ち自分が Web サイトを閲覧していないときにも好き勝手されるという状態を回避できるだけであって,そもそも XSS 脆弱性を生んでしまった時点で致命的です。これを踏まえてもなおメリットとして成立する点としては,以下のようなものが挙げられます。
- パスワード変更・メールアドレス変更・アカウント削除・API キーの発行など, アカウントの操作に直結する重要なアクションに 「現在のパスワードの再確認」を挟んでいれば ,ログイン権限の奪取やバックドアに対しては耐性を持つ。
- XSS が確認された後に,認証情報を直ちに無効化しなくても被害が拡大しにくい。 XSS 脆弱性を潰せばひとまず時間的猶予はできる。もし認証情報そのものが流出してしまうと,それの無効化など,インシデント対応の手間が増える。
自社 SPA 向けの Cookie 認証
Sanctum の自社 SPA 向け実装の意図を理解するためには,以下の 3 つのファイルを読むといいです。
-
Guard.php
-
__invoke()
の前半部分が全てです。この処理はilluminate/auth
のRequestGuard
から呼ばれる部分ですが, 後半のトークン認証に分岐している部分を除けば,ほぼSessionGuard
に処理を丸投げしているだけです。つまり, トークン認証を使わないのであれば直接SessionGuard
を使うのと大差ありません。
/** * Retrieve the authenticated user for the incoming request. * * @param \Illuminate\Http\Request $request * @return mixed */ public function __invoke(Request $request) { foreach (Arr::wrap(config('sanctum.guard', 'web')) as $guard) { if ($user = $this->auth->guard($guard)->user()) { return $this->supportsTokens($user) ? $user->withAccessToken(new TransientToken) : $user; } } // ... }
- トークン認証にも対応したユーザを使っている場合, Cookie 認証ではダミーの
TransientToken
が格納されます。
-
-
Http/Middleware/EnsureFrontendRequestsAreStateful.php
- HTTP ミドルウェアです。上で説明したように,理想的なセキュリティ検証を行っていることが一目で分かると思います。認証用の Cookie は
HttpOnly
かつSameSite=Lax
です。
- HTTP ミドルウェアです。上で説明したように,理想的なセキュリティ検証を行っていることが一目で分かると思います。認証用の Cookie は
-
Http/Controllers/CsrfCookieController.php
- これ自体は何もしないエンドポイントですが,ここ(
/csrf-cookie
)にアクセスしてきたときに,他のリクエストを送るとき必要なX-XSRF-TOKEN
というヘッダで付与させる CSRF トークンを返すことを意図しています。 -
VerifyCsrfToken.php と Cookie.php を見ると分かる通り, CSRF トークン用の Cookie は
HttpOnly
ではありません。これは JavaScript から読み取って使われる想定であるためです。
(万が一 XSS で盗まれても直ちに乗っ取りには繋がりません)
- これ自体は何もしないエンドポイントですが,ここ(
ネイティブアプリ/サードパーティ向けのトークン認証
ネイティブアプリ向けにはトークンを使うことが極めて一般的であり,特に懸念はありません。同じように,ソースコードからアプローチしてみましょう。
-
Guard.php
-
__invoke()
の後半部分が該当します。トークン専用のテーブルがあり,有効性検証後にトークンに紐付くユーザを取得しているのが分かります。トークンを最後に使用した日時が記録されるのは分かりやすくていいですね。RDB 上でこれをやるのは書き込みコストはどうなんだろう
/** * Retrieve the authenticated user for the incoming request. * * @param \Illuminate\Http\Request $request * @return mixed */ public function __invoke(Request $request) { // ... if ($token = $this->getTokenFromRequest($request)) { $model = Sanctum::$personalAccessTokenModel; $accessToken = $model::findToken($token); if (! $this->isValidAccessToken($accessToken) || ! $this->supportsTokens($accessToken->tokenable)) { return; } $tokenable = $accessToken->tokenable->withAccessToken( $accessToken ); event(new TokenAuthenticated($accessToken)); if (method_exists($accessToken->getConnection(), 'hasModifiedRecords') && method_exists($accessToken->getConnection(), 'setRecordModificationState')) { tap($accessToken->getConnection()->hasModifiedRecords(), function ($hasModifiedRecords) use ($accessToken) { $accessToken->forceFill(['last_used_at' => now()])->save(); $accessToken->getConnection()->setRecordModificationState($hasModifiedRecords); }); } else { $accessToken->forceFill(['last_used_at' => now()])->save(); } return $tokenable; } }
-
-
CheckAbilities.php
- トークンの権限をチェックするミドルウェアです。エンドポイントごとに設定する使い方が想定されています。
-
HasApiTokens.php
-
User
モデルにミックスインして使うトレイトです。トークンを作成するときにcreateToken()
を呼び出します。デフォルトではアビリティは*
になっていて,全てのアクションが許可されます。
/** * Create a new personal access token for the user. * * @param string $name * @param array $abilities * @return \Laravel\Sanctum\NewAccessToken */ public function createToken(string $name, array $abilities = ['*']) { $token = $this->tokens()->create([ 'name' => $name, 'token' => hash('sha256', $plainTextToken = Str::random(40)), 'abilities' => $abilities, ]); return new NewAccessToken($token, $token->getKey().'|'.$plainTextToken); }
-
以上です。若干ソースコードクオリティが気になる部分はありますが, Passport と比べると Sanctum はかなりシンプルで,本当に必要なものだけを残した感がありますね。
laravel/socialite
: OAuth 1, OAuth 2 プロトコルを用いたソーシャルログインのためのクライアント
これまでは illuminate/auth
に関連したものでしたが,これは全く用途が異なります。これはサードパーティのサービスにユーザを OAuth でログインさせて,ユーザの代わりになってそのサービスの API を叩けるようにする機能です。実際に「Sign in with Twitter」などの機能を実現する場合, Twitter でログインして取得した情報を,自分のアプリケーションの管理下にあるテーブルに保存する必要があります。この部分の面倒は見てくれないので,自分で実装しましょう。
選定基準
既に @localdisk さんの記事でも触れられているとおりですが,概ね以下のようなところでしょう。
まず,用途が明確なものを整理します。
- ソーシャルログインを実装したい場合, とりあえず Socialite は入れる。但し,これだけで自分のアプリケーション側の認証管理はできないので,他と組み合わせることになる。
-
自社 SPA 向けの API 認証や,ネイティブアプリ・サードパーティ向けのトークン認証を検討する場合, とりあえず Sanctum は入れる。但し,(コアの
illuminate/auth
を含む)他と組み合わせることになる。- サードパーティ提供を重視し,厳格な OAuth 2 プロトコルに則ったサーバを立てたい場合, Sanctum の代わりに Passport を検討する。但し, Sanctum のように 「1つ立てておけば Cookie もトークンも対応できる」 という特性はなく, Guard を 2 つ並べることになるだろう。
次に, 基盤や MPA 向けの機能を提供するものを整理します。
- Auth0 や Cognito などのフルマネージドサービスや自社の認証基盤に乗っかる場合は,
illuminate/auth
をそのまま使うのがベスト。また, Sanctum が提供する SPA やネイティブアプリ向けの認証で全て完結する場合もこれで OK。
以下,自前でガッツリユーザ情報を管理する場合。
- 初心者のポートフォリオ制作などでは Breeze が最適。
- 一般的な商用ユースケースには Fortify が最適。
- JetStream は積極的に使うべきではないが,「管理画面用途であるため UI デザインは関心がない」かつ「Breeze では機能不足」というときには有用。