前回の記事で環境構築をしましたが、実装しか必要のない方も多いと思いますので、分けておきました。
データベースの設定と実装
ユーザー管理用の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」を作成し、以下の様にします。
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace svelteCsAsp.Data
{
public class ApplicationDbContext: IdentityDbContext<IdentityUser>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
}
}
「Startup.cs」に以下の2つのネームスペース参照を追加します。
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
さらに「ConfigureServices」メソッドに以下の行を追加します。
public void ConfigureServices(IServiceCollection services)
{
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」フォルダに作りました。)
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Threading.Tasks;
namespace svelteCsAsp.Models
{
public class UserRollInitialize
{
// 初期化時のロール
public static readonly string SystemManagerRole = "SystemManager"; // システム管理権限
public static readonly string GroupManagerRole = "GroupManager"; // グループ管理権限
// 初期化時のシステム管理ユーザーID
public static readonly string SystemUserName = "system"; // 最初のシステム管理ユーザーのメールアドレス
public static readonly string SystemManageEmail = "system@test.com"; // 最初のシステム管理ユーザーのメールアドレス
public static readonly string SystemManagePassword = "!initialPassword01"; // 最初のシステム管理ユーザーの初期パスワード
// 初期化時のグループ管理ユーザーID
public static readonly string GroupUserName = "groupuser"; // 最初のグループ管理ユーザーのメールアドレス
public static readonly string GroupUserEmail = "groupuser@test.com"; // 最初のグループ管理ユーザーのメールアドレス
public static readonly string GroupUserPassword = "!initialPassword02"; // 最初のグループ管理ユーザーの初期パスワード
/// <summary>
/// ユーザーとロールの初期化
/// 初期のシステムユーザーあが存在しない場合のみ内容が実行される。存在する場合は何もせずに終了
/// </summary>
/// <param name="serviceProvider"></param>
public static async Task Initialize(IServiceProvider serviceProvider)
{
// ユーザー管理を取得(using Microsoft.Extensions.DependencyInjectionがないとエラーになる)
var userManager = serviceProvider.GetService<UserManager<IdentityUser>>();
// 初期のユーザーマネージャーが存在しなければロールの作成と初期システムユーザーを作成する
var systemManager = await userManager.FindByNameAsync(SystemUserName);
if (systemManager == null)
{
// ロール管理を取得
var roleManager = serviceProvider.GetService<RoleManager<IdentityRole>>();
// ロールの追加
await roleManager.CreateAsync(new IdentityRole(SystemManagerRole)); // システム管理ロール
await roleManager.CreateAsync(new IdentityRole(GroupManagerRole)); // グループ管理ロール
// 初期システム管理者の作成
systemManager = new IdentityUser { UserName = SystemUserName, Email = SystemManageEmail };
await userManager.CreateAsync(systemManager, SystemManagePassword);
// システム管理ユーザーにシステム管理ロールを追加
systemManager = await userManager.FindByNameAsync(SystemUserName);
await userManager.AddToRoleAsync(systemManager, SystemManagerRole);
// グループユーザーの作成
var groupUser = new IdentityUser { UserName = GroupUserName, Email = GroupUserEmail };
await userManager.CreateAsync(groupUser, GroupUserPassword);
// グループユーザーにグループユーザーロールを追加
groupUser = await userManager.FindByNameAsync(GroupUserName);
await userManager.AddToRoleAsync(groupUser, GroupManagerRole);
}
}
}
}
「Program.cs」の「Main」を次のように変更します。
public static void Main(string[] args)
{
// CreateHostBuilder(args).Build().Run(); <= もともとこの1行のみ
var host = CreateHostBuilder(args).Build();
using (var scope = host.Services.CreateScope())
{
// サービスプロバイダーの取得
var services = scope.ServiceProvider;
// データベースの自動マイグレーション
var context = 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 = new TokenValidationParameters()
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = Configuration["Jwt:Issuer"],
ValidAudience = Configuration["Jwt:Issuer"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Jwt:Key"]))
};
});
ログイン用のコントローラーを作ります。
「Controllers」フォルダに「AuthController.cs」を作って以下の様にします。
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.Authentication.JwtBearer;
namespace svelteCsAsp.Controllers
{
[AuthorizeJwt]
// [Authorize]
// [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[ApiController]
[Route("[controller]/[action]")]
public class AuthController : ControllerBase
{
// 設定管理オブジェクト
IConfiguration _config;
// サインインマネージャー(DefaultIdenityを利用している)
SignInManager<IdentityUser> _signInManager =null;
UserManager<IdentityUser> _userManage = null;
public class LoginRequest
{
public string userId { get; set; }
public string password { get; set; }
}
// コンストラクタ
// サインインマネージャーとコンフィグ管理のオブジェクトをDI
public AuthController(SignInManager<IdentityUser> signInManager, IConfiguration config, UserManager<IdentityUser> userManage)
{
_signInManager = signInManager;
_config = config;
_userManage = userManage;
}
// ログイン処理
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> Login(LoginRequest request)
{
// ASP.Net core のdDefaultIdentityを利用してIDとパスワードの確認
var result = await _signInManager.PasswordSignInAsync(request.userId, request.password, false, false);
if (result.Succeeded == false)
{
return BadRequest("ユーザー名またはパスワードが違います。");
}
// ログイン成功でおJWTトークンを返す
return Ok(new { token = await BuildToken(request) });
}
// ログアウト処理
[HttpPost]
public IActionResult Logout()
{
_signInManager.SignOutAsync(); <= これは不要な気がする
return Ok();
}
// JWTトークンの作成
private async Task<string> BuildToken(LoginRequest request)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var user = await _userManage.FindByNameAsync(request.userId);
var principal = await _signInManager.CreateUserPrincipalAsync(user);
var roles = await _userManage.GetRolesAsync(user);
var claims = new List<Claim>(principal.Claims);
claims.Add(new Claim(JwtRegisteredClaimNames.Sub, user.UserName));
claims.Add(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()));
foreach(var role in roles)
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Issuer"],
expires: DateTime.Now.AddMinutes(30),
signingCredentials: creds,
claims: claims);
var tmp = new JwtSecurityTokenHandler().WriteToken(token);
return tmp;
}
// テスト用
[HttpPost]
[AuthorizeJwt(Roles = "SystemManager")]
public IActionResult IsSystemManager()
{
return new JsonResult(new {status="OK", message="You are SystemManager!"});
}
[HttpPost]
[AuthorizeJwt(Roles = "GroupManager")]
public IActionResult IsGroupManager()
{
return new JsonResult(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>
また、ログインのトークン保持用に以下のファイルを作成します。
class SharedData{
public jwtBearerToken = "";
}
export const sharedData = new SharedData()
最後に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]になっています。これはコントローラーと同じフォルダに以下のファイルを作っています。
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication.JwtBearer;
namespace svelteCsAsp.Controllers
{
public class AuthorizeJwtAttribute : AuthorizeAttribute
{
public AuthorizeJwtAttribute(): 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 = new TokenValidationParameters()
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = Configuration["Jwt:Issuer"],
ValidAudience = Configuration["Jwt:Issuer"],
IssuerSigningKey = new SymmetricSecurityKey(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を使っているのもこの辺りが原因かなと考えています。