ねののお庭。

かりかりもふもふ。

【C#】ASP.NET CoreでIdentityやEFを使わずに、googleなどの外部プロバイダによるOAuthを利用する方法。

なんでこんなの書いてるかというと、公式が余計なもの色々くっつけた上でのドキュメントしか書いてないから...。

個人で運用してるwebアプリで、わざわざアカウントとパスワードなんか作らせたくないわけです。 というか私がユーザだったらその時点で十中八九離脱する...。 で今の時代OAuthとか活用して外部プロバイダでログインとかさせるわけじゃないですか。

当然ASP.NET Coreもそこらへんについては公式にドキュメントがあります。以下のような。

docs.microsoft.com

なんですが、こいつはASP.NET Core IdentityやらEntityFrameworkのものをガシガシ利用する前提で書かれています。

docs.microsoft.com

Identityはデブいし、EntityFramework前提だったりして、まぁ使いたくない。

というわけでIdentityやEFを使わずにOAuth使うのどうすんねん、となったので備忘録書いておくことにしました。 ついでにAspNetCore.Authenticationの基本的な事も。

なお、この投稿の中では基本的に.NET 6を使ってます。

ASP.NET Coreにおける認証。

ASP.NET Coreで認証をするにはAspNetCore.Authenticationなるものを利用します。 AspNetCore.AuthenticationはMVCやWeb APIとかとは分離しており、独立してます。なので最低限以下のような形で使えます。

全体はGitHubに。 github.com

なるべく明示的に書いてるので若干冗長感が漂っているのはしょうがない。 一度"/"にアクセスしてJWTトークンもらったら、それを認証ヘッダに乗せてhttp叩くと認証が通ります。

認証がらみでやってる事はといえば、

  • コンテナに認証ハンドラを認証スキーム(string)とペアで追加
    • .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme~~
  • リクエストが飛んできた際にはcontext.AuthenticateAsync(scheme)で認証結果をもらう
    • schemeに対応する認証ハンドラが使われる。

という感じで非常にシンプル。 なお、services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)とか書くことでデフォルトのスキームを設定することができ、デフォルトが設定されていればcontext.AuthenticateAsync()のような形で認証スキームを省略して書けます。

googleを外部プロバイダとしてOAuthを利用する。

準備

googleが提供するOAuthを利用するためには、まずgoogle console等でOAuthの設定をしなければなりません。 そこらへんは以下のドキュメントをみてClient IDとClient Secretを発行しておきましょう。

docs.microsoft.com

プロジェクトは.NET6かつmvcテンプレートでひな形作ったと仮定して話を進めます。 さらにgoogleのOAuthパッケージも追加して、先ほど作成したOAuthのClient Idやらも登録しておきます。

dotnet new mvc -o OAuthSample
cd OAuthSample

dotnet add package Microsoft.AspNetCore.Authentication.Google

dotnet user-secrets set "Authentication:Google:ClientId" "<client-id>"
dotnet user-secrets set "Authentication:Google:ClientSecret" "<client-secret>"

完成形はGitHubに置いておきました。(もちろんユーザシークレットは含まれてないので自分で設定してネ)

github.com

というわけでコードをみていきましょう。

サービスの設定

まずサービスをコンテナに登録していきます。

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllersWithViews();

var configuration = builder.Configuration;

builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
    })
    .AddCookie()
    .AddGoogle(options =>
    {
        var googleAuthSection = configuration.GetSection("Authentication:Google");

        options.ClientId = googleAuthSection["ClientId"];
        options.ClientSecret = googleAuthSection["ClientSecret"];

        // default of {options.CallbackPath} is "/signin-google"
    });

上から見ていきます。

AddAuthentication()

とりあえず注目するべきはここ。

options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;

認証の種類というのはいろいろあります。

  • Authenticate
    • ユーザが送ってきた(暗号化されてたりする)情報をもとにユーザが何者であるか特定する。
  • Challenge
    • 認証されてないユーザに認証しなさいって促す事。
    • ログイン画面に飛ばしたり、googleとかの外部の認証に飛ばしたり。
  • SignIn

などなど。で、それぞれに対してデフォルトでどのスキームを使うか設定するために、DefaultAuthenticateSchemeDefaultChallengeSchemeDefaultSignInSchemeなどが存在しています。 いろいろありますが、各種デフォルトが設定されていない場合はDefaultSchemeにフォールバックされます。 MVCであれば、基本的にはCookieをデフォルトで設定しておけばOKでしょう。 ただ今回はユーザに認証をさせる(チャレンジ)のはgoogle oauthを使いたいので、DefaultChallengeSchemeだけGoogleDefaults.AuthenticationSchemeに設定してます。

AddCookie() / AddGoogle()

Googleで認証するならAddCookie()いらないのでは?と思ったりするかもしれませんが、OAuthの認証結果を永続化(SignIn)しないといけないので、AddCookie()が必要になります。

AddCookie()で設定される認証ハンドラはCookieAuthenticationHandlerですが、これはJwtBearerHandlerOAuthHandlerなど他の認証ハンドラとはちょっと違います。 継承関係をみると分かりますが、CookieAuthenticationHandlerのみがSignInAuthenticationHandlerを継承している事が分かります。 SignInAuthenticationHandlerIAuthenticationSignInHandlerIAuthenticationSignOutHandlerを実装しています。

AddCookie()を呼ばなかった場合を考えてみましょう。 まずユーザがパスワードや2段階認証をポチポチ入力した後、google consoleで設定したコールバック(/signin-google)が呼び出されます。 コールバックが呼び出されると、フレームワークが認証結果をDefaultSignInSchemeもしくはDefaultSchemeに設定された認証スキームに対応する認証ハンドラを使って認証結果を永続化しようとします。 もしoptions.DefaultScheme = GoogleDefaults.AuthenticationSchemeとかで設定してもGoogleHandlerはSignInを実行することはできず(なぜならIAuthenticationSignInHandlerなどが実装されてないため)、エラーを吐いて死ぬ、という感じになります。

分かりづらいのは、DefaultChallengeSchemeDefaultSchemeで設定した認証ハンドラがシームレスに連携されるところでしょうか。 チャレンジして認証情報が渡ってきた後に明示的にSignInする必要はなく、フレームワーク側が勝手にやってくれます。

http pipeline

UseAuthenticationを追加します。これだけ。

一番初めにcontext.AuthenticateAsync()で認証情報が取れるという話をしましたが、実際のところ毎回書くのはめんどくさいです。 なので認証ミドルウェアを設定します。 認証ミドルウェアは裏側で認証ハンドラを呼んでHttpContext.User認証情報を突っ込んでくれます

var app = builder.Build();

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

app.UseRouting();

app.UseAuthentication(); // 追加
app.UseAuthorization();

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

app.Run();

Challenge / Sign in / Sign out

ログインボタンが押されたり、ログアウトボタンが押された時の処理が必要です。

認証されてない場合はthis.Challenge()でユーザに認証を促します。 コード上では省略してますが、チャレンジする際スキームを明示的に設定する事もできます。

認証済みで、ログアウトする場合はクッキーから情報を削除します。 google oauthで認証しても、結局SignInはクッキーの認証スキームを使っているので、SignOutする場合もクッキーの認証スキームを指定する事になります。 もちろん、デフォルトでCookieAuthenticationDefaults.AuthenticationSchemeが設定されているので省略できます。

public class AccountController : Controller
{
    private readonly ILogger<AccountController> _logger;

    public AccountController(ILogger<AccountController> logger)
    {
        _logger = logger;
    }

    public IActionResult Login()
    {
        var prop = new AuthenticationProperties
        {
            RedirectUri = Url.Action(nameof(LoginCallback))
        };

        return this.Challenge(prop);
    }

    public async Task<IActionResult> LoginCallback()
    {
        return this.RedirectToAction("Index", "Home");
    }

    public async Task<IActionResult> Logout()
    {
        // スキームは省略可。デフォルトがCookieAuthenticationDefaults.AuthenticationSchemeで設定されているため。
        await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

        return this.RedirectToAction(nameof(HomeController.Index), "Home");
    }
}

Authorize

controllerやactionに対して[Authorize(scheme)]のようにスキームを指定したattributeでアノテーションすることで、指定したスキームで認証されてないと弾かれるようになります。 認証ハンドラとか意識する必要がない。 テンプレのHomeControllerにあるPrivacyを認証してないと通さないようにしてみましょう。

public class HomeController : Controller
{
    private readonly ILogger<HomeController> _logger;

    public HomeController(ILogger<HomeController> logger)
    {
        _logger = logger;
    }

    public IActionResult Index()
    {
        return View();
    }

    // [Authorize(CookieAuthenticationDefaults.AuthenticationScheme)]
    [Authorize]
    public IActionResult Privacy()
    {
        return View();
    }

    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
    public IActionResult Error()
    {
        return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
    }
}

上では単に[Authorize]とアノテーションしており、明示的に認証スキームを設定していませんが、 [Authorize(CookieAuthenticationDefaults.AuthenticationScheme)]みたいな感じでスキームを明示することもできます。 明示しなければデフォルトに設定された認証スキームが利用されます。 まぁMVCならデフォルトにCookieを、Web APIの場合はJWTをデフォルトで設定しておけばだいたいOKな気がするので、明示する機会は少ないような気がします。

全体の流れ

ブラウザとappサーバと認証サーバのhttpのやりとりは以下の画像のようになってます。 もちろん、実際にはクエリパラメータが山ほどついてますし、認可サーバとリソースサーバのエンドポイントは違ったりしますが。

f:id:nenoNaninu:20211120021947p:plain
httpピタゴラスイッチ。

まとめ。

要するにサービスにAddAuthentication()でどの認証にどの認証スキームを使うかデフォルトで設定しつつ、AddGoogle()等で認証スキームと認証ハンドラをセットで追加して、httpパイプラインに認証ミドルウェアを設定すればとりあえずOK。あとは AuthorizeAttributeをアノテーションしていけばよいのです。HttpContext.AuthenticateAsync()とか使う事はまぁほとんどないでしょう。

ChallengeSchemeの結果がSignInSchemeにシームレスに繋がっているのが初見だとだいぶ分かりづらいと感じますが、それさえ理解すればあとは割とすんなりな気がしますね。

github.com github.com

References