Google認証の実装

こんにちは成田です。初めて記事を書くのでお手柔らかにお願いします。
今回はGoogle認証を実装してみたので記事にしました。

実装にあたっての背景と構想

1:背景

SES特有のコミュニケーション不足の解消のためであったり、技術力の向上を目的に、社員で集まってアプリを一つ作ってみようということになりました。
そのときに認証はどうするかという話になり
   Google認証で!!!(雑)
ということで本記事を書いてみています。(※だいぶ話を盛っています)

実際Google認証にした理由はざっくりこんな感じ

  • 自分たちで認証情報を管理したくない(面倒くさい+セキュリティ的に嫌+ベンリー)
  • OAuth?SSO?(←よくわかってない)で一番目にするので情報がたくさん出ているであろう

この時はこんなこと考えてました。

  • 実際現場で技術選定する人たちは何を根拠にこれを選ぶんだろう…?
  • 初めて触る人からしたらどれくらい楽できるんかなあ…?
2:構想

メンバーありきで何を作るかを考えていたため、フロントバックが自然と分かれて開発を進める流れとなってました。
なので1つのサービスを作るとなった時、認証の流れは下記のようなものを想像してました

この図から、バックエンドで必要なものは

  • APIサーバー
    ・サンプルのAPI(③⑥のところ)

     →今回はトークンがダメだった時のリフレッシュ等は考慮してません
    ・トークン検証処理(④⑤のところ)
  • トークンを設定してリクエストを上記APIサーバーに投げるためのSPA(検証用)
    ・ログイン処理(①②のところ)
  • Google側がAPI、SPAを識別できるように設定が必要なのかな?

これに従って、具体的な実装内容を書いていこうと思います。

実装内容

1:サンプルAPIの作成(③⑥の処理)

APIはコマンドを用いてテンプレを作成後、簡単なものを作成しました。
成功したらメッセージを返すだけの簡単なやつ。(③⑥の処理)

using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class SampleController() : ControllerBase
{
    [HttpGet]
    public IActionResult Get()
    {
        var result = new { Message = "サンプル取得成功" };
        return Ok(result);
    }
}
2:検証用SPA作成

こちらもコマンドを用いてMVCテンプレのアプリを作成しました。
テンプレの状態でデバッガを起動するとページが表示されるので、そのページからGoogle Loginをしてトークンをもらう想定です、
launchSettings.jsonで起動時のURIを指定しておきます。

{
  "profiles": {
    "https": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "applicationUrl": "https://localhost:8080",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}
3:GoogleがSPAを識別できるように設定

GoogleCloudPlatformにログインし、OAuthClientを作成します

作成画面

上記画像の「URIを追加」で設定をします。どちらもSPAのURI(https://localjhost:8080)を設定すれば良さそう。トークンの検証だけであればAPI側の設定は不要っぽかった。トークンリフレッシュとかするのであれば必要になってくるのかも?

作成後、CLIENT_IDとCLIENT_SECRETが作成されます。

4:ログイン処理(①②の処理)

検証用のページを作成
開発環境での検証用のため、脳死でIDとかべた書き(笑)※実際はちゃんと管理してください
ところどころ不要なところは省いているので、使えるところだけ使ってください。

<head>
    <meta name="google-signin-client_id" content="YOUR_CLIENT_ID" /> // クライアントIDを設定
</head>

<body>
    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script src="https://accounts.google.com/gsi/client" async defer></script>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script>
        var token = ''

        // Google認証後の処理
        function handleCredentialResponse(response) {
            token = response.credential // とりあえず変数に入れとこう
        }

        // サンプルAPIを呼び出す
        function callSampleApi() {
            $.ajax({
                url: 'http://localhost:7080/api/sample',
                type: 'GET',
                headers: {
                    "Authorization": `${token}`
                },
                contentType: 'application/json',
                success: function (data) {
                    alert(data.message);
                },
                error: function (xhr) {
                    alert('エラーが発生しました。');
                }
            });
        }

    </script>
    <div class="container">
        <main role="main" class="pb-3">
            <div id="g_id_onload" data-client_id="YOUR_CLIENT_ID(// クライアントIDを設定)" data-callback="handleCredentialResponse" data-use_fedcm_for_prompt="true" class="text-center"></div>
            <div class="g_id_signin" data-type="standard"></div>
            <div><button onclick="callSampleApi()">callSampleApi</button></div>
        </main>
    </div>
</body>
</html>

実際の画面はこんな感じ

ここからログインボタンを押すとログインしに行ってくれます。当たり前か。
handleCredentialResponseで、認証後の情報を使ってなんらかのことができそうですね。

5:トークン検証処理(④⑤の処理)

要約すると、Program.csにカスタム認証スキームを追加、カスタム認証処理のクラスを作成することで実現できました。

Program.cs
// カスタム認証スキームの登録
// リクエストが届くたびにトークンの確認処理を行う
builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddScheme<AuthenticationSchemeOptions, CustomJwtAuthenticationHandler>(JwtBearerDefaults.AuthenticationScheme, options => { });

builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(
        policy =>
        {
            // クロスオリジンになるため、URIを指定して許可
            policy.WithOrigins(Environment.GetEnvironmentVariable("ALLOW_ORIGIN") ?? (builder.Environment.IsDevelopment() ? "http://localhost:8080" : ""))
                .AllowAnyHeader()
                .AllowAnyMethod()
                .AllowCredentials();
        });
});
CustomJwtAuthenticationHandler
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;
using static Google.Apis.Auth.GoogleJsonWebSignature;

public class CustomJwtAuthenticationHandler(
    IOptionsMonitor<AuthenticationSchemeOptions> options,
    ILoggerFactory logger,
    UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        // AllowAnonymous属性がついていれば認証をSkip
        var endpoint = Context.GetEndpoint();
        if (endpoint?.Metadata?.GetMetadata<IAllowAnonymous>() != null) return AuthenticateResult.NoResult(); // 認証をスキップ

        // リクエストのAuthorizationヘッダからトークンを取得
        var token = Request.Headers["Authorization"].ToString();
        if (string.IsNullOrEmpty(token)) return AuthenticateResult.Fail("トークンが設定されていません");

        try
        {
            // トークンを検証
            var tokenInfo = await ValidateAsync(
                token,
                new ValidationSettings() { Audience = [Environment.GetEnvironmentVariable("GOOGLE_AUTH_CLIENT")] }
            );
            if (tokenInfo == null) return AuthenticateResult.Fail("トークンが無効です");

            // ユーザー情報を作成
            var claims = new[]
            {
                new Claim(ClaimTypes.NameIdentifier, tokenInfo.Subject ?? ""),
                new Claim(ClaimTypes.Email, tokenInfo.Email ?? "")
            };
            var identity = new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme); // ここはよくわかってない。ご注意。
            var principal = new ClaimsPrincipal(identity);
            var ticket = new AuthenticationTicket(principal, JwtBearerDefaults.AuthenticationScheme);

            Logger.LogInformation("トークン検証完了");
            return AuthenticateResult.Success(ticket);
        }
        catch (Exception ex)
        {
            Logger.LogError($"トークン検証エラー: {ex.Message}");
            return AuthenticateResult.Fail($"トークン検証エラー: {ex.Message}");
        }
    }
}
launchSettings.json
{
  "profiles": {
    "https": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "launchUrl": "swagger",
      "applicationUrl": http://localhost:7080",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development",
        "GOOGLE_AUTH_CLIENT": "~~~",
        "GOOGLE_AUTH_SECRET": "~~~",
        "ALLOW_ORIGIN": "https://localhost:8080"
      }
    }
  }
}

苦労した点・気になったこと

ライブラリが提供されているのかいないのか

この辺りを触ったことがなかったのでなかなかわからなかったんですが、Cookie認証を使ったものが用意されていてそれ以外はなさそう???
イメージとしては、NugetPackageでインストールしてProgram.csにちょちょんと追加したら成功〜〜〜イェイ!ピースピース!みたいになると思ってたんですが、トークン検証用のライブラリがあるくらいでした。(自分を信じられないので誰か教えて…)
なので、最終的にカスタマイズした認証ロジックを組む必要があったとの結論に至りました。

Cookie認証って信用していいのか?

って思って特に調べもせずなんとか使わない方向での実装を考えていた。そうすると、ログイン時のトークンどうやって管理するんだ? という話になって、localStrage?やっぱりcookie?みたいになって沼。結局用意されていたライブラリ使うのが正解だったのかな。

アプリのつくりがアカウント単位でどうこうしたいって時

結局アカウント情報は管理しないといけない?作りたいアプリ内でGoogleアカウント情報との紐付けが必要になってきそうだなと思いました。メリットは利用者が使いやすいっていうことくらいになるのかな?

トークンが盗まれた時にどうするか

まず盗まれにくくしようというのはあるが、、、。ログイン処理後のトークンは保存せずに基本的にはサーバーに直送りしてどうこうするのが良さそう?
結局それを管理するのであればリスキーな気がしてきた…..。人の情報を扱うのにリスクを負わない方がおかしいかと妙に納得。結局どうするのがいいんだろう。

もし盗まれたとして、盗んだ人がその情報を使ってアクセスしてきた時にどうやって判断する?
 ブラウザ情報とかIPとか? 沼。

実際は

実際はフロントエンドの人たちが開発環境をすぐに整えられるようにAPIサーバーはDockerを使ってコンテナ化していました。なので開発を進める時はSPAをローカルでたてて、APIサーバーをコンテナでローカルに立ち上げて、、、みたいなことをしていました。そのあたりもうちょっと上手くやれなかったかなぁ

最後に

認証まわりってすごく大事なのに、フレームワークで自動でやりますよ〜!とか既に組み込まれていて機能拡張とかで現場に入るような経験しかなくなかなか触れる機会が今までなくて、考えるのすごく難しいなと思いました。実際に取り入れようと思った時に、色々考えることはありそう。

長いこと読んでもらって感謝感謝。またね。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

投稿者プロフィール

NaritaMasahito
最新の投稿

関連記事

  1. ASP.NET Core Web APIを使ってみた

最近の記事

  1. AWS
  2. AWS
  3. AWS

制作実績一覧

  1. Checkeys