📝

.NET 6 + React V18 + Typescriptで認証付きWebアプリのひな型を作る②認証機能の組み込み

2022/11/06に公開約21,300字

概要

前回までで、サーバーサイドの情報を受け取ってクライアントサイドで表示することができるようになりました。最低限の動作が確認できるようになったので、認証機能を追加していこうと思います。

コード管理(Github)

認証機能追加の流れは以下です

  • 環境確認・追加
  • 必要なパッケージのインストール

サーバーサイドの構築

環境確認・追加

パッケージを確認して、入ってなければNugetパッケージマネージャーと、SQLiteをインストールします。

詳細な説明(開くと表示)

Extensionsを開きます

↓以下2パッケージが入っている事を確認して、なければインストールしておきます

Nuget GalleryはNugetパッケージをインストールする際に、SQLiteはSQLiteのデータを確認するために使用します。SQLiteはインストールしなくてもアプリの構築自体は可能ですが、データを閲覧したい際などに便利です

必要なパッケージのインストール

以下のパッケージをNuget Galleryからインストールします

  • 認証機能
    • Microsoft.AspNetCore.Authentication.JwtBearer
    • Microsoft.AspNetCore.Identity.EntityFrameworkCore
    • Microsoft.AspNetCore.Identity.UI
  • EntityFramework関連
    • Microsoft.EntityFrameworkCore.Design
    • Microsoft.EntityFrameworkCore.Sqlite
詳細な説明(開くと表示)

F1を押下して、Nugetパッケージマネージャーを開きます

Nuget Galleryからパッケージをインストールします。

インストールできているかの確認方法はいろいろありますが、↓のようにプロジェクトファイルを見てみて上記のパッケージが入っていればOKです。

コード追加・変更

準備ができたら認証関連のコードを追加していきます。
以下のファイルを追加or変更します。

サーバーサイドのコード構築・変更一覧

  • データモデル
    • (A)LoginModel.cs(ログイン処理時にデータを渡すためのモデル)
    • (A)RegisterModel.cs(ユーザー登録時にデータを渡すためのモデル)
    • (A)UserModel.cs(ログイン・ユーザー登録成功後に結果を返す際に使用)
    • (A)ApplicationUser.cs(ユーザー管理テーブル用モデルクラス)
    • (A)ApplicationDbContext.cs(データを操作するためのコンテキストクラス)
  • コントローラ
    • (A)AccountController.cs(ログイン・ユーザー登録処理・結果取得を行う)
    • (C)WeatherForecast.cs(認証時のみ結果を返すように変更)
  • サービス関連
    • (A)JwtService.cs(トークン生成等を行う)
    • (C)Program.cs(認証関連の処理を追加する)
  • 定義ファイル
    • (C)appsettings.json(接続文字列を追加)
    • (C)appsettings.Development(トークン生成用文字列を追加)

サーバーサイドのコード一覧

長いので開いて表示にしました(開くと表示)

フォルダ構成は以下の様にしました

各コードは以下です

LoginModel.cs

LoginModel.cs
namespace serverapp.Models
{
    public class LoginModel
    {
        public string Email {get; set;}
        public string Password {get; set;}
        
    }
}

RegisterModel.cs

RegisterModel.cs
using System.ComponentModel.DataAnnotations;

namespace server_app.Models
{
    public class RegisterModel
    {
        [Required]
        [EmailAddress]
        public string Email { get; set;}
        [Required]
        [RegularExpression("^[a-zA-Z0-9.?/-]{8,24}$", ErrorMessage ="Password must be complex")]
        public string Password {get; set; }
        [Required]
        public string Username { get; set; }

        
    }
}

UserModel.cs

UserModel.cs
namespace serverapp.Models
{
    public class UserModel
    {
        //public string Token { get; set; }
        public string Username {get; set; }
        
    }
}

ApplicationUser.cs

ApplicationUser.cs
using Microsoft.AspNetCore.Identity;

namespace serverapp.Models.EDM
{
    public class ApplicationUser : IdentityUser
    {
    }
}

ApplicationDbContext.cs

ApplicationDbContext.cs
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using serverapp.Models.EDM;

namespace serverapp.Models.Data
{
    public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }
    }
}

AccountController.cs

AccountController.cs

using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using serverapp.Models.EDM;
using server_app.Services;
using serverapp.Models.DTO;

namespace serverapp.Controllers
{
    [AllowAnonymous]
    [ApiController]
    [Route("[controller]")]
    public class AccountController : ControllerBase
    {
        private readonly UserManager<ApplicationUser> _userManager;
        private readonly  SignInManager<ApplicationUser> _signinManager;
        private readonly TokenService _tokenService;
        public AccountController(UserManager<ApplicationUser> userManager
            ,SignInManager<ApplicationUser> signinManager
            ,TokenService tokenService
             )
        {
            _tokenService = tokenService;
            _signinManager = signinManager;
            _userManager = userManager;
        }

        [HttpPost("login")]
        public async Task<ActionResult<UserModel>> Login(LoginModel loginModel)
        {
            
            var user = await _userManager.FindByEmailAsync(loginModel.Email);

            if(user == null) return Unauthorized();

            var result = await _signinManager.CheckPasswordSignInAsync(user, loginModel.Password, false);

            if(result.Succeeded)
            {
                return CreateUserObject(user);
            }

            return Unauthorized();
        }

        [HttpPost("register")]
        public async Task<ActionResult<UserModel>> Register(RegisterModel registerModel)
        {
            if(await _userManager.Users.AnyAsync(x => x.Email == registerModel.Email))
            {
                ModelState.AddModelError("email", "Email taken");
                return ValidationProblem();
            }
            if(await _userManager.Users.AnyAsync(x => x.UserName == registerModel.Username))
            {
                ModelState.AddModelError("username", "Username taken");
                return ValidationProblem();
            }

            var user = new ApplicationUser
            {
                Email = registerModel.Email,
                UserName = registerModel.Username
            };

            var result = await _userManager.CreateAsync(user, registerModel.Password);

            if(result.Succeeded)
            {
                return CreateUserObject(user);
            }

            return BadRequest("Problem regist User");
        }
        private UserModel CreateUserObject(ApplicationUser user)
        {
            return new UserModel
            {
                    Token = _tokenService.CreateToken(user),
                    Username = user.UserName
            };
        }

        [Authorize]
        [HttpGet]
        public async Task<ActionResult<UserModel>> GetCurrentUser()
        {
            var user = await _userManager.FindByEmailAsync(User.FindFirstValue(ClaimTypes.Email));
            return CreateUserObject(user);
        }
    }
}

WeatherForecastController.cs

WeatherForecastController.cs
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Authorization;

namespace serverapp.Controllers;

+[Authorize]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private static readonly string[] Summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

    private readonly ILogger<WeatherForecastController> _logger;

    public WeatherForecastController(ILogger<WeatherForecastController> logger)
    {
        _logger = logger;
    }

    [HttpGet(Name = "GetWeatherForecast")]
    public IEnumerable<WeatherForecast> Get()
    {
        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = DateTime.Now.AddDays(index),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = Summaries[Random.Shared.Next(Summaries.Length)]
        })
        .ToArray();
    }
}

JwtService.cs

JwtService.cs
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using serverapp.Models.EDM;

namespace server_app.Services
{
   public class TokenService
   {
       private readonly IConfiguration _config;
       public TokenService(IConfiguration config)
       {
           _config = config;
       }

       public string CreateToken(ApplicationUser user)
       {
           var claims = new List<Claim>
           {
               new Claim(ClaimTypes.Name, user.UserName),
               new Claim(ClaimTypes.NameIdentifier, user.Id),
               new Claim(ClaimTypes.Email, user.Email),
           };

           var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["TokenKey"]));
           var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha512Signature);

           var tokenDescriptor = new SecurityTokenDescriptor
           {
               Subject = new ClaimsIdentity(claims),
               Expires = DateTime.Now.AddDays(7),
               SigningCredentials = creds
           };

           var tokenHandler = new JwtSecurityTokenHandler();

           var token = tokenHandler.CreateToken(tokenDescriptor);

           return tokenHandler.WriteToken(token);            

       }
   }
}

Program.cs

Program.cs
+using System.Text;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.IdentityModel.Tokens;
+using server_app.Services;
+using serverapp.Models.Data;
+using serverapp.Models.EDM;

var builder = WebApplication.CreateBuilder(args);

string MyAllowSpecificOrigins = "_myAllowSpecificOrigins";

// Add services to the container.

+builder.Services.AddDbContext<ApplicationDbContext>(opt =>
+{
+    opt.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection"));
+} );

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();


+builder.Services.AddDefaultIdentity<ApplicationUser>(
+    options => {
+                options.SignIn.RequireConfirmedAccount = false;
+                }
+    )
+    .AddEntityFrameworkStores<ApplicationDbContext>();



+//for jwt token
+var key = new +SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["TokenKey"]));
+
+builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+    .AddJwtBearer(opt =>
+    {
+        opt.TokenValidationParameters = new TokenValidationParameters
+        {
+            ValidateIssuerSigningKey = true,
+            IssuerSigningKey = key,
+            ValidateIssuer = false,
+            ValidateAudience = false
+        };
+    });
+builder.Services.AddScoped<TokenService>();
+//--------------------------------------------------------------------------------------



builder.Services.AddCors(o => o.AddPolicy(MyAllowSpecificOrigins, builder =>
{
    builder.AllowAnyOrigin()    // Allow CORS Recest from all Origin
            .AllowAnyMethod()    // Allow All Http method
            .AllowAnyHeader();   // Allow All request header
}));


var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseCors(MyAllowSpecificOrigins);   // Add For CORS

+app.UseAuthentication();

+app.UseAuthorization();

app.MapControllers();

app.Run();


appsettings.json

接続文字列を追加します

appsettings.json
{
+  "ConnectionStrings": {
+    "DefaultConnection":"Data Source=database.db"
+  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

appsettings.Development.json

接続文字列を追加します

appsettings.Development.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
+  "TokenKey":"key for jwt token"
}

データベースの更新

コードの追加が出来たので、データベースを生成します。
以下のコマンドを実行します

dotnet ef migrations add InitialCreate
dotnet ef database update
構築結果の確認方法など(開くと表示)

成功すると以下のようなメッセージが表示されます

以下の様にDBが生成されます

サーバーサイドで、「ユーザー登録」、「ID・パスワードを入力するとOK/NG・トークンを返す」、「認証トークンと共にアクセスしてきたユーザーは認証要の操作も許可する」という基本機能の実装が出来ました。次はクライアント側を構築して、認証機能に沿った各操作が出来るようにしていきます。

クライアントサイドの構築

必要なパッケージのインストール

APIと通信する処理を簡単にするために、axiosをインストールします。
(本記事記載時点で最新版は1.xxですが、書き慣れている0.26をインストールしました)

npm install axios@^0.26

クライアントサイドのコード構築・変更一覧

  • API処理
    • (A)api.ts(apiとの通信)
    • (A)Login.tsx(ログイン用コンポーネント)
    • (A)Register.tsx(ユーザー登録用コンポーネント)
    • (C)WeatherForecast.tsx(認証関連の処理を追加する)
    • (C)App.tsx(作った各種コンポーネントを呼び出し)

クライアントサイドのコード一覧

長いので開いて表示にしました(開くと表示)

フォルダ構成は以下の様にしました

各コードは以下です

api.ts

接続文字列を追加します

api.ts
import axios, { AxiosResponse } from "axios";

axios.defaults.baseURL = "https://localhost:5001"; 


axios.interceptors.request.use(config => {
    const token = window.localStorage.getItem('react_dotnet_skelton_jwt_token');
    if(token) config.headers!.Authorization = `Bearer ${token}`
    return config;
})

const WeatherForecast = {
    index: () => axios.get<any>(`/weatherforecast`).then((response: AxiosResponse<any>)=>response.data),
}



const api = {
    WeatherForecast,
}

export default api;

Login.tsx

接続文字列を追加します

Login.tsx

import React, { SyntheticEvent, useState } from 'react';

const Login = (
    ) => 
{
    
    const [name, setName] = useState('');
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [resultcode, setResultcode] = useState(0);
    const [resultTitle, setResultTitle] = useState('');
    const [token, setToken] = useState('');



    const submit = async (e: SyntheticEvent) => {
        e.preventDefault();
        const response = await fetch('https://localhost:5001/account/login',
        {
            method : 'POST',
            headers:{'Content-Type' : 'application/json'},
            body: JSON.stringify({
                email,
                password
            })
        });
        const content = await response.json();
        const status = await response.status

        setResultcode(status);
        setResultTitle(content.title);
        if(status==200){
            setName(content.username);
            setToken(content.token);
            window.localStorage.setItem('react_dotnet_skelton_jwt_token', content.token);


        }

    }

    
    return (
        <>
        <form onSubmit={submit}>
            <h2>Sign in</h2>

            <ul>

                <li>
                    <label>email</label>
                    <input type="email" placeholder="name@example.com" required 
                        onChange = {e => setEmail(e.target.value)}            
                    />
                </li>

                <li>                    
                    <label>password</label>
                    <input type="password" placeholder="Password" required 
                        onChange = {e => setPassword(e.target.value)}            
                    />
                </li>
            </ul>

            <button type="submit">Sign in</button>

        </form>
        
        <h2>Response</h2>

        <ul>
            <li>
                {resultcode!=0 && <>{resultcode==200 ? <>Login Success</> : <>Login Fail</>}</>}
            </li>

            <li>
                {resultcode==200 && <>Name:{name}</>}
            </li>

            <li>
                {resultcode!=0 && <>Code:{resultcode}</>}
            </li>

            <li>
                {resultcode!=0 && <>msg:{resultTitle}</>}
            </li>

            <li>
                {resultcode!=0 && <p>token : {token}</p>}
            </li>
        </ul>
        </>
    );

}

export default Login;

Register.tsx

接続文字列を追加します

Register.tsx

import React, { SyntheticEvent, useState } from 'react';

const Register = () => {
    
    const [username, setName] = useState('');
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [resultcode, setResultcode] = useState(0);
    const [resultTitle, setResultTitle] = useState('');

    const submit = async (e: SyntheticEvent) => {
        e.preventDefault();
        const response = await fetch('https://localhost:5001/account/register',
        {
            method : 'POST',
            headers:{'Content-Type' : 'application/json'},
            body: JSON.stringify({
                email,
                username,
                password
            })
        });
        const content = await response.json();
        const status = await response.status

        setResultcode(status);
        setResultTitle(content.title);
    }

    
    return (
        <>
        <form onSubmit={submit}>
            <h2>Register</h2>

            <ul>
                <li>
                    <label>Name</label>
                    <input placeholder="Name" required
                        onChange = {e => setName(e.target.value)}            
                    />
                </li>

                <li>
                    <label>email</label>
                    <input type="email" placeholder="name@example.com" required 
                        onChange = {e => setEmail(e.target.value)}            
                    />
                </li>

                <li>                    
                    <label>password</label>
                    <input type="password" placeholder="Password" required 
                        onChange = {e => setPassword(e.target.value)}            
                    />
                </li>
            </ul>

            <button type="submit">Register</button>

        </form>
        
        <h2>Response</h2>

        <ul>
            <li>
                {resultcode!=0 && <>{resultcode==200 ? <>Register Success</> : <>Register Fail</>}</>}
            </li>

            <li>
                {resultcode!=0 && <>Code:{resultcode}</>}
            </li>

            <li>
                {resultcode!=0 && <>msg:{resultTitle}</>}
            </li>
        </ul>
        </>
    );

}

export default Register;

WeatherForecast.tsx

接続文字列を追加します

WeatherForecast.tsx

import { useEffect, useState } from 'react';
+import api from './api';

interface Forecast {
  date: Date;
  temperatureC: number;
  temperatureF: number;
  summary: string;
}

export const WeatherForecast = () => {
    
    
    const [loading, setLoading] = useState(true);
    const [forecasts, setForecast] = useState<Forecast[]>();
  
    useEffect(() => {
        populateWeatherData();
    }, []);
  
    const populateWeatherData = async () => {

+        const data = await api.WeatherForecast.index();
        
        setForecast(data);
        setLoading(false);
    };
    
    if(loading) return <div>loading....</div>

    return (
        <div>
            <h1 id="tabelLabel">Weather forecast</h1>
            <p>This component demonstrates fetching data from the server.</p>
            <table className="table table-striped" aria-labelledby="tabelLabel">
                <thead>
                <tr>
                    <th>Date</th>
                    <th>Temp. (C)</th>
                    <th>Temp. (F)</th>
                    <th>Summary</th>
                </tr>
                </thead>
                <tbody>
                {forecasts && forecasts.map((forecast) => (
                    <tr key={forecast.date.toString()}>
                    <td>{forecast.date.toString()}</td>
                    <td>{forecast.temperatureC}</td>
                    <td>{forecast.temperatureF}</td>
                    <td>{forecast.summary}</td>
                    </tr>
                ))}
                </tbody>
            </table>
        </div>
    )
}

App.tsx

接続文字列を追加します

App.tsx

import React from 'react';
+import Login from './Account/Login';
+import Register from './Account/Register';
import { WeatherForecast } from './WeatherForecast';

function App() {
  return (
    <div>
+      <Login />
+      <hr />
+      <Register />
+      <hr />
      <WeatherForecast />
    </div>
  );
}

export default App;

動作させてみる

まずサーバーサイド・クライアントサイド両方を起動します。(この時点でDBは空です)

Webブラウザを開くと、認証前の状態なのでWheatherforcastのデータは表示されません。
Register欄に名前、email、パスワードを入れて登録すると成功のコードが返ってきます(00:57)
この状態でSQ Liteを開くと、ユーザー情報テーブル(AspNetUsers)に先ほど登録したbob@test.comが登録されているのが分かります。

この状態で検証画面を開くと、まず初回起動時にweatherforcastは401(認証)エラーになっていることが分かります(01:30)。

この状態でSign in欄に先ほど登録したemailとパスワードを打ち込むと、認証成功のメッセージとトークンのデータが返ってきます(1:57)

また、空だったローカルストレージにもトークン情報がセットされたことが分かります(2:03)

この状態でブラウザをリロードすると、今度はweatherforcastが表示され、認証が機能していることが分かります。

https://youtu.be/NISlt2gAPZQ

Discussion

ログインするとコメントできます