ASP.NET Core Cookie認証をIdentityなしで実装する方法とHTTP 405(Method Not Allowed)の原因:Razor Pages/MVCの違いも解説

ASP.NET CoreでIdentityを使わずCookie認証を自前実装すると、ログインフォームの送信先やHTTPメソッドが噛み合わずHTTP 405(Method Not Allowed)に遭遇しがちです。この記事では、Razor PagesとMVCの違いを軸に、405の原因と最小ログインフロー、確認ポイントを具体例で整理します。

目次

ASP.NET Coreの「Cookie認証」と「Identity」の関係を整理する

まず押さえておきたいのは、Cookie認証とASP.NET Core Identityは同じものではない、という点です。Cookie認証は「ログイン済みであることをブラウザのCookieで維持する仕組み」で、Identityは「ユーザー管理(登録・パスワードハッシュ・ロール・2FA・ロックアウト・外部ログインなど)まで一式を提供する枠組み」です。

Identityを使わない構成では、Cookieを発行するところ(SignInAsync)はASP.NET Core標準の仕組みを使いつつ、ユーザー名とパスワードを検証する部分や、どんなClaimをCookieに入れるかを自分で実装します。つまり、Cookie認証は「入口(ログイン)」「通行証(Cookie)」「門番([Authorize])」の仕組みで、Identityは「通行証を発行するための身分証発行所+台帳」まで用意するイメージです。

観点Identityを使うIdentityなしでCookie認証を自前実装
ユーザー管理ユーザー作成・更新・パスワード変更などが用意されている既存DB/外部IDP/社内認証などに合わせて自分で実装
ログインUI既成のUI/テンプレート(またはScaffold)が使えるMVC/Razor Pages/Minimal APIなどアプリの構造に合わせて自作
Cookie発行内部的にCookie認証(またはトークン)を利用HttpContext.SignInAsyncで自分で発行
ハマりどころ設定項目が多く、理解せずに入れるとブラックボックス化しやすいルーティング/HTTPメソッド/ReturnUrl/セキュリティを自分で揃える必要

今回のように「Identityパッケージは使わず、Cookie認証だけでログインを作りたい」というケースでは、アプリがRazor PagesなのかMVCなのかで実装の入口が変わります。ここを取り違えると、フォーム送信が正しいハンドラーに届かず、HTTP 405が出やすくなります。

HTTP 405(Method Not Allowed)を正しく理解する

HTTP 405は「URL(エンドポイント)は存在するが、そのHTTPメソッドは許可されていない」という意味です。例えば、同じ/Account/Loginでも、サーバー側がGETしか受け付けない設定なのに、ブラウザがPOSTした場合などに発生します。

405を見たら、まず「ルートが間違っている」のではなく、メソッドの不一致(GET/POSTなど)を疑うのが近道です。混同しやすいステータスと違いを表で整理します。

ステータス意味ログイン周りでの典型例
400 Bad Requestリクエストが不正(形式/検証エラー)CSRF対策のトークン不足、モデルバインドに必要な値が欠けている
401 Unauthorized認証が必要、または認証情報が無効APIにトークンなしでアクセス、Cookieが無効
403 Forbidden認証はできたが権限不足ログイン済みだが管理者ロールが必要な画面へアクセス
404 Not FoundURLに対応するエンドポイントがないコントローラー/ページが存在しない、ルーティング設定ミス
405 Method Not AllowedURLはあるが、そのメソッドは未対応GETだけのLoginにPOSTした、またはPOSTだけのLoginにGETした

実務的なコツとして、405レスポンスにはAllowヘッダーが付くことがあります。開発者ツールでレスポンスヘッダーを見てAllow: GETのように出ていれば、「そのURLはGETしか受けない」ことがほぼ確定です。

今回の相談のポイント:OnPostAsyncにPOSTしているのに405が返る理由

相談内容を要約すると、「Microsoftのサンプルで見かけたOnPostAsyncにログインフォームからPOSTしているつもりなのに、毎回405が返る。初回POSTは本当にそこに飛ぶのか?」という悩みです。

結論から言うと、OnPostAsyncはRazor PagesのPageModelに対する“POSTハンドラー”です。アプリがMVC(コントローラー+ビュー)構成なのに、Razor Pages前提のOnPostAsyncをそのまま持ち込むと、MVCのルーティング/アクション選択とは噛み合いません。その結果、たとえば次のような状態になりやすく、405につながります。

  • フォームの送信先が/Account/Loginになっているが、MVC側に[HttpPost] Loginが存在しない(GETだけ)
  • Razor Pagesのファイルは置いたつもりでも、アプリでRazor PagesをMapしていない(または場所が違う)
  • フォームが意図せずGETで送信され、POSTハンドラーに届いていない(method="post"がない、タグヘルパー指定ミスなど)

質問者が最終的に気づいたのは「サンプルはRazor Pages前提、自分のアプリはMVCだった」という前提差で、MVC用に書き換えて解決しています。ここから先は、Razor Pages版とMVC版を分けて、ログインの“正しい入口”を整理します。

Razor Pages版:OnPostAsyncが呼ばれるまでの流れ

Razor Pagesは、ページ(.cshtml)とPageModel(.cshtml.cs)がセットになり、同一URLに対してGET/POSTなどのHTTPメソッドで処理が分岐します。対応関係は基本的に以下です。

HTTPメソッドPageModel側のメソッド名役割
GETOnGet / OnGetAsync画面表示、ReturnUrlの初期化など
POSTOnPost / OnPostAsyncフォーム送信の受け取り、認証、Cookie発行

このため、Razor PagesでOnPostAsyncを入口にするなら、最低限次の条件が揃っている必要があります。

  • ログインページがPages/Account/Login.cshtmlのようにRazor Pagesとして配置されている
  • builder.Services.AddRazorPages()とapp.MapRazorPages()が有効になっている
  • フォームがPOSTで、かつログインページに送信される(<form method="post">)

Razor Pagesの最小Login PageModel例

以下は「Identityなし、Cookie認証だけでログインCookieを発行する」最小構成のイメージです。実運用では認証ロジック、パスワードハッシュ、失敗回数制限などを必ず強化してください。

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

public class LoginModel : PageModel
{
    [BindProperty]
    public string Username { get; set; }

    [BindProperty]
    public string Password { get; set; }

    [BindProperty]
    public string ReturnUrl { get; set; }

    public void OnGet(string returnUrl = null)
    {
        ReturnUrl = string.IsNullOrEmpty(returnUrl) ? "/Account/Private" : returnUrl;
    }

    public async Task<IActionResult> OnPostAsync(string returnUrl = null)
    {
        // 送信時にreturnUrlが来ないケースもあるため、二重にフォールバック
        ReturnUrl = string.IsNullOrEmpty(returnUrl) ? (ReturnUrl ?? "/") : returnUrl;

        if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))
        {
            ModelState.AddModelError(string.Empty, "ユーザー名またはパスワードが不正です。");
            return Page();
        }

        // TODO: 自前の認証ロジック(DB照合など)
        if (!AuthenticateUser(Username, Password))
        {
            ModelState.AddModelError(string.Empty, "ユーザー名またはパスワードが不正です。");
            return Page();
        }

        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.Name, Username),
            new Claim(ClaimTypes.Role, "User")
        };

        var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
        var principal = new ClaimsPrincipal(identity);

        var authProperties = new AuthenticationProperties
        {
            IsPersistent = true,
            ExpiresUtc = DateTimeOffset.UtcNow.AddHours(8)
        };

        await HttpContext.SignInAsync(
            CookieAuthenticationDefaults.AuthenticationScheme,
            principal,
            authProperties);

        return LocalRedirect(ReturnUrl);
    }

    private bool AuthenticateUser(string username, string password)
    {
        // 例: ここでDBのパスワードハッシュと照合する
        return true;
    }
}

Razor Pagesのフォーム例(重要:method=”post”)

Razor Pagesは「同じページにPOSTする」設計が自然です。フォーム側でmethodを指定しないと、ブラウザはGETで送ってしまうため、意図せずOnGetが呼ばれたり、POSTハンドラーがない場合に405になったりします。

<form method="post">
    <div>
        <label>ユーザー名</label>
        <input asp-for="Username" />
    </div>

    <div>
        <label>パスワード</label>
        <input asp-for="Password" type="password" />
    </div>

    <input asp-for="ReturnUrl" type="hidden" />

    <button type="submit">ログイン</button>
</form>

この構成で、未ログイン状態で保護ページ(例:/Account/Private)へアクセスすると、Cookie認証ミドルウェアがログインページへリダイレクトし、ReturnUrlがクエリに付与される、という流れが作れます。

MVC版:OnPostAsyncは使わず、[HttpPost]アクションを入口にする

MVC構成では、ログインの入口はPageModelのOnPostAsyncではなく、コントローラーのアクションです。HTTPメソッドは属性で明示するのが分かりやすく、基本は「GETでログイン画面表示」「POSTで認証してCookie発行」になります。

機能MVCでの実装場所代表的な形
ログイン画面表示コントローラー[HttpGet] Login()
ログイン処理(POST)コントローラー[HttpPost] Login(...)
ログイン画面(HTML)Views/Account/Login.cshtml<form method="post">...

MVCの最小AccountController例

以下はMVCでCookieを発行する最小例です。Razor Pagesと違い、POST用のアクションが存在しないと、フォームがPOSTした瞬間に405になりやすい点が重要です。

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;

public class AccountController : Controller
{
    [HttpGet]
    public IActionResult Login(string returnUrl = null)
    {
        ViewData["ReturnUrl"] = returnUrl;
        return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Login(string username, string password, string returnUrl = null)
    {
        if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
        {
            ModelState.AddModelError(string.Empty, "ユーザー名またはパスワードが不正です。");
            ViewData["ReturnUrl"] = returnUrl;
            return View();
        }

        if (!AuthenticateUser(username, password))
        {
            ModelState.AddModelError(string.Empty, "ユーザー名またはパスワードが不正です。");
            ViewData["ReturnUrl"] = returnUrl;
            return View();
        }

        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.Name, username),
            new Claim(ClaimTypes.Role, "User")
        };

        var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
        var principal = new ClaimsPrincipal(identity);

        await HttpContext.SignInAsync(
            CookieAuthenticationDefaults.AuthenticationScheme,
            principal);

        // ReturnUrlが外部サイトだとオープンリダイレクトになるため、必ずローカル判定
        if (!Url.IsLocalUrl(returnUrl))
        {
            return RedirectToAction("Index", "Home");
        }

        return LocalRedirect(returnUrl);
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Logout()
    {
        await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        return RedirectToAction("Index", "Home");
    }

    private bool AuthenticateUser(string username, string password)
    {
        // 例: DBのパスワードハッシュを照合する
        return true;
    }
}

MVCのログインView例(送信先とmethodを明示)

MVCでは、フォームの送信先がどのアクションかを明確にするために、タグヘルパー(asp-action/asp-controller)を使うと事故が減ります。ReturnUrlはhiddenで引き回し、POST側でUrl.IsLocalUrlの判定とセットで扱うのが安全です。

@{
    var returnUrl = ViewData["ReturnUrl"] as string;
}

<form asp-action="Login" asp-controller="Account" method="post">
    @Html.AntiForgeryToken()

    <div>
        <label>ユーザー名</label>
        <input name="username" />
    </div>

    <div>
        <label>パスワード</label>
        <input name="password" type="password" />
    </div>

    <input type="hidden" name="returnUrl" value="@returnUrl" />

    <button type="submit">ログイン</button>
</form>

もしこのViewがPOSTした先に[HttpPost] Loginが存在しない(GETしかない)場合、まさに今回のように405が返りやすくなります。逆に、POSTしかないのにフォームがGETで送っている場合も同様です。

Cookie認証ミドルウェアの設定例(Program.cs)

Identityを使わないCookie認証でも、認証の土台(Authentication/Authorizationのミドルウェア)は必須です。設定が欠けると、405ではなく「常に未認証扱い」になったり、保護ページが素通りしてしまったりと別のトラブルになります。

using Microsoft.AspNetCore.Authentication.Cookies;

var builder = WebApplication.CreateBuilder(args);

// MVCの場合
builder.Services.AddControllersWithViews();

// Cookie認証
builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.LoginPath = "/Account/Login";
        options.AccessDeniedPath = "/Account/AccessDenied";
        options.ExpireTimeSpan = TimeSpan.FromHours(8);
        options.SlidingExpiration = true;

        // セキュリティ設定の一例
        options.Cookie.HttpOnly = true;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.SameSite = SameSiteMode.Lax;
    });

builder.Services.AddAuthorization();

var app = builder.Build();

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

// 重要:順番も大事(UseRoutingの後、Mapの前)
app.UseAuthentication();
app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

app.Run();

Razor Pagesの場合はAddRazorPages()とMapRazorPages()が必要になります。MVCとRazor Pagesを混在させることもできますが、その場合は「どのURLをどちらが担当するか」を明確にしておくと、405や404の混乱が減ります。

HTTP 405の原因を最短で切り分けるチェックリスト

405の解決は「どのURLに、どのメソッドで飛んでいるか」を確定させることから始まります。慣れると数分で原因に辿り着けます。

ブラウザ(Networkタブ)で最初に見るポイント

  • Request URL:本当に想定のURLに送っているか(例:/Account/Login)
  • Request Method:POSTのつもりがGETになっていないか
  • Status Code:405の前に302が挟まっていないか(リダイレクト先で405になっているケース)
  • Response Headers:Allowヘッダーがあれば、許可メソッドが分かる

症状→原因→対処の早見表

症状ありがちな原因対処
POSTした瞬間に405。AllowがGETサーバー側にPOSTハンドラーがない(MVCで[HttpPost]がない、Razor PagesでOnPostがない)POSTアクション/OnPostAsyncを追加し、フォームの送信先を正しいエンドポイントへ
GETで送ってしまい405。AllowがPOSTmethod="post"がない、またはフォームが別URLにGETしているフォームにmethod="post"を明示し、送信先(asp-page/asp-action)を修正
サンプル通りに作ったのに405サンプルの前提(Razor Pages)と自分のアプリ(MVC)が違うRazor Pagesならページとして実装、MVCならControllerに書き換える
POST先のURLが思っていたものと違うタグヘルパーの指定ミス、ベースパス/リバースプロキシ配下でのパスずれNetworkで実URLを確認し、asp-action/asp-controllerなどを明示

サーバー側ログで「どのエンドポイントにマッチしたか」を見る

ブラウザでURLとメソッドが分かっても、実際にサーバーがどのエンドポイントにマッチさせたかが不明な場合があります。開発環境ではログレベルを上げ、ルーティングのログが見えるようにすると切り分けが速くなります。

  • 「どのController/Actionが選択されたか」
  • 「Razor Pagesのどのページが選択されたか」
  • 「メソッド制約で弾かれたか」

405は“存在するが許可されない”なので、ログ上は「エンドポイントは見つかったがHTTPメソッドが合わない」という形で現れることが多いです。

ReturnUrlの扱いで失敗しないための注意点

ログイン後に元のページへ戻すためにreturnUrlを使うのは王道ですが、扱いを誤ると脆弱性や不自然な挙動につながります。

  • ReturnUrlは必ずローカルURLか検証:外部サイトへ誘導できるとオープンリダイレクトになるため、MVCならUrl.IsLocalUrl、Razor PagesならLocalRedirectを基本にする
  • 二重送信に備える:クエリ文字列で来る場合とhiddenで来る場合がある。POST側でフォールバックを入れる
  • 未ログイン→ログイン→戻るの流れを実機で確認:特にReverse Proxy配下ではReturnUrlが想定外になることがある

Identityなしで自前認証をするなら、ここだけは手を抜かない

Identityを導入しない理由は「既存のユーザー基盤がある」「シンプルにしたい」「UIを自作したい」など様々ですが、Identityが肩代わりしてくれていた安全策も自分で背負うことになります。最低限、次のポイントは設計段階で決めておくと安心です。

パスワードの扱い

  • 平文保存は絶対にしない:必ずストレッチングされたハッシュ(PBKDF2/BCrypt/Argon2など)で保存する
  • 比較は一定時間を意識:文字列比較の早期returnがタイミング攻撃の足がかりになることがあるため、ライブラリの検証関数を使う
  • ログに出さない:例外やデバッグログに入力値を出さない

Cookieの設定

  • HTTPS前提:SecurePolicy.Alwaysを基本にし、開発以外でHTTPに流さない
  • HttpOnly:XSSでCookieが読まれるリスクを下げる
  • SameSite:CSRFを緩和する(ただし要件によってLax/Strict/Noneを選ぶ)
  • 有効期限と更新:ExpireTimeSpanとSlidingExpirationで「いつ切れるか」を仕様として決める

ログイン試行とロックアウト

自前実装で抜けやすいのが「失敗回数制限」です。パスワード総当たり対策として、IPやユーザー単位で一定回数失敗したら時間を空ける、CAPTCHAを挟むなどの仕組みを用意しておくと、運用事故を減らせます。

よくあるハマりどころ(405以外も含む)

ログイン周りは405が解決しても、次の段階で別の“あるある”が出てきます。合わせてチェックしておくと、やり直しが少なくなります。

現象原因の例確認ポイント
ログインできたのに保護ページで未認証扱いUseAuthentication()を呼んでいない、スキーム名が違うProgram.csのミドルウェア順序、CookieAuthenticationDefaults.AuthenticationSchemeの一致
[Authorize]が効かない/効きすぎるAddAuthorization()未設定、ポリシー/既定設定の誤りAuthorizationの登録、コントローラー/ページへの属性付与範囲
ログイン画面に戻され続けるCookieが保存されない(SameSite/ドメイン/HTTPS)、ReturnUrlが外部扱いCookieの属性、ブラウザのCookie一覧、ReturnUrlの検証ロジック
POSTが400になるCSRF対策トークン不足(AntiForgery)MVCなら@Html.AntiForgeryToken()、Razor Pagesならformタグヘルパーの利用

まとめ:まず「Razor PagesかMVCか」を揃えると405は消える

HTTP 405(Method Not Allowed)は、ログイン処理そのものよりも「そのURLはPOSTを受け付ける設計になっているか?」という入口の問題で起きることが多いエラーです。特に、公式サンプルの前提(Razor Pages)と自分のプロジェクト(MVC)がズレていると、OnPostAsyncにPOSTしているつもりでも、実際はMVCのGETアクションにPOSTして405、という形になりがちです。

最初に次の3点を揃えるだけで、405の多くは解消できます。

  • アプリの構造を決める(Razor PagesならOnPostAsync、MVCなら[HttpPost])
  • フォームのmethodと送信先を明示し、Networkタブで実際のリクエストを確認する
  • Cookie発行(SignInAsync)までの最小フローを作ってから、認証ロジックや権限を増やす

Identityを使わないCookie認証は自由度が高い分、前提のズレがそのまま不具合になります。今回のように「まずサンプルをそのまま動かす(Razor Pagesのプロジェクトで確認する)→考え方をMVCへ移植する」という順番を意識すると、迷いが減って実装が安定します。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次