前回の記事で環境構築をしましたが、実装しか必要のない方も多いと思いますので、分けておきました。
データベースの設定と実装
ユーザー管理用のDBの実装です。以下の3つのパッケージをNuGetで取り込んでください。DBのパッケージは使うデータベースに合わせて変えてください。私はPostgreSQLを使っています。3つ目のパッケージは、後述するマイグレーションの実行時に必要なようです。インストールしてないと、マイグレーション時にインストールしろとメッセージが出ます。
- Npgsql.EntityFrameworkCore.PostgreSQL(使うデータベースに合わせて変更します)
- Microsoft.AspNetCore.Identity.EntityFrameworkCore
- Microsoft.EntityFrameworkCore.Design
「appsettings.json」にDBのエントリーを追加します。これも使うDBに合わせてください。
{"ConnectionStrings":{"DefaultConnection":"Server=localhost;Port=5432;Database=<DBNAME>;User ID=<USERID>;Password=<PASSWORD>;Enlist=true"},....基本のフォルダの直下に「Data」フォルダを作成し、「ApplicationDbContext.cs」を作成し、以下の様にします。
usingMicrosoft.AspNetCore.Identity;usingMicrosoft.AspNetCore.Identity.EntityFrameworkCore;usingMicrosoft.EntityFrameworkCore;namespacesvelteCsAsp.Data{publicclassApplicationDbContext:IdentityDbContext<IdentityUser>{publicApplicationDbContext(DbContextOptions<ApplicationDbContext>options):base(options){}}}「Startup.cs」に以下の2つのネームスペース参照を追加します。
usingMicrosoft.EntityFrameworkCore;usingMicrosoft.AspNetCore.Identity;さらに「ConfigureServices」メソッドに以下の行を追加します。
publicvoidConfigureServices(IServiceCollectionservices){services.AddDbContext<ApplicationDbContext>(options=>options.UseNpgsql(// <= この部分は使うDBに合わせてくださいConfiguration.GetConnectionString("DefaultConnection")));services.AddIdentity<IdentityUser,IdentityRole>().AddEntityFrameworkStores<ApplicationDbContext>();...これでデータベースを使ってDefaultIdentityユーザーを利用する準備ができました。
データベースのマイグレーション
作成た状態で、データベースのマイグレーションを実行します。マイグレーションは「dotnet ef」コマンドで実行するのですが、インストールする必要があります(Visual Studioならインストールの必要もないのですが)。コマンドラインで「dotnet tool install --global dotnet-ef」でインストールしてください。
インストールしたら、VSCodeのターミナルで「dotnet ef migrations add initdb」を実行してEntityFrameworkを使えるようにします。(initdbの部分は管理用の名称ですので変更してもいいです)
手動でDBを初期化する場合は、同じく「dotnet ef」コマンドをつかうのですが、私は基本的に自動アップデートにしていますので、今回もそうします。
(※わかっているとは思いますが、データベースは既にインストールして使える状態です。テーブルが何も入っていない状態にしておいてください)
ユーザー初期化のクラスは以下の通りです。(好きなフォルダに作ってください。私は「Models」フォルダに作りました。)
usingMicrosoft.AspNetCore.Identity;usingMicrosoft.Extensions.DependencyInjection;usingSystem;usingSystem.Threading.Tasks;namespacesvelteCsAsp.Models{publicclassUserRollInitialize{// 初期化時のロールpublicstaticreadonlystringSystemManagerRole="SystemManager";// システム管理権限publicstaticreadonlystringGroupManagerRole="GroupManager";// グループ管理権限// 初期化時のシステム管理ユーザーIDpublicstaticreadonlystringSystemUserName="system";// 最初のシステム管理ユーザーのメールアドレスpublicstaticreadonlystringSystemManageEmail="system@test.com";// 最初のシステム管理ユーザーのメールアドレスpublicstaticreadonlystringSystemManagePassword="!initialPassword01";// 最初のシステム管理ユーザーの初期パスワード// 初期化時のグループ管理ユーザーIDpublicstaticreadonlystringGroupUserName="groupuser";// 最初のグループ管理ユーザーのメールアドレスpublicstaticreadonlystringGroupUserEmail="groupuser@test.com";// 最初のグループ管理ユーザーのメールアドレスpublicstaticreadonlystringGroupUserPassword="!initialPassword02";// 最初のグループ管理ユーザーの初期パスワード/// <summary>/// ユーザーとロールの初期化/// 初期のシステムユーザーあが存在しない場合のみ内容が実行される。存在する場合は何もせずに終了/// </summary>/// <param name="serviceProvider"></param>publicstaticasyncTaskInitialize(IServiceProviderserviceProvider){// ユーザー管理を取得(using Microsoft.Extensions.DependencyInjectionがないとエラーになる)varuserManager=serviceProvider.GetService<UserManager<IdentityUser>>();// 初期のユーザーマネージャーが存在しなければロールの作成と初期システムユーザーを作成するvarsystemManager=awaituserManager.FindByNameAsync(SystemUserName);if(systemManager==null){// ロール管理を取得varroleManager=serviceProvider.GetService<RoleManager<IdentityRole>>();// ロールの追加awaitroleManager.CreateAsync(newIdentityRole(SystemManagerRole));// システム管理ロールawaitroleManager.CreateAsync(newIdentityRole(GroupManagerRole));// グループ管理ロール// 初期システム管理者の作成systemManager=newIdentityUser{UserName=SystemUserName,Email=SystemManageEmail};awaituserManager.CreateAsync(systemManager,SystemManagePassword);// システム管理ユーザーにシステム管理ロールを追加systemManager=awaituserManager.FindByNameAsync(SystemUserName);awaituserManager.AddToRoleAsync(systemManager,SystemManagerRole);// グループユーザーの作成vargroupUser=newIdentityUser{UserName=GroupUserName,Email=GroupUserEmail};awaituserManager.CreateAsync(groupUser,GroupUserPassword);// グループユーザーにグループユーザーロールを追加groupUser=awaituserManager.FindByNameAsync(GroupUserName);awaituserManager.AddToRoleAsync(groupUser,GroupManagerRole);}}}}「Program.cs」の「Main」を次のように変更します。
publicstaticvoidMain(string[]args){// CreateHostBuilder(args).Build().Run(); <= もともとこの1行のみvarhost=CreateHostBuilder(args).Build();using(varscope=host.Services.CreateScope()){// サービスプロバイダーの取得varservices=scope.ServiceProvider;// データベースの自動マイグレーションvarcontext=services.GetRequiredService<ApplicationDbContext>();context.Database.Migrate();// 初期のユーザーとロールの作成UserRollInitialize.Initialize(services).Wait();}host.Run();}これでサービスを起動すると、DBに接続して必要なテーブルは勝手に作って初期ユーザーの登録までできてしまいます。
JWTのバックエンド実装
まずJWTを使うために「Microsoft.AspNetCore.Authentication.JwtBearer」パッケージを追加します。
「appsettings.json」にJWT認証のパラメータを追加します。Kyeは下記の通りにするのではなく、ランダムな文字列で長いものを設定します。重要なキーになりますのでしっかり作ってください。
"Jwt":{"Key":"abcdefghijklmnopqrstuvwxyz","Issuer":"https://virtual_office.com"} JWTによる認証はDBと同じように「Startup.cs」を変更します。まず必要な以下の2つのネームスペースをusingで追加してください。
- System.Text;
- Microsoft.IdentityModel.Tokens;
次に「ConfigureServices」に以下の内容を追加してください。
// 認証にJWTベアラトークンを利用services.AddAuthentication().AddJwtBearer(options=>{options.TokenValidationParameters=newTokenValidationParameters(){ValidateIssuer=true,ValidateAudience=true,ValidateLifetime=true,ValidateIssuerSigningKey=true,ValidIssuer=Configuration["Jwt:Issuer"],ValidAudience=Configuration["Jwt:Issuer"],IssuerSigningKey=newSymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Jwt:Key"]))};}); ログイン用のコントローラーを作ります。
「Controllers」フォルダに「AuthController.cs」を作って以下の様にします。
usingSystem;usingSystem.IdentityModel.Tokens.Jwt;usingSystem.Text;usingSystem.Threading.Tasks;usingSystem.Collections.Generic;usingSystem.Security.Claims;usingMicrosoft.AspNetCore.Authorization;usingMicrosoft.AspNetCore.Identity;usingMicrosoft.AspNetCore.Mvc;usingMicrosoft.Extensions.Configuration;usingMicrosoft.IdentityModel.Tokens;usingMicrosoft.AspNetCore.Authentication.JwtBearer;namespacesvelteCsAsp.Controllers{[AuthorizeJwt]// [Authorize]// [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)][ApiController][Route("[controller]/[action]")]publicclassAuthController:ControllerBase{// 設定管理オブジェクトIConfiguration_config;// サインインマネージャー(DefaultIdenityを利用している)SignInManager<IdentityUser>_signInManager=null;UserManager<IdentityUser>_userManage=null;publicclassLoginRequest{publicstringuserId{get;set;}publicstringpassword{get;set;}}// コンストラクタ// サインインマネージャーとコンフィグ管理のオブジェクトをDIpublicAuthController(SignInManager<IdentityUser>signInManager,IConfigurationconfig,UserManager<IdentityUser>userManage){_signInManager=signInManager;_config=config;_userManage=userManage;}// ログイン処理[HttpPost][AllowAnonymous]publicasyncTask<IActionResult>Login(LoginRequestrequest){// ASP.Net core のdDefaultIdentityを利用してIDとパスワードの確認varresult=await_signInManager.PasswordSignInAsync(request.userId,request.password,false,false);if(result.Succeeded==false){returnBadRequest("ユーザー名またはパスワードが違います。");}// ログイン成功でおJWTトークンを返すreturnOk(new{token=awaitBuildToken(request)});}// ログアウト処理[HttpPost]publicIActionResultLogout(){_signInManager.SignOutAsync(); <=これは不要な気がするreturnOk();}// JWTトークンの作成privateasyncTask<string>BuildToken(LoginRequestrequest){varkey=newSymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]));varcreds=newSigningCredentials(key,SecurityAlgorithms.HmacSha256);varuser=await_userManage.FindByNameAsync(request.userId);varprincipal=await_signInManager.CreateUserPrincipalAsync(user);varroles=await_userManage.GetRolesAsync(user);varclaims=newList<Claim>(principal.Claims);claims.Add(newClaim(JwtRegisteredClaimNames.Sub,user.UserName));claims.Add(newClaim(JwtRegisteredClaimNames.Jti,Guid.NewGuid().ToString()));foreach(varroleinroles){claims.Add(newClaim(ClaimTypes.Role,role));}vartoken=newJwtSecurityToken(issuer:_config["Jwt:Issuer"],audience:_config["Jwt:Issuer"],expires:DateTime.Now.AddMinutes(30),signingCredentials:creds,claims:claims);vartmp=newJwtSecurityTokenHandler().WriteToken(token);returntmp;}// テスト用[HttpPost][AuthorizeJwt(Roles="SystemManager")]publicIActionResultIsSystemManager(){returnnewJsonResult(new{status="OK",message="You are SystemManager!"});}[HttpPost][AuthorizeJwt(Roles="GroupManager")]publicIActionResultIsGroupManager(){returnnewJsonResult(new{status="OK",message="You are GroupManager!"});}}}ログイン、ログアウトと、ロールの確認用の2つのメソッドが公開されています。
JWTのフロントエンド実装
フロントエンドはSPAですので、SPAのルーティングの為に「svelte-spa-router」をインストールします。VSCodeのターミナルでClientAppフォルダに移動して「npm install --save-dev svelte-spa-router」を実行します。この辺りについてはSvelteで始める頑張らないフロントエンド生活 後編を参考にしました。あと、Ajaxを使うために「npm install --save-dev rxjs」で「rxjs」をインストールしました。
ログイン画面として「ClientApp/src」フォルダに「Login.svelte」を以下のようにつくりました。
<script lang="ts">
import { push } from 'svelte-spa-router'
import { ajax } from 'rxjs/ajax'
import {sharedData} from './sharedData'
export let userId = "";
export let pass = "";
let errorMessage = "";
function Login(){
ajax({
url: '/Auth/Login',
method: 'POST',
headers : {
'content-type': 'application/json;charset=UTF-8'
},
body: {
userId: userId,
password: pass
}
}).subscribe(
res => {
sharedData.jwtBearerToken = res.response.token;
push(`/Main/${userId}`);
}
);
}
</script>
<main>
<h1>Svelte C# SPA login</h1>
<div class="login-frame">
<table>
<tr>
<th>ID:</th>
<td> <input type="text" id="user_id" bind:value={userId} ></td>
</tr>
<tr>
<th>PASSWORD:</th>
<td><input type="password" id="password" bind:value={pass} ></td>
</tr>
<tr>
<td colspan="2"><button on:click={Login}>ログイン</button></td>
</tr>
{#if errorMessage.length > 0}
<tr>
<td colspan=2>{errorMessage}</td>
</tr>
{/if}
</table>
</div>
</main>
さらに、ログイン後のロールの確認画面として以下のファイルを作りました。システム管理のロールとグループ管理のロールの確認ボタンを配置してます。
<script lang="ts">
import { push } from 'svelte-spa-router'
import { ajax } from "rxjs/ajax"
import { sharedData } from './sharedData'
export let params: {userId:string}
let userId = params.userId
let isSystemManager = "";
let isGroupManager = "";
function checkSystemManager(){
ajax({
url: '/Auth/IsSystemManager',
method: 'POST',
headers: {
'Authorization': 'Bearer ' + sharedData.jwtBearerToken,
'rxjs-custom-header': 'Rxjs'
}
}).subscribe(
res => {
isSystemManager = res.response.status + ' '+ res.response.message;
},
() => {
isSystemManager = "Not authenticated!"
}
);
}
function checkGroupManager(){
ajax({
url: '/Auth/IsGroupManager',
method: 'POST',
headers: {
'Authorization': 'Bearer ' + sharedData.jwtBearerToken,
'rxjs-custom-header': 'Rxjs'
}
}).subscribe(
res => {
isGroupManager = res.response.status + ' '+ res.response.message;
},
() => {
isGroupManager = "Not authenticated!"
}
);
}
function logout(){
ajax({
url: '/Auth/Logout',
method: 'POST',
headers: {
"Authorization": "Bearer " + sharedData.jwtBearerToken
}
}).subscribe(
() => {
sharedData.jwtBearerToken = "";
push('/')
}
);
}
</script>
<main>
<h1>Svelte C# SPA Main</h1>
ようこそ{userId}さん<br />
<button on:click={checkSystemManager}>Is System Manager</button>{isSystemManager}
<button on:click={checkGroupManager}>Is GroupManager</button>{isGroupManager}
<!-- PASSWORD: {password} -->
<button on:click={logout}>ログアウト</button>
</main>
また、ログインのトークン保持用に以下のファイルを作成します。
classSharedData{publicjwtBearerToken="";}exportconstsharedData=newSharedData()最後にspaルーターの動作にするために「App.svelte」を以下の様に変えます。(styleは全部消してます)
<script lang="ts">
import Router from 'svelte-spa-router'
import Login from './Login.svelte';
import Main from './Main.svelte';
const routes = {
'/': Login,
'/Main/:userId': Main,
'*': Login
};
</script>
<Router routes={routes}></Router>
承認用アトリビュートの作成
さて、承認について「AuthController.cs」で気づいたかもしれませんが、承認のアトリビュートが[Authorize]ではなく、[AuthorizeJwt]になっています。これはコントローラーと同じフォルダに以下のファイルを作っています。
usingMicrosoft.AspNetCore.Authorization;usingMicrosoft.AspNetCore.Authentication.JwtBearer;namespacesvelteCsAsp.Controllers{publicclassAuthorizeJwtAttribute:AuthorizeAttribute{publicAuthorizeJwtAttribute():base(){AuthenticationSchemes=JwtBearerDefaults.AuthenticationScheme;}}} これは[AuthorizeJwt(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]と書くのが長ったらしいので、作りました。
よく知っている方なら、それなら「Startup.cs」で「services.AddAuthentication()」の時に、以下の様に書けばとなりますが、なぜかこれが機能しません。どうすればいいか知っている方がおられたら教えてください。(ネットで探していると。 app.UseAuthorizationをapp.UseMVCの前に書けばいけたとの書き込みもありましたが、MVCは使わないので...。あとでも記載していますが、どうもMVCの何かが必要な状態になっている様に思います。)
services.AddAuthentication(options=>{// JWT Bearer をデフォルトにするoptions.DefaultAuthenticateScheme=JwtBearerDefaults.AuthenticationScheme;options.DefaultScheme=JwtBearerDefaults.AuthenticationScheme;options.DefaultChallengeScheme=JwtBearerDefaults.AuthenticationScheme;}).AddJwtBearer(options=>{options.SaveToken=true;options.TokenValidationParameters=newTokenValidationParameters(){ValidateIssuer=true,ValidateAudience=true,ValidateLifetime=true,ValidateIssuerSigningKey=true,ValidIssuer=Configuration["Jwt:Issuer"],ValidAudience=Configuration["Jwt:Issuer"],IssuerSigningKey=newSymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Jwt:Key"]))};});承認とロールの確認
これで全てそろったので、Launch.jsonに作成した「Compound」でデバッグ実行します。
問題が無ければログイン画面が表示されます。データベースの自動マイグレーションで作成しておいた「system」でログインすると、ログイン後の「Is System Manager」は成功し、「Is GroupManager」は失敗します。「groupuser」でログインすればその反対になります。
ほかに検討してみた実装
今回、SPAの認証の実装にはJWTを使っているのですが、そこに行く前の段階で2種類ほど検討しました。
一つ目は、dotnetコマンドでAngularの環境を作り、AngularをSvelteに入れ替えようとしました。AngularのテンプレートではIdentityServerで認証サーバーを立ててoidcで認証しているみたいです。認証のIdentityは同じものを使っているようでしたし、独自の認証サーバーが必要かどうか疑問でした。ソースがAngular用で少し複雑だったのもあってパスしました。他の認証局を利用する場合、例えばGoogleなんかを使う場合のライブラリは別途あるみたいでしたし、それはその時調べて使おうかなと思います。どうもoidcでstateを使ってCSRF対策してるようです。oicdはまだよく知らないので今後の私の課題でしょうか。
次にMVCとかで使っていた方法で、通常の認証を使った上にCSRF対策のアンチリフォージェンシーを利用する方法を使ってみました。トークンの作成と引き渡しできましたが、なぜか[ValidateAntiForgeryToken]がうまく機能しませんでした。オプションでトークンをヘッダーに置く方法もあったのですが、どうやらこの属性はMVC関連のライブラリにいることから、MVCでないとうまく動作しない感じです。まあ、ページの隠し入力エレメントを利用して、開発者が意識しなくても使えるように作っているので当然かもしれませんが。Angularでoidcを使っているのもこの辺りが原因かなと考えています。