Article
Laravelで作成したWebアプリをRust(Axum)に置き換えたときのログイン実装はどう考えるべきか。
Laravelで作成したWebアプリをRust(Axum)に置き換えたときのログイン実装を、Laravel Sanctumとaxum-loginの違いを踏まえて整理する。
Laravelで作成したWebアプリのバックエンドをRust(Axum)に置き換えようとしたときに、「ログイン周りをどう作るべきか」で悩んだ。
業務ではLaravelで作成することが多いのだが、認証の仕組みはフレームワーク(Laravel Sanctum)があるためあまり深く考えずに実装できてしまう。
特にSPA認証も簡単にできるため、Rust(Axum)に置き換えようとしたときには特に大きな差を感じた。
この記事では、Laravelで作成したアプリをRustに置き換えようとしたときにログイン周りをどうすれば良いかを、Laravel Sanctumと比較しながら整理する。
あわせて、Axumで一般的なログイン周りのクレートである axum-login が何をしてくれて、何をしてくれないのかもまとめる。
まず前提としてAxumはLaravelのように認証一式を抱えていない
Laravelでは、認証についてほとんどの機能が揃っていると言っても過言ではない。
セッション認証、CSRF保護、ミドルウェア、ユーザープロバイダ、パスワードハッシュ、ガードといった要素が最初から整理されており、Sanctumを使えばSPA認証やAPIトークン認証にも乗せやすい。
一方でAxumは、HTTPルーティングやextractorを中心にしたものであり、Laravel Sanctumに比較すると最低限しか含まれていないフレームワークである。
そのため、ログイン機能を作るときは次のような要素を自分で選んで組み合わせることになる。
- ユーザーをどう取得するか
- パスワード検証をどう行うか
- セッションをCookieベースで持つか、サーバー側ストアに持つか
- 未ログイン時にどこへ返すか
- 権限判定をどこで行うか
- SPA向けにCookie認証でいくのか、APIトークンでいくのか
- CSRF対策をどうするか
Axumの特徴としては、「何でもできるが、何を選ぶかは自分で決める」感じである。
逆にいうと、Laravelの感覚で「認証パッケージを入れればだいたい終わり」と比べると、少しギャップがある。
Laravel Sanctumは何をしてくれるのか
Laravel Sanctumは、Laravel公式ドキュメントでもシンプルな認証パッケージとして位置づけられている。
大きくいうと、Sanctumは次の二つを扱える。
- 自前SPA向けのCookieベース認証
- モバイルアプリや外部クライアント向けのAPIトークン認証
ここで重要なのは、SanctumはCookieセッションとAPIトークンの両方を、Laravelの既存機構に自然につなげてくれるという点である。
特にSPA認証では、Sanctum自体が独自トークン方式で動くのではなく、Laravelのセッション認証とCSRF保護の流れにそのまま乗る。
このため、Laravel側では「自前フロントのログイン」と「一部のAPI利用者向けトークン」の両方を、かなり一貫した体験で扱える。
Laravelに慣れていると、この体験が基準になりやすい。
しかしAxumでは、同じことを実現する場合でも、役割ごとにクレートや設計を分けて考える必要がある。
axum-loginはSanctumの代わりではなく、ログインセッションの土台である
axum-login は、Axum向けにユーザー認証と認可の仕組みを組み込むためのクレートである。
記事公開時点でlatestは 0.18.0 で、AuthUser、AuthnBackend、必要に応じて AuthzBackend を実装し、AuthSession をhandlerで扱えるようにする構成になっている。
このクレートのよい点は、Axumの世界でログイン実装に最低限必要な骨組みを担保している点である。
- ログイン済みユーザーの取り出し
- セッションへのログイン状態の保持
- ログイン必須ルートの保護
- 権限チェックの差し込み
- ハンドラでの認証状態の取得
特に login_required! のようなミドルウェアは分かりやすく、未ログイン時の保護ルートを簡潔に書ける。
また、AuthSession extractorをハンドラの引数として受け取れるため、各handlerがリクエストごとに認証コンテキストを明示的に受け取りながら処理できる。
ここで受け取る AuthSession は単なるユーザー情報そのものではなく、セッションにひもづいた認証状態と、現在ログイン中の利用者を取得するための窓口である。
そのため、Laravelでよく使用する Illuminate\Support\Facades\Auth::user() や auth()->user()、コントローラやミドルウェア内での Illuminate\Http\Request::user() を使って「現在ログイン中のユーザー」を参照する感覚に近いものは作りやすい。
Sanctumを使う場合であれば、必要に応じて Auth::guard('sanctum')->user() のようにガード経由で利用者を取得するイメージに近い。
加えて、実装次第ではhandler内から AuthSession を通じてログイン、ログアウト、認証確認のような処理の起点もまとめやすく、Laravelのガードや認証マネージャを明示的に受け取って使う感覚に寄せやすい。
ただし、ここで誤解しやすいのは、axum-login はSanctumのように「SPA認証とAPIトークン認証を丸ごと提供するパッケージ」ではないという点である。
axum-login が強いのは、あくまで「セッションベースのログイン」をAxumに載せるところである。
そのため、Laravel Sanctumと比較したときに足りないのは主に次の部分である。
- Personal Access Tokenの発行機能
- トークンability/scopeの標準実装
- トークン失効管理の標準実装
- SPA認証に付随するCSRFの標準一式
- ログイン画面、登録、パスワードリセットなどのUI/ルート群
つまり、axum-login は便利ではあるものの、Laravel Sanctumと1対1で対応するクレートではない。
AxumでLaravelのセッション認証に相当する基盤を作るための一部である。
Axumでログイン実装するときの実際の役割分担
AxumでWebアプリのログインを組むなら、実際には次のような分担になることが多い。
axum: ルーティング、handler、extractoraxum-login: 認証状態の管理、ログイン済みユーザー取得、保護ルートtower-sessions: セッション管理- Cookie/session store系クレート: Cookie保存やサーバー側セッションストア
argon2など: パスワードハッシュ- 自前コード: ユーザー検索、認証、ログインフォーム処理、CSRF方針、トークン発行など
ここで特に重要なのは、axum-login が単独で完結しないことである。
内部では tower-sessions を前提にしており、セッションの持ち方をどうするかは別途考える必要がある。
tower-sessions 自体は、セッションIDをCookieに持たせ、データ本体はストアに永続化する構成を取りやすい。
一方で、tower-sessions-cookie-store のようにCookie自体へセッションデータを保持する選択肢もある。
この選択で、インフラ運用、失効のしやすさ、Cookieサイズ、セキュリティ特性が変わる。
Laravelだとこのあたりは設定ファイルとdriverの選択で進めやすいが、Axumでは「どこまでをCookieに持たせるか」「Redisなどを使うか」を自分で決める必要がある。
Sanctumと比べたときの一番大きな差は「トークン認証が別物」であること
Laravel Sanctumは、セッション認証とAPIトークン認証を同じ文脈で扱えるのが手軽な理由だと思う。
自前SPAはCookieベース、外部クライアントやモバイル向けはBearer Token、という構成が比較的自然に作れる。
Axumで axum-login を採用する場合、この後者のAPIトークン部分は以下のように基本的に別途実装である。
- ブラウザログイン:
axum-login+tower-sessions - APIトークン: 独自middleware/extractorを用意する
- DBにトークンテーブルを持つ
- ハッシュ化保存、失効、権限管理を自前で作る
逆に、管理画面や社内ツールのように「ブラウザでログインできれば十分」な用途なら、axum-login はかなり相性がよい。
Laravel Sanctumほど多機能ではないが、その分だけ仕組みを追うことが出来る点が長所である。
普段Laravelを使用する人がAxumで見落としやすいポイント
CSRFは別で考える必要がある
Cookieベース認証を使うなら、CSRF対策は避けて通れない。
Laravelではミドルウェアと既定の流れがあり、SanctumのSPA認証もそこに乗る。
しかしAxumでは、ログインフォームや状態変更系エンドポイントにどう対策を入れるかを自分で決める必要がある。
axum-login を入れただけでは、この問題は解決しない。
例えば、Cookieベースのセッション認証を前提にするなら、Synchronizer Token Patternのように「サーバー側セッションにCSRFトークンを保持し、フォーム送信時に照合する」構成が分かりやすい。
Laravelでいう @csrf と VerifyCsrfToken の流れを、自分で組み立てるイメージに近い。
実装イメージは次のようになる。
- フォーム表示時にランダムなCSRFトークンを生成する
- そのトークンをサーバー側セッションへ保存する
- hidden inputとしてHTMLフォームへ埋め込む
- POST時にフォームから受け取ったトークンとセッション内トークンを比較する
- 一致しなければ
403 Forbiddenを返す
use axum::{
extract::State,
http::StatusCode,
response::{Html, IntoResponse},
Form,
};
use serde::Deserialize;
use tower_sessions::Session;
use uuid::Uuid;
const CSRF_TOKEN_KEY: &str = "csrf_token";
#[derive(Clone)]
struct AppState;
#[derive(Deserialize)]
struct LoginForm {
email: String,
password: String,
csrf_token: String,
}
async fn show_login_form(session: Session) -> Result<Html<String>, StatusCode> {
let csrf_token = Uuid::new_v4().to_string();
session
.insert(CSRF_TOKEN_KEY, csrf_token.clone())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let html = format!(
r#"
<form method="post" action="/login">
<input type="email" name="email" />
<input type="password" name="password" />
<input type="hidden" name="csrf_token" value="{csrf_token}" />
<button type="submit">login</button>
</form>
"#
);
Ok(Html(html))
}
async fn login(
session: Session,
State(_state): State<AppState>,
Form(form): Form<LoginForm>,
) -> Result<impl IntoResponse, StatusCode> {
let stored_token = session
.get::<String>(CSRF_TOKEN_KEY)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let Some(stored_token) = stored_token else {
return Err(StatusCode::FORBIDDEN);
};
if stored_token != form.csrf_token {
return Err(StatusCode::FORBIDDEN);
}
session
.remove::<String>(CSRF_TOKEN_KEY)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// ここでメールアドレスとパスワードを検証し、成功時にaxum-login側でログイン処理を行う
Ok(StatusCode::OK)
}
この方式のポイントは、セッションCookieだけではリクエストを正当化せず、「その画面を正規に表示したクライアントだけが持てる値」を追加で確認することにある。
少なくともログイン、ログアウト、プロフィール更新、パスワード変更、削除処理のような状態変更を伴う場合には同様の対策を入れたい。
SPAでJSON APIを叩く場合も考え方は同じで、初回アクセス時にCSRFトークンを発行し、フロントエンドが X-CSRF-Token のようなヘッダで送る構成が分かりやすい。
ただしその場合でも、どのタイミングでトークンを払い出すか、トークンをローテーションするか、CORS設定とどう整合させるかは別途設計が必要である。
Remember me、メール認証、パスワード再発行は自前寄り
Laravelでは周辺機能まで含めてエコシステムが揃っている。
一方Axumは、ログイン成功後の周辺機能をどこまで持つかで実装量が大きく変わる。
- Remember me
- メール認証
- パスワードリセット
- 多要素認証
- ログイン試行制限
このあたりは axum-login の担当範囲ではないため、自分で別途実装する必要がある。
認証よりも「ユーザー境界」と「権限」の方が難しい
実務では、ログイン処理そのものよりも「誰が何を見てよいか」の方が厄介である。
axum-login は AuthzBackend によって認可の仕組みも差し込めるが、実際にどの粒度で権限を切るかはアプリ次第である。
LaravelだとPolicyやGateの発想に乗せればよいことが多いが、Axumではその設計も自前で行う必要がある。
そのため、最初に考えるべきなのは「ログインしているか」ではなく、「どの利用者が、どの資源に、どの条件で触れられるか」である。
例えば管理画面なら、少なくとも次の三つは早い段階で分けて考えた方がよい。
- 誰のデータなのか: 自分の投稿だけ編集できるのか
- どのロールなのか: 管理者だけが公開操作できるのか
- どの操作なのか: 閲覧、作成、更新、削除で条件が違うのか
LaravelであればPolicyに update や delete により整理するが、Axumでも発想は同じである。
重要なのは、handlerの中に if user.role == "admin"で溢れないよう、「認可判断」を1か所に寄せることである。
例えば、投稿の編集権限を判定するだけでも、実務では次のような条件が混ざりやすい。
- 管理者は全件編集できる
- 一般ユーザーは自分が作成した下書きだけ編集できる
- 公開済み記事は編集申請フローを通すため、通常ユーザーは直接更新できない
このような条件をhandlerに直接書き始めると、画面やAPIが増えるほど判定漏れが起きやすい。
そのため、まずは「アクション単位の判定関数」を切り出す形から始めるのが現実的である。
#[derive(Clone, Debug, PartialEq, Eq)]
enum Role {
Admin,
Editor,
User,
}
#[derive(Clone, Debug)]
struct User {
id: i64,
role: Role,
}
#[derive(Clone, Debug)]
enum PostStatus {
Draft,
Published,
}
#[derive(Clone, Debug)]
struct Post {
author_id: i64,
status: PostStatus,
}
fn can_update_post(user: &User, post: &Post) -> bool {
match user.role {
Role::Admin => true,
Role::Editor => true,
Role::User => {
user.id == post.author_id && matches!(post.status, PostStatus::Draft)
}
}
}
handler側では、認証と認可を分けて扱うと見通しがよくなる。
つまり、「ログインしているか」は axum-login に任せ、その先の「この投稿を更新してよいか」は自前の関数やサービスに寄せる。
use axum::{
extract::{Path, State},
http::StatusCode,
};
use axum_login::AuthSession;
async fn update_post(
auth_session: AuthSession<Backend>,
State(state): State<AppState>,
Path(post_id): Path<i64>,
) -> Result<StatusCode, StatusCode> {
let user = auth_session.user.ok_or(StatusCode::UNAUTHORIZED)?;
let post = state
.post_repository
.find(post_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
if !can_update_post(&user, &post) {
return Err(StatusCode::FORBIDDEN);
}
Ok(StatusCode::OK)
}
この形にしておくと、後で can_update_post を PostPolicy や AuthorizationService のような構造へ寄せやすい。
最初から抽象化しすぎる必要はないが、「認可ロジックはhandlerに埋め込まない」という方針だけは早めに決めておいた方がよい。
また、実務でさらに重要になるのはロールよりもユーザー境界である。
例えばSaaSや管理画面では、admin かどうかよりも「同じテナントに所属しているか」「その会社のデータか」の方が事故に直結しやすい。
そのため、実際には次の順序で判定する方が安全である。
- ログインしているか
- 同じテナント、同じ組織、同じ所有者配下のデータか
- その上で、閲覧や更新の権限を持つか
ここを後回しにすると、/posts/:id のような単純なエンドポイントでも、IDを変えるだけで他人のデータに触れてしまう事故が起きる。
LaravelのPolicyを置き換えるように、Axumでも「対象リソースを読み出した後に認可判定する」流れを明示的に作るのがよい。
AuthzBackend を使う場合も考え方は同じで、ロール文字列だけを返して終わりにせず、「何の操作に対する許可か」をアプリ側で表現した方が運用しやすい。
最初は admin / user の2値でもよいが、更新対象が増えてきたら post:update や user:invite のように操作単位で整理した方が破綻しにくい。
したがって、最初は雑にロール判定だけで始めるよりも、「所有者判定」と「管理者例外」の二段構えくらいまでは最初から入れておく方が実務では安全である。
では、どんなときにaxum-loginを選ぶべきか
個人的には、次のような条件なら axum-login を選択肢に入れてよいと思う。
- Axumでサーバーサイド主導のWebアプリを作る
- まずはブラウザログインだけ欲しい
- セッションベース認証で十分
- 認証ロジックを自分で追える形にしておきたい
- Laravelのような巨大認証基盤までは不要
逆に、次の要件が強いなら最初から別設計を考えた方がよい。
- APIトークン発行を標準機能として持ちたい
- OAuth2連携や外部認可基盤が必要
- 認証まわりをできるだけフレームワーク標準で済ませたい
- CSRF、認証画面、メール認証、再発行などを短期間で全部揃えたい
Axumは自由度が高い分、認証に関しては「何を採用しないか」を先に決めた方がよい。
まとめ
Axumのログイン周りは、Laravelに比べると明らかに自分で実装する箇所及び必要かどうかの判断を問われる場合が多い。
しかし、その分だけ仕組みを理解しながら積み上げやすいという見方もできる。
Laravel Sanctumは、SPA認証とAPIトークン認証をLaravelの既存機構に統合するパッケージである。
一方 axum-login は、Axumにセッションベースのログインを載せるための土台として優秀だが、Sanctumのような「全部入り」ではない。
そのため、Axumでログイン実装を考えるときは、次の切り分けで捉えると分かりやすい。
- ブラウザのログイン状態をどう持つか:
axum-login+tower-sessions - APIクライアントをどう認証するか: 別途トークン設計
- CSRFや権限管理をどうするか: 自前方針を決める
LaravelのSanctumを期待して axum-login を見ると物足りなく感じるが、Axumを使用する場合の「必要最小限のログイン基盤」として見ると、選択肢の一つであると考えられる。
参考リンク
関連記事
Zod とは何かを理解する
Zodとは何か、TypeScriptの型だけでは足りない理由を自分なりに整理する。
Reactの状態管理を理解する
状態管理という言葉が何を指しているのか、なぜ必要になるのかを自分なりに整理する。
Astro で個人ブログを始めるときに最初に決めたこと
Astro と Cloudflare Pages を前提に、個人ブログの初期設計で決めたことをまとめる。