LoginSignup
0
4

More than 1 year has passed since last update.

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

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

Google Cloud Platform で認証情報を作成する

  • メニューから[+認証情報を作成][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\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 を変更
  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 を変更
    :
    <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] を追加
  @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>

user-gray.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 は不要)
    以上

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

ポリシーとロール

引用

0
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
4