- 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>
アバター・ログイン・コンポーネント化
-
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
※ 簡素な方法をご存じの方がいらっしゃいましたら修正していただけるとありがたいです。
ポリシーとロール
引用
- ASP.NET Core での開発におけるアプリ シークレットの安全な保存
- ASP.NET Core Identity を使用せずにソーシャル サインイン プロバイダー認証を使用する
- Google Authentication in Server Side Blazor
- C#でSPAが開発できるBlazorにAuth0で認証機能を付けてみる
- ASP.NET Core Blazor の認証と承認
- ASP.NET Core での Razor ページ認可規則
- ASP.NET Identity のロール管理 (CORE)
- Blazor WebAssembly. Add Authorize attribute at layout level