.NET6 Blazor Server で Google OAuth 認証を実装する


- Visual Studio 2022 から 「Blazor Server アプリ」(BlazorGoogleOAuth)を作成する。(認証なし)
- NuGetでインストールする。
  Microsoft.AspNetCore.Authentication.Google
  Microsoft.AspNetCore.Authentication.Cookies

#### [Google Cloud Platform](https://console.cloud.google.com/apis/credentials?project=youtubeuploder-297904) で認証情報を作成する
- メニューから[＋認証情報を作成][OAuthクライアントID] を選択
  [アプリケーションの種類]：ウェブアプリケーション
  [名前]：Blazor-Login
  [承認済みのリダイレクト URI]：https://localhost:xxxx/signin-google
  作成すると[クライアントID][クライアント シークレット]が表示されるので控えておく。

#### シークレットマネージャーにGoogle認証情報を保存
- プロジェクトフォルダでDOS窓を開いてシークレットストレージ作成
```
> dotnet user-secrets init
```
- Visual Studio 2022 でプロジェクトを右クリックして[ユーザーシークレットの管理]を開いてGoogle認証情報をJson形式で記述
```%APPDATA%\Microsoft\UserSecrets\<user_secrets_id>\secrets.json
{
    "Authentication": {
        "Google": {
            "Instance": "https://accounts.google.com/o/oauth2/v2/auth",
            "ClientId": "{クライアントID}",
            "ClientSecret": "{クライアント シークレット}",
            "CallbackPath": "/signin-google"
        }
    }
}
```
※ 上記Jsonを `appsettings.json` に記述しても可能（リポジトリ公開リスクあり）

#### Program.cs にGoogle認証利用のサービス追加
- Program.cs を変更
```diff_c_sharp
  using BlazorApp2.Data;
  using Microsoft.AspNetCore.Components;
  using Microsoft.AspNetCore.Components.Web;

//+ using System.Net.Http;
//+ using Microsoft.Extensions.Configuration;
+ using Microsoft.AspNetCore.Authentication;
+ using Microsoft.AspNetCore.Authentication.Google;
+ using Microsoft.AspNetCore.Authentication.Cookies;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
+ builder.Services
+     .AddAuthentication(options => {
+         options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
+         options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
+     })
+     .AddCookie()
+     .AddGoogle(options => {
+         options.ClientId = builder.Configuration["Authentication:Google:ClientId"];
+         options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"];
+         options.ClaimActions.MapJsonKey("urn:google:profile", "link");
+         options.ClaimActions.MapJsonKey("urn:google:image", "picture");
+     });
+ builder.Services.AddHttpClient();           // 注入@inject HttpClient Http
+ builder.Services.AddHttpContextAccessor();  // 注入@inject IHttpContextAccessor _httpContextAccessor

builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddSingleton<WeatherForecastService>();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

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

+ app.UseAuthentication();
+ app.UseAuthorization();
+ app.UseCookiePolicy();

app.UseRouting();

app.MapBlazorHub();
app.MapFallbackToPage("/_Host");

app.Run();
```

#### Pagesフォルダに Login.csthml, Logout.cshtml を作成
Pagesフォルダ右クリック-[追加][Razorページ]
[Razorページ-空][追加]
[Razorページ-空][Login.cshtml][追加]

```Pages/Login.csthml
@page
@model BlazorGoogleOAuth.Pages.LoginModel
@{
    ViewData["Title"] = "Log in";
}
<h2>Login</h2>
```
```Pages/Login.csthml.cs
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace BlazorGoogleOAuth.Pages
{
    [AllowAnonymous]
    public class LoginModel : PageModel
    {
        public IActionResult OnGetAsync(string returnUrl = null)
        {
            // なぜか returnUrl に戻りURLが入っていないので Referer を取得する
            var url = String.IsNullOrWhiteSpace(returnUrl) ? Request.Headers["Referer"].ToString() : Request.Host.ToString();
            returnUrl = String.IsNullOrWhiteSpace(url) ? "/" : new Uri(url).AbsolutePath;

            string provider = "Google";
            // Request a redirect to the external login provider.
            var authenticationProperties = new AuthenticationProperties
            {
                RedirectUri = Url.Page("./Login", 
                pageHandler: "Callback", 
                values: new { returnUrl }),
            };
            return new ChallengeResult(provider, authenticationProperties);
        }
        public async Task<IActionResult> OnGetCallbackAsync(
            string returnUrl = null, string remoteError = null)
        {
            // Get the information about the user from the external login provider
            var GoogleUser = this.User.Identities.FirstOrDefault();
            if (GoogleUser.IsAuthenticated)
            {
                var authProperties = new AuthenticationProperties
                {
                    IsPersistent = true,
                    RedirectUri = this.Request.Host.Value
                };
                await HttpContext.SignInAsync(
                CookieAuthenticationDefaults.AuthenticationScheme,
                new ClaimsPrincipal(GoogleUser),
                authProperties);
            }
            return LocalRedirect(returnUrl);
        }
    }
}
```
Logoutページ
```Pages/Logout.csthml
@page
@model BlazorGoogleOAuth.Pages.LogoutModel
@{
    ViewData["Title"] = "Logout";
}
<h2>Logout</h2>
```
```Pages/Logout.csthml.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace BlazorGoogleOAuth.Pages
{
    public class LogoutModel : PageModel
    {
        public string ReturnUrl { get; private set; }
        public async Task<IActionResult> OnGetAsync(
            string returnUrl = null)
        {
            returnUrl = returnUrl ?? Url.Content("~/");
            // Clear the existing external cookie
            try
            {
                await HttpContext
                    .SignOutAsync(
                    CookieAuthenticationDefaults.AuthenticationScheme);
            }
            catch (Exception ex)
            {
                string error = ex.Message;
            }
            return LocalRedirect("/");
        }
    }
}
```

#### ログインコントローラーの作成
- Shared/LoginControl.razor を作成する
```Shared/LoginControl.razor.cs
@using System.Security.Claims
@using Microsoft.AspNetCore.Http

@inject IHttpContextAccessor _httpContextAccessor
@inject HttpClient Http

@if (User.Identity.Name != null)
{
    <img src="@Avatar" />
    <b>You are logged in as: @GivenName @Surname</b>
    <a class="ml-md-auto btn btn-primary"
       href="/Logout"
       target="_top">Logout</a>
}
else
{
    <a class="ml-md-auto btn btn-primary"
       href="/Login"
       target="_top">Login</a>
}
@code {
    private ClaimsPrincipal User;
    private string Id;
    private string eMail;
    private string GivenName;
    private string Surname;
    private string Avatar;

    protected override void OnInitialized()
    {
        base.OnInitialized();
        try
        {
            Id = eMail = GivenName = Surname = Avatar = "";
            
            // Set the user to determine if they are logged in
            User = _httpContextAccessor.HttpContext.User;

            // Id 取得
            var id = _httpContextAccessor.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier);
            if (id != null)
            {
                Id = id.Value;
            }

            // eMail 取得
            var email = _httpContextAccessor.HttpContext.User.FindFirst(ClaimTypes.Email);
            if (email != null)
            {
                eMail = email.Value;
            }

            // Try to get the GivenName
            var givenName = _httpContextAccessor.HttpContext.User.FindFirst(ClaimTypes.GivenName);
            if (givenName != null)
            {
                GivenName = givenName.Value;
            }

            // Try to get the Surname
            var surname = _httpContextAccessor.HttpContext.User.FindFirst(ClaimTypes.Surname);
            if (surname != null)
            {
                Surname = surname.Value;
            }

            // Try to get Avatar
            var avatar = _httpContextAccessor.HttpContext.User.FindFirst("urn:google:image");
            if (avatar != null)
            {
                Avatar = avatar.Value;
            }
        }
        catch { }
    }
}
```
- アバターの表示をCSSで指定する
```Shared/LoginControl.razor.css
img {
    border-radius: 50%; /* 角丸半径を50%にする(=円形にする) */
    width: 35px;        /* ※縦横を同値に */
    height: 35px;       /* ※縦横を同値に */
    margin: 0 5px 0 0;
}
```

- Shard/MainLayout.razor を変更
```diff_html
    :
    <main>
        <div class="top-row px-4">
+           <LoginControl />
            <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
        </div>
    :
```

- Pages/FetchData.razor に認証必須ページにする為、`@attribute [Authorize]` を追加
```diff_c_sharp
  @page "/fetchdata"
+ @attribute [Authorize]
  :
  :
```

#### 未ログイン時にログインページにリダイレクト
- Shared/RedirectToLogin.razor にログインページへリダイレクトするコード
```Shared/RedirectToLogin.razor
@inject NavigationManager Navigation
@code {
    protected override void OnInitialized()
    {
        Navigation.NavigateTo("/Login", true);
    }
}
```
- App.razor に未承認ならリダイレクトするように変更
```App.razor.html
<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(Program).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" 
                DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    <RedirectToLogin />
                </NotAuthorized>
                <Authorizing>
                    <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
                    <FocusOnNavigate RouteData="@routeData" Selector="h1" />
                </Authorizing>
            </AuthorizeRouteView>
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <h1>404</h1>
                <p>ページが見つかりませんでした。</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>
```

# すべてのページに認証が必要な場合
```Shared/RedirectToLogin.razor
@inject NavigationManager Navigation
@code {
    [CascadingParameter]
    private Task<AuthenticationState> AuthenticationStateTask { get; set; }

    protected override async Task OnInitializedAsync()
    {
        var authenticationState = await AuthenticationStateTask;

        if (authenticationState?.User?.Identity is null || !authenticationState.User.Identity.IsAuthenticated)
        {
            Navigation.NavigateTo("Identity/Account/Login", true);
        }
    }
}
```
```修正 Shared/MainLayout.razor
@inherits LayoutComponentBase

<PageTitle>IbMan</PageTitle>

<AuthorizeView>
    <Authorized>
        :
        <div class="page"> ・・・ 既存部分
        :
    </Authorized>
    <NotAuthorized>
        <RedirectToLogin />
    </NotAuthorized>
</AuthorizeView>
```

# <img width="35" alt="user-gray.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366837/0c07a40a-1a88-c546-5cac-11c23d4e4c8e.png"> アバター・ログイン・コンポーネント化
- `Shared/AvatarLogin.razor` コンポーネント作成
- NuGet でインストール
  Syncfusion.Blazor.Navigations;


```Shared/AvatarLogin.razor@.cs
@**
    表示した Google アバターでログイン・ログアウトを行うコンポーネント
*@

@using Syncfusion.Blazor.Navigations;
@using System.Security.Claims
@using Microsoft.AspNetCore.Http
@using Syncfusion.Blazor.Popups
@using TooltipPosition = Syncfusion.Blazor.Popups.Position

@inject NavigationManager Navigation
@inject IHttpContextAccessor _httpContextAccessor
@inject HttpClient Http

<div id="avatar-menu-component">
    <div class="avatar-menu-bottom">
        <SfMenu TValue="MenuItem" @ref="MenuObj">
            <MenuEvents ItemSelected="Selected" TValue="MenuItem"></MenuEvents> 
            <MenuItems>
                <MenuItem>
                    <MenuItems>
                        @if (user.isLogin)
                        {
                            @*<MenuItem Text=@user.Id IconCss= "em-icons e-save"></MenuItem>*@
                            <MenuItem Text=@user.Name IconCss= "las la-user"></MenuItem>
                            <MenuItem Text=@user.eMail IconCss= "las la-envelope"></MenuItem>
                            <MenuItem Text="アカウントの管理" IconCss= "las la-user-cog" Url="https://myaccount.google.com/?utm_source=chrome-profile-chooser&pli=1"></MenuItem>
                            <MenuItem Separator="true"></MenuItem>                        
                            <MenuItem Text="ログアウト" IconCss="las la-sign-out-alt" Url="/Logout" ></MenuItem>
                        } else {
                            <MenuItem Text="ログイン" IconCss="las la-sign-in-alt" Url="/Login" ></MenuItem>
                        }
                    </MenuItems>
                </MenuItem>
            </MenuItems>
        </SfMenu>
    </div>
    <div class="avatar-menu-top">
        <img class="avatar-img" src=@user.Avatar />
    </div>
</div>

<style>
/* Line Awesome アイコン CDN 設定 アイコン検索 => https://icons8.com/line-awesome */
/* CSS分離するCSS記述方法を見つけられなかった orz */
.las::before {
    font-size:20px;
}
</style>


@code {
    private class GoogleUser
    {
        public string Id { get; set; }
        public string eMail { get; set; }
        public string GivenName { get; set; }
        public string Surname { get; set; }
        public string avatar;
        public string Avatar => isLogin ? avatar : "images/user-gray.png";
        public string Name => $"{GivenName} {Surname}";
        public bool isLogin => !string.IsNullOrWhiteSpace(Id);
    }

    private ClaimsPrincipal User;
    private GoogleUser user;

    protected override void OnInitialized()
    {
        base.OnInitialized();
        try
        {
            // Set the user to determine if they are logged in
            User = _httpContextAccessor.HttpContext.User;
            
            user = new GoogleUser();
            user.Id          = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
            user.eMail       = User.FindFirst(ClaimTypes.Email)?.Value;
            user.GivenName   = User.FindFirst(ClaimTypes.GivenName)?.Value;
            user.Surname     = User.FindFirst(ClaimTypes.Surname)?.Value;
            user.avatar      = User.FindFirst("urn:google:image")?.Value;
        }
        catch { }
    }


    // Select イベント
    SfMenu <MenuItem> MenuObj;
    private void Selected(MenuEventArgs<MenuItem> args) 
    { 
        var item = args.Item;
        var menuLevel = MenuObj.GetItemIndex(item); // メニュー深さの位置 1=トップレベルのメニュー
        var text = item.Text;
        var id = item.Id;
        var url = item.Url;

        if (!String.IsNullOrWhiteSpace(url))
        {
            Navigation.NavigateTo(url, true);            
        }
    }
}
```
- CSS分離 `Shared/AvatarLogin.razor.css` 作成
```Shared/AvatarLogin.razor.css
#avatar-menu-component .avatar-menu-bottom {
    position: absolute;
}

#avatar-menu-component .avatar-menu-top {
    margin-left: 5px;
    position: relative;
    pointer-events: none;
}

#avatar-menu-component .avatar-menu-top img.avatar-img {
    border-radius: 50%;
    height: 30px;
    width: 100%;
}
```
- 未ログイン時の画像 `wwwroot/images/user-gray.png` を作成
- `<LoginControl />` と `<AvatarLogin />` を置き換える（`Shared/LoginControl.razor` は不要）
以上

<br>

※ アバター画像を `MenuItem` の親にする方法が分からなかったのでCSSで要素を重ねる苦肉の策で対応しました orz
※ 簡素な方法をご存じの方がいらっしゃいましたら修正していただけるとありがたいです。


# ポリシーとロール

- [認証されたユーザーを要求する](https://docs.microsoft.com/ja-jp/aspnet/core/security/authorization/secure-data?view=aspnetcore-6.0#rau)
- [ASP.NET Core の Identity の概要](https://docs.microsoft.com/ja-jp/aspnet/core/security/authentication/identity?view=aspnetcore-6.0&tabs=visual-studio)

#### 引用
- [ASP.NET Core での開発におけるアプリ シークレットの安全な保存](https://docs.microsoft.com/ja-jp/aspnet/core/security/app-secrets?view=aspnetcore-6.0&tabs=windows)
- [ASP.NET Core Identity を使用せずにソーシャル サインイン プロバイダー認証を使用する](https://docs.microsoft.com/ja-jp/aspnet/core/security/authentication/social/social-without-identity?view=aspnetcore-6.0)
- [Google Authentication in Server Side Blazor](https://blazorhelpwebsite.com/ViewBlogPost/19)
- [C#でSPAが開発できるBlazorにAuth0で認証機能を付けてみる](https://dev.classmethod.jp/articles/auth0-wth-blazor/)
- [ASP.NET Core Blazor の認証と承認](https://docs.microsoft.com/ja-jp/aspnet/core/blazor/security/?view=aspnetcore-6.0)
- [ASP.NET Core での Razor ページ認可規則](https://docs.microsoft.com/ja-jp/aspnet/core/security/authorization/razor-pages-authorization?view=aspnetcore-6.0)
- [ASP.NET Identity のロール管理 (CORE)](http://surferonwww.info/BlogEngine/post/2020/06/01/role-management-on-aspnet-core-mvc.aspx)
- [Blazor WebAssembly. Add Authorize attribute at layout level](https://stackoverflow.com/questions/63378376/blazor-webassembly-add-authorize-attribute-at-layout-level)