7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C#Advent Calendar 2024

Day 23

【詳説】C#でREST APIサーバを作り、Azureへデプロイする実装例

Last updated at Posted at 2024-12-22

はじめに

この記事では、C#を用いたREST APIサーバ構築方法を具体的な手順で解説します。間違いがあれば指摘をお願いいたします。

また、ASP.NET Coreを利用しますが、ASP.NET Coreの解説というわけではないので、独自実装が多いです。

作成するAPIサーバの機能

下記機能を持つAPIサーバを作ります。これらの機能はほとんどのサーバでも必要だと思うので、このサーバを拡張・修正して利用してください。

ユーザ登録機能

ユーザを登録し、サインインできるようにします。ユーザが入力する情報は、ユーザ名、サインインID、パスワードとします。
ユーザが登録されると、自動的に登録日時が保存されるものとします。

ルール

ユーザ名: 1文字以上50文字以内
サインインId: 10文字以上50文字以内。他のユーザIDとはユニークでなければならない
パスワード: 10文字以上

サインイン機能

サインインID、パスワードを利用したサインイン機能を実装します。サインイン完了後はJWTを発行し、クライアントへ返します。

トークン認証機能

サインイン時に返却したJWTを利用した認証を行い、認証されていない利用者が利用できないAPIを実装します。

トークンリフレッシュ機能

記事が思ったよりも長くなってしまったので概要だけ示します。

対象読者・必要なソフトウェア

対象読者

  • Gitのリポジトリ作成やプル/プッシュ操作に慣れている
  • Dockerを触ったことがある人
  • C#でREST APIサーバを作りたい人

この記事でわかること

  • C#を利用したREST APIサーバ実装方法
  • Dockerを利用した.NET開発環境の構築方法
    • コンテナ内でデバッグする方法
    • dotnet watchコマンドを用いてソースコードの変更を即時反映する方法
  • パスワード管理などセキュリティの基礎知識
  • xUnitを利用したテストの実装方法
    • モックの利用方法・注意点
    • パラメータ化されたテストの実装
  • GitHub Actionsを利用したCIの構築方法
    • プルリクエスト時、mainブランチへのプッシュ時に自動テストを実行します
  • Azure上でサーバを動作させる方法
  • GitHub Actionsを利用し、Azure Container Appsへ継続的デプロイする方法
    • productionブランチへプッシュされた際に自動デプロイします

設計思想についても説明しますが、この辺は主観が目立つため、参考程度にとどめてください。

エディタ

筆者はWindows環境でVisual Studio CodeまたはVisual Studio 2022を利用しています。
本記事では他OSでも開発できるようにVisual Studio Codeを利用して説明します。

必要なもの

  • .NET SDK
  • Webブラウザ
  • Docker
  • Git

本記事では.NET 8を利用しますが、恐らく.NET 9でも動作します。.NETはメジャーバージョンが上がっても破壊的変更は優しいです。

各サービスやソフトウェアのつながり

環境図

開発環境や使うもののつながりを図にまとめました。Visual Studio Codeを軸とした開発環境です。

  • REST API サーバやデータベースサーバのローカル実行環境はDocker上に構築します
  • ソースコード等ファイルはホストOS上に用意し、Dockerのボリュームを利用して同期します
  • CI/CDの構築はGitHub Actionsを利用します
  • 本番環境はAzureを利用します

サンプルプログラム

サーバのサンプルプログラムをGitHubに置いているので、必要に応じてご参照ください。この記事では説明を一部省略している箇所があります。
サンプル

データベースの環境構築

空のローカルリポジトリを作成した段階から開始します。

SQL Serverの開発環境を準備

Visual Studio Codeに拡張機能をインストールする

2つの拡張機能をインストールします。
data_workspace.png
dbproject.png

拡張機能の情報
名前: Data Workspace
ID: ms-mssql.data-workspace-vscode
説明: Additional common functionality for database projects
バージョン: 0.5.0
パブリッシャー: Microsoft

名前: SQL Database Projects
ID: ms-mssql.sql-database-projects-vscode
説明: Enables users to develop and publish database schemas for MSSQL Databases
バージョン: 1.4.3
パブリッシャー: Microsoft

Data Workspaceのリンク
SQL Database Projectsのリンク

プロジェクトファイルを作成する

  1. リポジトリルートにDBディレクトリを作ります
  2. 画面左側のDatabase Projectsをクリックします
    create_dbp_0.png
  3. Create newをクリックし、Azure SQL Databaseを選択します
    create_dbp_1.png
  4. プロジェクト名を入力します
  5. プロジェクトファイルの場所を指定します。リポジトリルート>DBディレクトリを指定してください
  6. SDK スタイルのプロジェクトファイルを使うかを聞かれるので、yesを選択します

作成すると、この画面になります(プロジェクト名をbasic_dbとしました)。
create_dbp_result.png
この画面でbasic_dbを右クリックし、テーブル定義のファイルなどを追加・記述していく開発フローとなります。
また、エクスプローラーの画面を見ると、このような結果となっています。
create_dbp_result2.png

Docker上でSQL Serverを動かす

Azure SQL DatabaseはSQL Serverとの互換性が高いので、ローカル環境ではSQL ServerをDocker上に構築します。
SQL ServerのDockerイメージはmcr.microsoft.com/mssql/server:2022-latestのようにMicrosoft公式のイメージがあるため、これを利用します。
dockerhubへのリンク

Dockerfileを書く

直接上記イメージを利用してもいいですが、今回はコンテナの初回起動時のみ.sqlprojをビルドし、SQL Serverへパブリッシュする仕組みを構築します。
Dockerfileをプロジェクト名ディレクトリに作成します(dockerfileDockerFile等ファイル名の誤字に注意)。
create_db_dockerfile.png

Dockerfile
FROM mcr.microsoft.com/mssql/server:2022-latest

# ROOT権限にする
USER root

# dotnetをインストールし、sqlprojをビルド可能にする
RUN apt-get update && apt-get install -y dotnet-sdk-8.0
RUN dotnet tool install -g microsoft.sqlpackage

# sqlpackageへのパスを通す
ENV PATH="${PATH}:/root/.dotnet/tools"

# 実行権限を付与
RUN chmod +x /root/.dotnet/tools/sqlpackage

# init.shをコピー
COPY init.sh .

# 初期化スクリプト
ENTRYPOINT ["/bin/bash", "init.sh"]

マルチステージビルド等を活用すればイメージのサイズを抑えられそうですが、このイメージは開発環境でしか使わないので、この構成で進めます。

解説

まず、.NET 8.0をインストールし、sqlpackageツールをインストールします。
最後にパスや権限を通し、後述する初期化スクリプトから利用できるようにします。

初期化スクリプトを記述する

続いて、初期化用のシェルスクリプトを記述します。
init.shDockerfileと同じ階層に作成します。
create_db_init.png

大まかな処理の流れを示します。

  1. 環境変数が登録されているかの確認を行います
  2. SQL Serverを起動します
  3. SQL Serverの起動を制限時間付きで待ちます
  4. 起動完了後、.sqlprojをビルド・パブリッシュします
init.sh
#!/bin/bash

# エラーが発生したら即座に終了する
set -e

# 環境変数のチェック
required_vars=("MSSQL_SA_PASSWORD" "PROJECT_PATH" "DACPAC_PATH")
for var in "${required_vars[@]}"; do
    if [ -z "${!var}" ]; then
        echo "エラー: ${var} が設定されていません。"
        exit 1
    fi
done

wait_mssql() {
    echo "DBSを起動します(最低10秒待機します)。"
    sleep 10

    timelimit=30
    for i in $(seq 1 "${timelimit}"); do
        if version=$(/opt/mssql-tools18/bin/sqlcmd -S localhost,1433 -U sa -P "${MSSQL_SA_PASSWORD}" -Q "SELECT @@VERSION" -C 2>/dev/null); then
            echo "起動完了しました。バージョン情報: ${version}"
            return 0
        else
            echo "起動待機中... (${i}/${timelimit})"
            sleep 1
        fi
    done

    echo "タイムアウト: DBSの起動に失敗しました。"
    return 1
}

execute_ddl() {
    echo "sqlプロジェクトをビルドします。"
    if ! dotnet build "${PROJECT_PATH}"; then
        echo "ビルドに失敗しました。"
        return 1
    fi

    echo "プロジェクトビルド内容をmssqlにパブリッシュします。"
    if ! sqlpackage /Action:Publish \
        /SourceFile:"${DACPAC_PATH}" \
        /TargetServerName:"localhost,1433" \
        /TargetUser:"sa" \
        /TargetPassword:"${MSSQL_SA_PASSWORD}" \
        /TargetDatabaseName:"${DB_NAME}" \
        /TargetTrustServerCertificate:"True"; then
        echo "パブリッシュに失敗しました。"
        return 1
    fi

    echo "DDLの実行が完了しました。"
    return 0
}

main() {
    if ! wait_mssql; then
        exit 1
    fi

    initialized="/init"
    if [ ! -e "${initialized}" ]; then
        if execute_ddl; then
            touch "${initialized}"
            echo "初期化が完了しました。"
        else
            echo "初期化に失敗しました。"
            exit 1
        fi
    else
        echo "既に初期化済みです。"
    fi
}

main &
exec /opt/mssql/bin/sqlservr --accept-eula

解説

main関数をバックグラウンドで実行しながら、SQL Serverを起動しているのがポイントです。
DockerのコンテナはPIDが1のプロセスが終了すると終了してしまうので、main関数はバックグラウンドで動かす必要があります。

SQL Server実行の注意点
main &
exec /opt/mssql/bin/sqlservr --accept-eula

また、初回起動時の判定方法は、初回起動時に空ファイルを生成し、そのファイルが存在するかをチェックする方法にしています。

初回起動か判定する
initialized="/init"
if [ ! -e "${initialized}" ]; then
    if execute_ddl; then
        touch "${initialized}"
        echo "初期化が完了しました。"
    else
        echo "初期化に失敗しました。"
        exit 1
    fi
else
    echo "既に初期化済みです。"
fi

環境変数については後述するdocker-compose.ymlに記述します。

docker-compose.ymlを記述する

リポジトリルートにdocker-compose.ymlを作成します。
create_db_dc.png

docker-compose.yml
services:
  db:
    build:
      context: ./DB
    # 自動で起動する
    restart: always
    ports:
      - "1433:1433"
    volumes:
      - ./DB/{SQLプロジェクト名}:/src
    environment:
      - MSSQL_SA_PASSWORD={管理者アカウントのパスワード}
      - DB_NAME={DBの名前}
      - PROJECT_PATH=/src/{SQLプロジェクト名}.sqlproj
      - DACPAC_PATH=/src/bin/debug/{SQLプロジェクト名}.dacpac

docker-composeはどの場所のDockerfileをビルドするかや、環境変数の設定など細かい設定をしているだけです。
volumesではデータベースプロジェクトソースを即反映できるように指定しています。Dockefileでコピーするという方法もありますが、
この方法だとソースを変更するたびにイメージをビルドする必要が出てくるため、コンテナ内のディレクトリにマウントする方法を取りました。
init.shも頻繁に変更する予定があればボリュームを使う方法の方が良いです。

ここまで出来たら、リポジトリルートでdocker compose up -d dbのようにコンテナを起動できるようになります。

なぜDockerfileを自分で書くのか運用を踏まえて解説

コンテナ起動する度に手動で.sqlprojをビルド・パブリッシュするのは面倒だからです。
この方法なら、コンテナ起動だけでデータベースを利用可能です。
実際の運用方法をケースごとに説明します。

DBを起動したい

cd <docker-compose.ymlがあるディレクトリ>
docker compose up -d db

DBを止めたい

cd <docker-compose.ymlがあるディレクトリ>
docker compose stop db

DBをリセットしたい

コンテナを削除してから再起動することでDBのリセットができます。

cd <docker-compose.ymlがあるディレクトリ>
docker compose rm -sfv db
docker compose up -d db

この3操作だけでローカル上のDBを管理できるので、シンプルな運用ができると思います。

テーブル設計

開発環境が整ったので、ユーザ情報を管理するためのテーブルを設計します。ER図を示します。
db_er.png

ユーザ情報とログイン等認証に関する情報を分けて設計しています。このように分ける理由は、認証方法は頻繁に変わるからです。
今回はパスワードとログインIDによる認証ですが、2要素認証や、ソーシャルログイン、IdPを利用した認証など様々な方法があります。これらへの対応を容易に行えるよう切り分けています。
ユーザIDはプログラム側から生成したいため、uniqueidentifier型にしました。C#からはGuid構造体として利用できます。
もしuser_authenticationsのIDもプログラム側から生成したい場合は、同様にuniqueidentifier型を利用できます。

ここでの命名ルール

  • テーブル名は複数形
  • 主キーはidで統一
  • 外部キーは外部テーブル名_id

この辺は個人の好みなので自由に決めていいです。

T-SQLを記述する

スキーマを定義

SQL ServerはMySQL等と異なりスキーマを持つため、スキーマの定義から行います。
まず、Database Projectsタブに移動し、プロジェクト名を右クリック->Add Item...をクリックします。
create_schema.png

Scriptをクリックし、ファイル名の入力を求められたらschemataと入力します(自由に決めてOK)。
ファイルが作成されたら、そこにスキーマを定義します(スキーマ名は自由に決めてOK)。

schemata.sql
CREATE SCHEMA [app];
GO

この流れでT-SQLを記述します。

テーブルを定義

まず、以下のようなファイル構成としました。
tables.png

テーブルごとにファイルを作り、tablesディレクトリ配下に保存する構成です。

次に、ER図の通りにCREATE TABLEします。

users

users.sql
CREATE TABLE [app].[users]
(
    [id] uniqueidentifier PRIMARY KEY NOT NULL,
    [name] nvarchar(50) NOT NULL,
    [registered_at] datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);

user_authentications

user_authentications.sql
CREATE TABLE [app].[user_authentications]
(
    [id] int PRIMARY KEY IDENTITY(1,1) NOT NULL,
    [user_id] uniqueidentifier NOT NULL,
    [sign_in_id] nvarchar(100) UNIQUE NOT NULL,
    [password] nvarchar(128) NOT NULL,
    [refresh_token] nvarchar(128) DEFAULT NULL,
    [refresh_token_expiration] datetime2 DEFAULT NULL,
    CONSTRAINT [fk_user_authentications_users] FOREIGN KEY([user_id]) 
        REFERENCES [app].[users]([id]) ON DELETE CASCADE
);

データベースを初期化・確認

データベースを初期化

テーブルの定義ができたので、実行します。先述した通り、docker compose up -d dbすることで自動実行されます。
もし既に実行している場合はdocker compose rm -sfv dbをしてから実行してください。
成功すると、次のようなログが流れます(docker compose logs -f dbでログを確認できます)。
create_table_log.png

DBに接続して確認する

SQL Serverタブに移動し、接続の追加をクリックします。クリックするとSQL Serverへ接続するための画面が出てくるので、画像の通りに入力します。
create_connection.png

接続に成功すると、テーブル等を見ることができるようになるので、正しく作成できているかを確認します。
created_connection.png

以上でデータベースの準備が完了しました。

REST APIサーバ環境構築・開発

データベースの準備が整ったので、サーバの開発を開始します。

環境構築

プロジェクトの準備

ソリューション・プロジェクト作成

リポジトリルートへ移動し、次のスクリプトを実行します。

ソリューション、プロジェクト作成スクリプト
# サーバ開発用ディレクトリを作成
mkdir ./API
cd ./API
# ソリューションを作成
dotnet new sln -n API
# サーバプロジェクト用ディレクトリを作成
mkdir ./Server
# プロジェクトを作成し、ソリューションにプロジェクトを追加
dotnet new webapi -f "net8.0" -lang "C#" -o "./Server"
dotnet sln "API.sln" add "./Server/Server.csproj"
cd ..

動作内容はコメントアウトの通りです。実行すると、次の画像の通りとなります。
create_api_proj.png

プロジェクトにライブラリをインストールする

Microsoft.Data.SqlClientDapperMicrosoft.AspNetCore.Authentication.JwtBearerをインストールします。

ライブラリをインストール
dotnet add ./API/Server/Server.csproj package Microsoft.Data.SqlClient --version 5.2.2
dotnet add ./API/Server/Server.csproj package Dapper --version 2.1.35
dotnet add ./API/Server/Server.csproj package Microsoft.AspNetCore.Authentication.JwtBearer --version 8.0.6

不要なファイル・実装を削除

次に、ソリューションエクスプローラーでソリューションを開きます。
open_sln.png

WeatherForecastController.csServer.httpWeatherForecast.csは削除します。
また、Properties/launchSettings.json内を書き換えます。

launchSettings.json
{
  "$schema": "http://json.schemastore.org/launchsettings.json",
  "profiles": {
    "http": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "launchUrl": "swagger",
      "applicationUrl": "http://0.0.0.0:8080",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
  }
}

ホストマシン上からアクセスするためにapplicationUrlのIPアドレスを0.0.0.0にしています。

さらに、appsettings.jsonから"AllowedHosts": "*"の記述を削除します。

appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  }
}

ビルドが通るか確認

リポジトリルートでdotnet build ./API/API.slnを実行すると、コンパイルに成功します。プロジェクトの準備はここで一区切りつけます。

Dockerfileを準備

API用のDockerfileはデプロイ時も利用するため、マルチステージビルドでイメージサイズを削減します。
APIディレクトリ(ソリューションファイルと同じ階層)にDockerfileを作成し、次の通りに記述します。
.NET SDKのイメージ
ASP.NET Core Runtimeのイメージ

Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
WORKDIR /api

# すべてコピーする
COPY . ./

# リストアする
RUN dotnet restore
# パブリッシュする
RUN dotnet publish -c Release -o out

# ランタイムイメージを利用し、実行環境を準備する。
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /api
COPY --from=build-env /api/out .
# http通信を行い、ポートを設定
ENV ASPNETCORE_URLS="http://+:8080;"
CMD ["dotnet", "Server.dll"]

解説

dotnetのビルド、実行環境の設定という2層構造のビルドとなっています。最終ステージではasp.netのランタイムイメージを利用するため、イメージサイズを削減できます。

サーバ起動用スクリプトを記述

APIディレクトリ(ソリューションファイルと同じ階層)にapi_run_dev.shを作成し、次の通りに記述します。

api_run_dev.sh
#!/bin/bash

# デバッガを使用する場合はサーバを起動しない。
if [ "${USE_DEBUGGER}" = true ]; then
    echo リモートエクスプローラからコンテナに接続し、デバッグしてください。
    # bashに入って終了しないようにする
    bash
else
    dotnet watch run --project /api_src/Server/Server.csproj --launch-profile http
fi

docker-compose.ymlに追記する

dbを作成した際に作成したdocker-compose.ymlへ追記します。

docker-compose.yml
  api:
    build:
      context: ./API
      # dotnet watchはdotnet/sdk:8.0に含まれるので、ビルド環境ステージのイメージを利用する
      target: build-env
    tty: true
    volumes:
      - ./API:/api_src/
      # objは名前付きボリュームにする。こうすることでコンテナ内でdotnet restoreする際にこっちに影響を与えなくなる。
      - ignores:/api_src/Server/obj/
      # devcontainer用に、VSCodeの拡張機能を保持するようにする。
      # https://zenn.dev/greendrop/articles/8bf88aad068f7d#docker-volume%E3%81%A7%E6%B0%B8%E7%B6%9A%E5%8C%96%E3%81%97%E3%80%81%E6%8B%A1%E5%BC%B5%E6%A9%9F%E8%83%BD%E3%82%92%E4%BF%9D%E6%8C%81
      - VSCode-extentions-api:/root/.vscode-server
    ports:
      - "8080:8080"
    depends_on:
      - "db"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - DOTNET_WATCH_RESTART_ON_RUDE_EDIT=true
      - USE_DEBUGGER=true
    command: bash /api_src/api_run_dev.sh
volumes:
  ignores:
  VSCode-extentions-api:

いくつかポイントがあるので解説します。

buildでtargetを指定する理由

dotnet watchを利用したいからです。dotnet watchを利用するには.NET SDKが必要なので、build-envを利用します。

ttyの設定

api_run_dev.shでデバッガを利用する場合にbashを実行していますが、ttyをtrueに設定することでコンテナの終了を防ぐことができます。

ボリューム

ソースコードの変更がコンテナ内へ即時反映させるためにapi_srcソースをディレクトリにマウントしています。
しかしこれだけでは問題があり、コンテナ内でビルドすると、objディレクトリ内が書き換わってしまいます。それを防ぐために名前付きボリュームへ隔離します。

objディレクトリが書き換わるとファイル内のパスがDockerコンテナ内のパスに置き換わるため、インストールした外部ライブラリの入力補完が効かなくなります。

環境変数

実行環境を開発環境とし、ホットリロードできない変更がある場合は自動で再起動するようにします。

Docker上でサーバを起動できるか確認する

リポジトリルートでdocker compose up -d apiを実行すると、コンテナが起動します。
ログをdocker compose logs -f apiで表示し、起動に成功している場合は次のようなログとなります。

basicrestapiserver-api-1  | dotnet watch ⌚ Polling file watcher is enabled
basicrestapiserver-api-1  | dotnet watch 🔥 Hot reload enabled. For a list of supported edits, see https://aka.ms/dotnet/hot-reload.
basicrestapiserver-api-1  |   💡 Press "Ctrl + R" to restart.
basicrestapiserver-api-1  | dotnet watch 🔧 Building...
basicrestapiserver-api-1  |   Determining projects to restore...
basicrestapiserver-api-1  |   All projects are up-to-date for restore.
basicrestapiserver-api-1  |   Server -> /api_src/Server/bin/Debug/net8.0/Server.dll
basicrestapiserver-api-1  | dotnet watch 🚀 Started
basicrestapiserver-api-1  | info: Microsoft.Hosting.Lifetime[14]
basicrestapiserver-api-1  |       Now listening on: http://[::]:8080
basicrestapiserver-api-1  | info: Microsoft.Hosting.Lifetime[0]
basicrestapiserver-api-1  |       Application started. Press Ctrl+C to shut down.
basicrestapiserver-api-1  | info: Microsoft.Hosting.Lifetime[0]
basicrestapiserver-api-1  |       Hosting environment: Development
basicrestapiserver-api-1  | info: Microsoft.Hosting.Lifetime[0]
basicrestapiserver-api-1  |       Content root path: /api_src/Server
basicrestapiserver-api-1  | warn: Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionMiddleware[3]
basicrestapiserver-api-1  |       Failed to determine the https port for redirect.

正しく起動できていればhttp://localhost:8080/swagger/index.htmlへアクセスするとswaggerの画面が表示されます。
swagger.png

APIの動作確認もここで行うので、お気に入りやブックマークですぐにアクセスできるようにすると便利です。

Visual Studio Codeからデバッグする方法

普段の開発では、ホットリロードだけで十分開発できますが、デバッガを利用したいときもあるのでその準備をします。まだデバッグ環境については模索中なので、もっと便利な方法があれば教えてほしいです。

Dev Containers拡張機能をインストールする

コンテナ内で作業をできるようにします。
image.png

拡張機能の情報
名前: Dev Containers
ID: ms-vscode-remote.remote-containers
バージョン: 0.388.0
パブリッシャー: Microsoft

拡張機能のリンク

リモートエクスプローラーからコンテナへアタッチする

  1. docker-compose.ymlの環境変数USE_DEBUGGERtrueに変更し、コンテナを再起動します

  2. リモートエクスプローラーからapiのコンテナを新しいウインドウでアタッチします
    image.png

  3. api_srcディレクトリを開きます
    image.png

コンテナ内に拡張機能をインストールする

cs_dev_kit.png

拡張機能の情報
名前: C# Dev Kit
ID: ms-dotnettools.csdevkit
説明: Official C# extension from Microsoft
バージョン: 1.13.9
パブリッシャー: Microsoft

拡張機能のリンク

コンテナ外でも利用したい場合はコンテナ外でもインストールしてください。

デバッグ用の設定

まず、コンテナにアタッチしたVisual Studio Codeで、api_srcディレクトリ配下に.vscode/launch.jsonを作成します。
image.png

launch.json
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "サーバをデバッグ",
            "type": "dotnet",
            "request": "launch",
            "projectPath": "${workspaceFolder}/Server/Server.csproj",
        }
    ]
}

次に、ホットリロードを有効にします。試験的機能なので、デフォルトでは無効になっています。
/root/.vscode-server/data/Machine/settings.jsonを開き、次の設定を追加します。

settings.json
{
    "csharp.experimental.debug.hotReload": true
}

サーバを起動

実行とデバッグタブに移動し、再生ボタンをクリックするだけで起動します。
image.png
image.png

これでブレークポイントを設定し、デバッグできるようになります。
以上で開発環境の構築が完了しました。

セキュリティについて

SQLの文字列でフォーマット文字列を使う際は注意

INSERT文などでパラメータを与える際はプレースホルダを利用します。直接文字列を埋め込む場合はSQLインジェクション攻撃の注意が必要です。

SQLインジェクションの発生例
var userInput = "1;SELECT * FROM 認証情報"
var query = $"SELECT id FROM 認証情報 WHERE id = {userInput}"

この例ではSELECT * FROM 認証情報も実行されてしまいます。

パスワードはハッシュ化してからDBへ

パスワード流出時の被害を軽減するための対策法です。攻撃者は入手したパスワードを逆ハッシュ化する必要が生じるため、生のパスワードが流出するまでの時間を稼ぐ事ができます。ここでは、ハッシュ化する際にできる工夫について説明します。

複数回ハッシュ化する(ストレッチング)

パスワードのハッシュ化にかかる計算コストを大きくすることで、総当たり攻撃を困難にする目的があります。したがって、ストレッチング回数はコンピュータの性能が向上するほど多くした方が良いことになります。また、予め計算してあるハッシュ値とパスワードの対応表を元にパスワードを推測するレインボーテーブル攻撃の対策にもなります。

今回作るサーバでは約30万回ハッシュ化していますが、ASP.NETでの規定では10万回ハッシュ化しているようです。

https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.aspnetcore.identity.passwordhasheroptions.iterationcount?view=aspnetcore-8.0

パスワードに文字列を付加してからハッシュ化する

パスワードに文字列を付加してからハッシュ化することでレインボーテーブル攻撃の対策を強化できます。
付加する文字列は推測が困難なものにします。この文字列のことをソルトといいます。今回の実装では2種類のソルトを利用します。

ユーザ固有の情報を利用したソルト

ユーザごとに生成する文字列を利用したソルトです。今回はユーザ登録日時をソルトとして利用しますが、ユーザ登録時にソルト用のランダムな文字列を生成し、データベースへ保存する方法もあります。

データベース外に定義したソルト

データベースにソルトを保存すると、データベースが流出した場合ソルトも流出してしまいます。そこで、環境変数などデータベース外に別のソルトを用意し、これも付加して使うようにします。このようなソルトをペッパーまたはシークレットソルトと言います。パスワードが流出する場合データベース内にあるソルトも流出している可能性が高いため、この対策はした方が良いと考えています。

フローの例

refresh_token_flow.drawio.png

ハッシュ関数の選択

ハッシュ関数にも種類があり、パスワードハッシュ化に特化したハッシュ関数があります。

  • PBKDF2(Password-Based Key Derivation Function 2)
  • bcrypt
  • Argon2

実装例ではPBKDF2を利用したパスワードハッシュ化方法を紹介します。

パスワードの情報はクライアントに渡さない

パスワード認証では、サインイン時にユーザが入力したパスワードをハッシュ化し、データベース内のパスワードと比較することで実現できます。パスワードをクライアントへ渡す必要はありません。

アクセストークンの有効期限を短く設定する

JWTはデータベースに保存する必要なくユーザを識別、認証できるメリットがあります。しかし、サーバ側で無効化できないデメリットもあります。このデメリットを軽減するため有効期限を短くします。

有効期限を短くするとログインする頻度が多く不便になってしまう

トークンが切れる度にログインを求めると利便性の低下につながります。この問題を解決する方法には、リフレッシュトークンの発行があります。リフレッシュトークンとはアクセストークンを再発行するためのトークンです。リフレッシュトークンはアクセストークンより長い有効期限を持つ事がポイントです。アクセストークンより送信頻度が少ないため、アクセストークンの有効期限を長くする場合より、リスクが小さいです。

refresh.drawio.png
上図の方法ではデータベースを利用したリフレッシュトークン管理が必要になりますが、リフレッシュトークンの検証頻度はアクセストークンの検証頻度より少ないので、アクセストークンをデータベース管理するよりデータベースへアクセスする頻度は少なく済みます。

この記事で実装するAPIサーバは上図の流れでリフレッシュトークンを実装できるようにテーブル設計をしています。今回の記事では実装方法を示しませんが、図の流れ通りに実装すれば実現できます。

設計

MVCパターンとオニオンアーキテクチャを拡張した設計にしています。依存関係の図を示します。
server_design.png

Server(名前空間のルート)

ここはサーバのエントリポイント、環境変数ヘアクセスする処理などサーバ全体の設定を扱う役割を持ちます。特にサーバのエントリポイントではサービスの登録などサーバの機能をセットアップする役割を持ちます。

Models

ユーザ、サインインID、パスワードなどのモデルやビジネスロジックを実装します。今回はサインインIDの文字数など、ルールが存在します。これらのルールもモデル内に実装します。

UseCases

ユースケース毎にメソッドを実装し、各ユースケースのメインルーチンの役割を持ちます。リポジトリや認証処理はインターフェイスを介して利用します。

Databases

ここではモデルの永続化を行う処理を実装します。これらの処理はRepositoryと命名しています。ユースケースでトランザクションを張ることが多いですが、ユースケースのテストをしたい都合でトランザクションもここで張ります。

Databases.DBClients

クエリを実行し、実際にデータベースへ問い合わせする役割を持ちます。リポジトリからのみ呼び出されます。
リポジトリ関連 (1).png

メリット・デメリット

複雑な集計処理がないことを前提としている設計なので、メリット・デメリットがあります。

メリット

  • 単純なクエリで実装できる
  • 再利用が容易
  • テーブルを参照している箇所の特定が容易

デメリット

  • 問い合わせ回数が増加する
  • トランザクションを張る回数が増え、デッドロックなどのリスクが高まる
  • Repositoryがモデル詰め替え場所のような実装になる

実装例では偶然テーブル単位になっていますが、集約単位で実装します。

Controllers

コントローラを実装します。ハンドラメソッドはユースケース単位で実装します。
処理内容は、

  1. リクエストパラメータをユースケース呼び出し用に詰め替える
  2. ユースケースを呼び出す
  3. 結果を返す

程度のものとなるため、薄いレイヤとなります。「外部との窓口を作る役割」だけを持たせます。

Authentications

パスワードのハッシュ化やアクセストークンを生成する処理を実装します。認証に関わるセキュリティ処理をする役割で取り扱い注意のため、独立した名前空間を与えています。

実装例

ソースコードのフォルダ構成は名前空間と同じ構成にしています。詳細はサンプルプログラムを参照してください。

モデルを実装

必要なモデルを実装します。

UserName

using System.Diagnostics.CodeAnalysis;

namespace Server.Models.Users;
public record UserName
{
    private UserName(string value)
    {
        Value = value;
    }

    public static readonly UserName Empty = new("");

    public string Value { get; }

    /// <summary>
    /// ユーザ名の作成を試みる
    /// </summary>
    /// <param name="value">ユーザ名の文字列</param>
    /// <param name="userName">作成したユーザ名</param>
    /// <returns>成功: true 失敗: false</returns>
    public static bool TryCreate(string value, [NotNullWhen(true)] out UserName? userName)
    {
        // ルール:空値を許可しない かつ 50文字以内
        if (!string.IsNullOrWhiteSpace(value) && value.Length <= 50)
        {
            userName = new(value);
            return true;
        }

        userName = null;
        return false;
    }
}

値オブジェクトの実装にしました。C#で開発する場合はrecordを使うと自動でプロパティによる等価比較を行うため非常に便利です。
また、インスタンスの生成時はTryCreateメソッドを呼び出す必要があるように実装しています。
このように実装することで、ユーザ名のルール「1文字以上50文字以内」を満たすオブジェクトのみ存在するようになります。

NotNullWhen属性とは

メソッドの戻り値によってnullではないことを保証するための属性です。今回の場合は、userNamenullableですが、trueが戻る場合はnullではないことが保証されるようになります。

User

namespace Server.Models.Users;

public readonly record struct UserId(Guid Value)
{
    public static readonly UserId Empty = new(Guid.Empty);

    public static UserId CreateNew() => new(Guid.NewGuid());
}

public class User
{
    private User(UserId id, UserName name, DateTime registeredAt)
    {
        Id = id;
        Name = name;
        RegisteredAt = registeredAt;
    }

    public static readonly User Unknown = new(UserId.Empty, UserName.Empty, DateTime.MinValue);

    public UserId Id { get; }

    public UserName Name { get; }

    public DateTime RegisteredAt { get; }

    /// <summary>
    /// ユーザを作成する
    /// </summary>
    /// <param name="id">ユーザID</param>
    /// <param name="userName">ユーザ名</param>
    /// <param name="registeredAt">登録日時</param>
    /// <returns>作成したユーザ</returns>
    public static User Create(UserId id, UserName userName, DateTime registeredAt)
    {
        return new(id, userName, registeredAt);
    }

    public override bool Equals(object? obj)
    {
        if (obj is User user)
        {
            return user.Id == Id;
        }

        return false;
    }

    public override int GetHashCode()
    {
        return Id.GetHashCode();
    }
}

ユーザはエンティティなので、IDで等価比較するように実装します。

Guidの重複について

重複しない前提で実装しています。重複のチェックをしてもいいのですが、コストに対して得られるメリットが少ないです。
理由

  • DB側で主キー制約をかけているため、重複してもデータの不整合は起こらない
  • Guidが重複する確率は限りなく低い(.NET 8、9ではバージョン4 UUIDを生成している)
    2^128通りのIDが存在するうえに、暗号学的に安全な疑似乱数生成器を使用している

.NET 8 Guid 構造体(learn.microsoft.com)
.NET 9 Guid 構造体(learn.microsoft.com)

.NET 8 Guid.NewGuid メソッド(learn.microsoft.com)
.NET 9 Guid.NewGuid メソッド(learn.microsoft.com)

SignInId

using System.Diagnostics.CodeAnalysis;

namespace Server.Models.UserAuthentications;
public record SignInId
{
    private SignInId(string value)
    {
        Value = value;
    }

    public string Value { get; }

    /// <summary>
    /// サインインIDの作成を試みる。
    /// </summary>
    /// <param name="value">サインインIDの文字列</param>
    /// <param name="signInId">作成したサインインID</param>
    /// <returns>成功: true 失敗: false</returns>
    public static bool TryCreate(string value, [NotNullWhen(true)] out SignInId? signInId)
    {
        // ルール:サインインIDは10文字以上100文字以下とする。
        if (10 <= value.Length && value.Length <= 100)
        {
            signInId = new(value);
            return true;
        }

        signInId = null;
        return false;
    }
}

サインインIDもユーザ名と同様の実装です。

RawPassword

using System.Diagnostics.CodeAnalysis;

namespace Server.Models.UserAuthentications;
public record RawPassword
{
    private RawPassword(string value)
    {
        Value = value;
    }

    public string Value { get; }

    /// <summary>
    /// 生パスワードの作成を試みる
    /// </summary>
    /// <param name="value">生パスワードの文字列</param>
    /// <param name="rawPassword">作成した生パスワード</param>
    /// <returns>成功: true 失敗: false</returns>
    public static bool TryCreate(string value, [NotNullWhen(true)] out RawPassword? rawPassword)
    {
        // ルール:パスワードは10文字以上とする。
        if (10 <= value.Length)
        {
            rawPassword = new(value);
            return true;
        }

        rawPassword = null;
        return false;
    }
}

ハッシュ化前のパスワードをRawPasswordと命名し、実装しました。実装内容はUserNameSignInIdと同様です。

IHasher

using Server.Models.Users;

namespace Server.Models.UserAuthentications;

public interface IHasher
{
    /// <summary>
    /// ハッシュ化されたパスワードを生成する。
    /// </summary>
    /// <param name="user">パスワードの所有者</param>
    /// <param name="rawPassword">パスワード</param>
    /// <returns>ハッシュ化されたパスワード</returns>
    public string Generate(User user, RawPassword rawPassword);
}

パスワードをハッシュ化するインターフェイスを定義します。Models名前空間でハッシュ化処理を利用する目的でここに置いています。

HashedPassword

using Server.Models.Users;

namespace Server.Models.UserAuthentications;

public record HashedPassword
{
    private HashedPassword(string value)
    {
        Value = value;
    }

    public string Value { get; }

    /// <summary>
    /// ハッシュ化したパスワードの生成をする。
    /// </summary>
    /// <param name="hasher">ハッシュ化用オブジェクト</param>
    /// <param name="rawPassword">生パスワード</param>
    /// <param name="user">パスワードの所有者</param>
    /// <returns>ハッシュ化したパスワード</returns>
    public static HashedPassword HashFromRawPassword(IHasher hasher, RawPassword rawPassword, User user)
    {
        // 生パスワードをハッシュ化する。
        return new(hasher.Generate(user, rawPassword));
    }
}

ハッシュ化したパスワードをHashedPasswordと命名し、実装しました。
HashedPasswordHashFromRawPasswordメソッド経由で生成しますが、ハッシュ化処理と生パスワード、パスワードの所有者を受け取ります。

StoredPassword

このオブジェクトもハッシュ化済みパスワードを表現しますが、HashedPasswordレコードと区別します。データベースなどから取得した文字列でオブジェクトを生成する必要があるからです。HashedPasswordレコードに文字列から生成するメソッドを実装すると、不正なオブジェクトの生成を許すことになります。ほかのモデル以上にパスワードにはリスクがあるため、このように厳密な設計としています。

using Server.Models.Users;

namespace Server.Models.UserAuthentications;
public record StoredPassword
{
    private StoredPassword(string value)
    {
        Value = value;
    }

    public string Value { get; }

    public static readonly StoredPassword Empty = new("");

    /// <summary>
    /// 保存済みパスワード文字列から生成する
    /// </summary>
    /// <param name="value">保存済みパスワード</param>
    /// <returns>HashedPasswordオブジェクト</returns>
    public static StoredPassword CreateFromString(string value)
    {
        return new(value);
    }

    /// <summary>
    /// 入力された生パスワードが正しいかを検証する。
    /// </summary>
    /// <param name="hasher">ハッシュ化用オブジェクト</param>
    /// <param name="inputRawPassword">入力された生パスワード</param>
    /// <param name="inputUser">入力者</param>
    /// <returns>正しい: true、不正: false</returns>
    public bool Verify(IHasher hasher, RawPassword inputRawPassword, User inputUser)
    {
        var inputHashedPassword = HashedPassword.HashFromRawPassword(hasher, inputRawPassword, inputUser);

        return Value == inputHashedPassword.Value;
    }
}

生パスワードを検証するメソッドを実装します。

ユースケースを実装

IUserRepository

using Server.Models.UserAuthentications;
using Server.Models.Users;

namespace Server.UseCases.Users;

public interface IUserRepository
{
    /// <summary>
    /// ユーザ作成を試みる。
    /// </summary>
    /// <param name="newUser">新規ユーザ</param>
    /// <param name="signInId">サインインID</param>
    /// <param name="hashedPassword">ハッシュ化済みパスワード</param>
    /// <returns>成功時: true, 失敗時: false</returns>
    Task<bool> TryCreateUserAsync(User newUser, SignInId signInId, HashedPassword hashedPassword);

    /// <summary>
    /// ユーザIDからユーザを探す
    /// </summary>
    /// <param name="targetUserId"></param>
    /// <returns>(成功したか、見つかったユーザ。失敗した場合はUnknownユーザ)</returns>
    Task<(bool ok, User foundUser)> TryFindUserByIdAsync(UserId targetUserId);
}

ユースケースからDBへ格納する処理を呼び出したいですが、ユースケースのテストを行う都合でインターフェイスを介します。

ResultTypes

ユースケースの実行結果を簡単に返すために列挙型を定義します。

namespace Server.UseCases;

public enum ResultTypes
{
    /// <summary>
    /// ユースケースが成功した場合
    /// </summary>
    Success,
    /// <summary>
    /// ユースケースがユーザ入力値のバリデーション結果で失敗した場合
    /// </summary>
    ValidationError,
    /// <summary>
    /// ユースケースが内部エラーが原因で失敗した場合
    /// </summary>
    InternalError,
}

成功、ユーザ入力内容が原因のエラー、内部エラーに分類します。

UserUseCase

using Server.Models.UserAuthentications;
using Server.Models.Users;
using Server.UseCases.UserAuthentications;

namespace Server.UseCases.Users;

public class UserUseCase(IUserRepository userRepository, IUserAuthenticationRepository authRepository, IHasher hasher)
{
    private readonly IUserRepository _userRepository = userRepository;

    private readonly IUserAuthenticationRepository _authRepository = authRepository;

    private readonly IHasher _hasher = hasher;

    public record RegisterValidationResult(bool UserNameOk, bool SignInIdOk, bool RawPasswordOk);
    /// <summary>
    /// ユーザ登録ユースケース
    /// </summary>
    /// <param name="userNameValue">ユーザ名</param>
    /// <param name="signInIdValue">サインインID</param>
    /// <param name="rawPasswordValue">生パスワード</param>
    /// <returns>(ユースケース実行結果、入力値バリデーション結果)</returns>
    public async Task<(ResultTypes resultTypes, RegisterValidationResult validationResult)> RegisterUserAsync(string userNameValue, string signInIdValue, string rawPasswordValue)
    {
        // 入力値のバリデーションを行う。
        if (!UserName.TryCreate(userNameValue, out var userName))
        {
            var validationResult = new RegisterValidationResult(
                false,
                SignInId.TryCreate(signInIdValue, out _),
                RawPassword.TryCreate(rawPasswordValue, out _)
            );
            return (ResultTypes.ValidationError, validationResult);
        }
        if (!SignInId.TryCreate(signInIdValue, out var signInId))
        {
            var validationResult = new RegisterValidationResult(
                true,
                SignInId.TryCreate(signInIdValue, out _),
                RawPassword.TryCreate(rawPasswordValue, out _)
            );
            return (ResultTypes.ValidationError, validationResult);
        }
        if (!RawPassword.TryCreate(rawPasswordValue, out var rawPassword))
        {
            var validationResult = new RegisterValidationResult(true, true, false);
            return (ResultTypes.ValidationError, validationResult);
        }

        // サインインIDが既に登録されているかをチェックする
        var (existsId, _, _) = await _authRepository.TryFindAuthenticationAsync(signInId);
        if (existsId)
        {
            var validationResult = new RegisterValidationResult(true, false, true);
            return (ResultTypes.ValidationError, validationResult);
        }

        // 以降の処理ではバリデーションに成功している。
        var validParamResult = new RegisterValidationResult(true, true, true);

        // ユーザを作成し、DBに格納する。
        var user = User.CreateNew(userName);
        var hashedPassword = HashedPassword.HashFromRawPassword(_hasher, rawPassword, user);
        var ok = await _userRepository.TryCreateUserAsync(user, signInId, hashedPassword);
        if (ok)
        {
            // 成功。
            return (ResultTypes.Success, validParamResult);
        }
        else
        {
            // DBに格納する処理で失敗した場合は内部エラーとして扱う。
            return (ResultTypes.InternalError, validParamResult);
        }
    }
}

ユーザ入力値のバリデーション後、詰め替えたモデルをリポジトリ経由でDBに格納しています。

IAccessTokenGenerator

アクセストークンを生成する処理のインターフェイスを定義します。

using Server.Models.Users;

namespace Server.UseCases.UserAuthentications;

public interface IAccessTokenGenerator
{
    /// <summary>
    /// トークンを生成する。
    /// </summary>
    /// <param name="userId">ユーザID</param>
    /// <returns>トークン</returns>
    string Generate(UserId userId);
}

IUserAuthenticationRepository

サインインIDからDBに保存されたユーザIDとハッシュ化済みパスワードを取得するメソッドを定義します。
UserId userId, HashedPassword hashedPasswordの部分はレコードにしてそれを返した方がよさそうですが、
そこまで問題にならなそうなのでタプルで返しています。

using Server.Models.UserAuthentications;
using Server.Models.Users;

namespace Server.UseCases.UserAuthentications;
public interface IUserAuthenticationRepository
{
    /// <summary>
    /// サインインIDからユーザID、パスワードを探す
    /// </summary>
    /// <param name="signInId">サインインID</param>
    /// <returns>成功したか、見つけたユーザID、見つけたパスワード</returns>
    Task<(bool ok, UserId userId, StoredPassword storedPassword)> TryFindAuthenticationAsync(SignInId signInId);
}

UserAuthenticationUseCase

using Server.Models.UserAuthentications;
using Server.UseCases.Users;

namespace Server.UseCases.UserAuthentications;

public class UserAuthenticationUseCase(IHasher hasher, IAccessTokenGenerator tokenGenerator, IUserAuthenticationRepository authRepository, IUserRepository userRepository)
{
    private readonly IUserAuthenticationRepository _authRepository = authRepository;

    private readonly IUserRepository _userRepository = userRepository;

    private readonly IHasher _hasher = hasher;

    private readonly IAccessTokenGenerator _tokenGenerator = tokenGenerator;

    /// <summary>
    /// サインインのユースケース
    /// </summary>
    /// <param name="signInIdString">サインインIDの文字列</param>
    /// <param name="rawPasswordString">ユーザが入力した生パスワード文字列</param>
    /// <returns>(ユースケース実行結果、アクセストークン文字列)</returns>
    public async Task<(ResultTypes result, string accessToken)> SignInAsync(string signInIdString, string rawPasswordString)
    {
        // ユーザ入力の文字列からサインインID、生パスワードオブジェクトを生成する
        if (!SignInId.TryCreate(signInIdString, out var signInId) || !RawPassword.TryCreate(rawPasswordString, out var rawPassword))
        {
            return (ResultTypes.ValidationError, "");
        }

        // サインインIDから登録されたユーザID、ハッシュ化済みパスワードを取得す
        // ここで失敗する場合はどちらかというとユーザ入力が原因であることが多いため、バリデーションエラーとする
        var (isFoundAuthentication, userId, storedPassword) = await _authRepository.TryFindAuthenticationAsync(signInId);
        if (!isFoundAuthentication)
        {
            return (ResultTypes.ValidationError, "");
        }

        // ユーザIDからユーザを取得する。ここで失敗した場合は確実にサーバ側エラー
        var (isFoundUser, user) = await _userRepository.TryFindUserByIdAsync(userId);
        if (!isFoundUser)
        {
            return (ResultTypes.InternalError, "");
        }

        // パスワードの検証を行い、成功した場合はアクセストークンを返却する
        if (storedPassword.Verify(_hasher, rawPassword, user))
        {
            var accessToken = _tokenGenerator.Generate(userId);
            return (ResultTypes.Success, accessToken);
        }

        return (ResultTypes.ValidationError, "");
    }
}

データベースにアクセスする処理

UserAuthenticationsClient

データベースにアクセスし、読み書きする機能を実装します。
recordを使うと簡単にイミュータブルなモデルを定義できるので引数などで活用していきます。

using System.Data.Common;
using Dapper;
using Microsoft.Data.SqlClient;

namespace Server.Databases.DBClients;
public class UserAuthenticationsClient
{
    public record InsertParam(Guid UserId, string SignInId, string HashedPassword);
    /// <summary>
    /// ユーザ認証情報を挿入する
    /// </summary>
    /// <param name="connection">コネクション</param>
    /// <param name="tx">トランザクション</param>
    /// <param name="param">挿入用パラメタ</param>
    /// <returns>タスク</returns>
    public async Task InsertAsync(SqlConnection connection, DbTransaction tx, InsertParam param)
    {
        // 認証情報を挿入する
        var authenticationParam = new
        {
            param.UserId,
            param.SignInId,
            param.HashedPassword,
        };
        var authenticationQuery = $"""
                INSERT INTO [app].[user_authentications] VALUES (
                    @{nameof(authenticationParam.UserId)},
                    @{nameof(authenticationParam.SignInId)},
                    @{nameof(authenticationParam.HashedPassword)},
                    default,
                    default
                );
            """;
        await connection.ExecuteAsync(authenticationQuery, authenticationParam, tx);
    }

    public record UserAuthenticationRow(int Id, Guid UserId, string SignInId, string Password, string RefreshToken, DateTime RefreshTokenExpiration);
    /// <summary>
    /// サインインIDをキーとして読む
    /// </summary>
    /// <param name="connection">コネクション</param>
    /// <param name="signInId">キーとなるサインインID</param>
    /// <returns>見つかった認証情報</returns>
    public async Task<UserAuthenticationRow> ReadBySignInIdAsync(SqlConnection connection, string signInId)
    {
        var param = new
        {
            signInId
        };
        var query = $"""
                SELECT
                    id,
                    user_id,
                    sign_in_id,
                    password,
                    refresh_token,
                    refresh_token_expiration
                FROM
                    [app].[user_authentications] user_authentications
                WHERE
                    user_authentications.sign_in_id = @{nameof(param.signInId)}
            """;

        var row = await connection.QueryFirstAsync<UserAuthenticationRow>(query, param);

        return row;
    }
}

UsersClient

ユーザ挿入処理も同様に実装します。

using System.Data.Common;
using Dapper;
using Microsoft.Data.SqlClient;

namespace Server.Databases.DBClients;
public class UsersClient
{
    public record UserRow(Guid Id, string Name, DateTime RegisteredAt);
    /// <summary>
    /// ユーザIDをキーとしてユーザを読む
    /// </summary>
    /// <param name="connection">コネクション</param>
    /// <param name="userId">キーとするユーザID</param>
    /// <returns>読んだユーザ</returns>
    public async Task<UserRow> ReadUserByIdAsync(SqlConnection connection, Guid userId)
    {
        var param = new
        {
            userId,
        };

        var query = $"""
                SELECT
                    id,
                    name,
                    registered_at
                FROM
                    [app].[users] users
                WHERE
                    users.id = @{nameof(param.userId)}
            """;

        var userRow = await connection.QueryFirstAsync<UserRow>(query, param);

        return userRow;
    }

    public record InsertUserParam(Guid UserId, string Name, DateTime RegisteredAt);
    /// <summary>
    /// ユーザ情報を挿入する
    /// </summary>
    /// <param name="connection">コネクション</param>
    /// <param name="tx">トランザクション</param>
    /// <param name="param">挿入用パラメタ</param>
    /// <returns>タスク</returns>
    public async Task InsertAsync(SqlConnection connection, DbTransaction tx, InsertUserParam param)
    {
        // ユーザデータの挿入を行う。
        var userParam = new
        {
            param.UserId,
            param.Name,
            param.RegisteredAt
        };
        var userQuery = $"""
                INSERT INTO [app].[users] VALUES (
                    @{nameof(userParam.UserId)},
                    @{nameof(userParam.Name)},
                    @{nameof(userParam.RegisteredAt)}
                );
            """;
        await connection.ExecuteAsync(userQuery, userParam, tx);
    }
}

UserRepository

実装したDBClientを利用した処理を記述します。

using Microsoft.Data.SqlClient;
using Server.Databases.DBClients;
using Server.Models.UserAuthentications;
using Server.Models.Users;
using Server.UseCases.Users;

namespace Server.Databases;

public class UserRepository(ILogger<UserRepository> logger, string connectionString) : IUserRepository
{
    private readonly UsersClient _userClient = new();

    private readonly ILogger<UserRepository> _logger = logger;

    private readonly UserAuthenticationsClient _authenticationsClient = new();

    /// <summary>
    /// ユーザ作成を試みる。
    /// </summary>
    /// <param name="newUser">新規ユーザ</param>
    /// <param name="signInId">サインインID</param>
    /// <param name="hashedPassword">ハッシュ化済みパスワード</param>
    /// <returns>成功時: true, 失敗時: false</returns>
    public async Task<bool> TryCreateUserAsync(User newUser, SignInId signInId, HashedPassword hashedPassword)
    {
        using var connection = new SqlConnection(connectionString);
        await connection.OpenAsync();

        using var tx = await connection.BeginTransactionAsync();
        try
        {
            // ユーザを挿入する。
            var insertUserParam = new UsersClient.InsertUserParam(newUser.Id.Value, newUser.Name.Value, newUser.RegisteredAt);
            await _userClient.InsertAsync(connection, tx, insertUserParam);

            // 認証用情報を挿入する。
            var insertAuthParam = new UserAuthenticationsClient.InsertParam(newUser.Id.Value, signInId.Value, hashedPassword.Value);
            await _authenticationsClient.InsertAsync(connection, tx, insertAuthParam);

            // コミットする
            await tx.CommitAsync();
            return true;
        }
        catch (Exception exception)
        {
            // 例外を出力し、ロールバックする。
            _logger.LogError(exception, "ユーザ作成時にエラーが発生しました。");
            await tx.RollbackAsync();
            return false;
        }
    }

    /// <summary>
    /// ユーザIDからユーザを探す
    /// </summary>
    /// <param name="targetUserId">見つける対象のユーザID</param>
    /// <returns>(成功したか、見つかったユーザ。失敗した場合はUnknownユーザ)</returns>
    public async Task<(bool ok, User foundUser)> TryFindUserByIdAsync(UserId targetUserId)
    {
        using var connection = new SqlConnection(connectionString);
        try
        {
            var userRow = await _userClient.ReadUserByIdAsync(connection, targetUserId.Value);
            _ = UserName.TryCreate(userRow.Name, out var userName);
            var user = User.Create(targetUserId, userName!, userRow.RegisteredAt);
            return (true, user);
        }
        catch (Exception exception)
        {
            _logger.LogError(exception, "ユーザ取得時にエラーが発生しました。");
            return (false, User.Unknown);
        }
    }
}

UserAuthenticationRepository

こちらも同様に実装します。

using Microsoft.Data.SqlClient;
using Server.Databases.DBClients;
using Server.Models.UserAuthentications;
using Server.Models.Users;
using Server.UseCases.UserAuthentications;

namespace Server.Databases;

public class UserAuthenticationRepository(ILogger<UserAuthenticationRepository> logger, string connectionString) : IUserAuthenticationRepository
{
    private readonly UserAuthenticationsClient _authenticationsClient = new();

    private readonly ILogger<UserAuthenticationRepository> _logger = logger;

    /// <summary>
    /// サインインIDからユーザID、パスワードを探す
    /// </summary>
    /// <param name="signInId">サインインID</param>
    /// <returns>成功したか、見つけたユーザID、見つけたパスワード</returns>
    public async Task<(bool ok, UserId userId, StoredPassword storedPassword)> TryFindAuthenticationAsync(SignInId signInId)
    {
        using var connection = new SqlConnection(connectionString);

        try
        {
            var authRow = await _authenticationsClient.ReadBySignInIdAsync(connection, signInId.Value);
            var hashedPassword = StoredPassword.CreateFromString(authRow.Password);
            var userId = new UserId(authRow.UserId);
            return (true, userId, hashedPassword);
        }
        catch (Exception exception)
        {
            // 例外を出力する。
            _logger.LogError(exception, "ユーザID・パスワードの取得に失敗しました。");
            return (false, UserId.Empty, StoredPassword.Empty);
        }
    }
}

パスワードハッシュ化処理を実装

HasherByPBKDF2

.NET 8では特にライブラリをインストールせずにPBKDF2を利用できます。パスワードのハッシュ化に特化しているハッシュ関数なので、活用します。
Rfc2898DeriveBytes クラス(learn.microsoft.com)

using Server.Models.UserAuthentications;
using Server.Models.Users;
using System.Security.Cryptography;
using System.Text;

namespace Server.Authentications;

public class HasherByPBKDF2(string pepper) : IHasher
{
    /// <summary>
    /// ペッパー
    /// </summary>
    private readonly string _pepper = pepper;
    /// <summary>
    /// ストレッチング回数
    /// </summary>
    private readonly static int _iterations = 310000;

    /// <summary>
    /// 生パスワードの作成を試みる
    /// </summary>
    /// <param name="value">生パスワードの文字列</param>
    /// <param name="rawPassword">作成した生パスワード</param>
    /// <returns>成功: true 失敗: false</returns>
    public string Generate(User user, RawPassword rawPassword)
    {
        // ユーザ登録日をソルトとする
        var saltBytes = Encoding.UTF8.GetBytes(user.RegisteredAt.ToString("yyyyMMddHHmmss"));
        var pepperBytes = Encoding.UTF8.GetBytes(_pepper);

        using var pbkdf2 = new Rfc2898DeriveBytes(
            Encoding.UTF8.GetBytes(rawPassword.Value),
            // ソルトとペッパーを結合して渡す
            [.. saltBytes, .. pepperBytes],
            _iterations,
            HashAlgorithmName.SHA512);

        // 8 * 64 = 512 ビットのハッシュ値を取得する
        var hash = pbkdf2.GetBytes(64);

        return Convert.ToBase64String(hash);
    }
}

31万回ストレッチングし、ソルト、ペッパーを利用してSHA-512ハッシュアルゴリズムでパスワードをハッシュ化します。
Rfc2898DeriveBytesのコンストラクタ引数ではソルトのみ受け付けているので、[.. saltBytes, .. pepperBytes]でソルトとペッパーを結合して渡しています。
この構文はC# 12でコレクション式が実装されたタイミングで実装されましたが、このような引数を渡す場面で非常に便利です。

アクセストークン生成処理を実装

アクセストークンの生成処理を実装します。今回はJWT認証を利用するため、JWTを生成する処理となります。

AccessTokenGenerator

using Microsoft.IdentityModel.Tokens;
using Server.Models.Users;
using Server.UseCases.UserAuthentications;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames;

namespace Server.Authentications;
public class AccessTokenGenerator(string key, string issuer, string audience) : IAccessTokenGenerator
{
    private readonly string _key = key;

    private readonly string _issuer = issuer;

    private readonly string _audience = audience;

    /// <summary>
    /// トークンを生成する。
    /// </summary>
    /// <param name="userId">ユーザID</param>
    /// <returns>トークン</returns>
    public string Generate(UserId userId)
    {
        var secrityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_key));
        // 資格情報
        var credentials = new SigningCredentials(secrityKey, SecurityAlgorithms.HmacSha256);

        Claim[] claims = [
            // sub(subject)クレームは JWT の主語となる主体の識別子である. JWT に含まれるクレームは, 通常 subject について述べたものである.  
            // https://openid-foundation-japan.github.io/draft-ietf-oauth-json-web-token-11.ja.html#subDef
            new(JwtRegisteredClaimNames.Sub, userId.Value.ToString()),
            // jti (JWT ID) クレームは, JWT のための一意な識別子を提供する. その識別子の値は, 重複確率が無視できるほど十分低いことを保証できる方法で割り当てられなければならない
            // https://openid-foundation-japan.github.io/draft-ietf-oauth-json-web-token-11.ja.html#jtiDef
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
        ];

        var token = new JwtSecurityToken(
            issuer: _issuer,
            audience: _audience,
            claims: claims,
            signingCredentials: credentials,
            expires: DateTime.UtcNow.AddHours(1)
        );
        var tokenString = new JwtSecurityTokenHandler().WriteToken(token);
        return tokenString;
    }

    /// <summary>
    /// リクエストからユーザIDを解析する
    /// </summary>
    /// <param name="authHeader">認可ヘッダ文字列</param>
    /// <returns>ユーザID</returns>
    public static Guid ParseUserId(HttpRequest request)
    {
        var authHeader = request.Headers.Authorization.ToString();
        var handler = new JwtSecurityTokenHandler();
        // 認可ヘッダには「Bearer 」という接頭辞がついているため、取り除く
        authHeader = authHeader.Replace("Bearer ", "");
        var jwtSecurityToken = (JwtSecurityToken)handler.ReadToken(authHeader);
        // サブジェクトクレームにユーザIDがある
        var id = jwtSecurityToken.Claims.First(claim => claim.Type == JwtRegisteredClaimNames.Sub).Value;
        // Guidとして解析し戻す
        return Guid.Parse(id);
    }

    /**
     * [参考]
     * https://qiita.com/te-k/items/600df0d0d812139de881
     * https://openid-foundation-japan.github.io/draft-ietf-oauth-json-web-token-11.ja.html
     */
}

subjectクレームにはユーザID、JWT IDクレームにはGuidを割り当てています。
JWTには改ざんを検知する仕組みが備わっているため、トークン認証後にユーザIDを取得する際はJWTをデコードしてユーザIDを取得します。

クレームには機密情報を含めないようにしてください。JWTのクレーム部分はBase64エンコードされているだけなので簡単にデコードできます。

環境変数へアクセスするためのクラスを実装する(任意)

このプログラムでは環境変数を利用します。C#で環境変数を取得するにはEnvironment.GetEnvironmentVariable(string)を利用しますが、これを直接利用すると少し不便な点があります。

  1. 環境変数名は文字列で渡す必要がある
    → 同じ環境変数を複数個所で参照している場合、変数名が変わった際の変更箇所が複数生じる
  2. 取得できない場合はnullが返ってしまう
    → 取得できない場合はサーバが動作しないはずなのでサーバを落としていい
  3. どこで環境変数を取得しているかがわかりづらくなる
    → 調べるためにEnvironment.GetEnvironmentVariableで検索をかけたくない
  4. 利用している環境変数を一目で確認できない
    → docker-composeに書かれる環境変数はプログラムから参照しているものだけではない

これらの問題を解決するために、環境変数を取得するためのラッパクラスを実装します。

Program.csと同じ階層にServerEnvironments.csを作成します。

namespace Server;

public enum VariableTypes
{
    /// <summary>
    /// データベースの接続文字列
    /// </summary>
    DBConnectionString,
    /// <summary>
    /// ハッシュ化時に用いるペッパー
    /// </summary>
    HasherPepper,
    /// <summary>
    /// JWTで用いる発行者
    /// </summary>
    JWTIssuer,
    /// <summary>
    /// リフレッシュトークン生成に用いる秘密鍵
    /// </summary>
    JWTKeyForRefreshToken,
    /// <summary>
    /// アクセストークン生成に用いる秘密鍵
    /// </summary>
    JWTKeyForAccessToken,
    /// <summary>
    /// JWTの受信者情報(基本的にURI形式)
    /// </summary>
    JWTAudience,
}

public class ServerEnvironments
{
    /// <summary>
    /// 環境変数の値を取得する。失敗した場合は落ちる。
    /// </summary>
    /// <param name="type">取得可能な環境変数の種類</param>
    /// <returns></returns>
    /// <exception cref="ArgumentOutOfRangeException">typeが不正な値だった場合スローされる</exception>
    /// <exception cref="ArgumentException">環境変数が設定されていなかった場合スローされる</exception>
    public static string Get(VariableTypes type)
    {
        var variableName = type switch
        {
            VariableTypes.DBConnectionString => "DB_CONNECTION_STR",
            VariableTypes.HasherPepper => "PEPPER",
            VariableTypes.JWTIssuer => "JWT_ISSURER",
            VariableTypes.JWTKeyForRefreshToken => "JWT_KEY_REFRESH_TOKEN",
            VariableTypes.JWTKeyForAccessToken => "JWT_KEY_ACCESS_TOKEN",
            VariableTypes.JWTAudience => "JWT_AUDIENCE",
            _ => throw new ArgumentOutOfRangeException(nameof(type), $"環境変数名の設定を忘れています: {type}")
        };

        return Environment.GetEnvironmentVariable(variableName)
            ?? throw new ArgumentException($"環境変数 {variableName} が設定されていません。", nameof(type));
    }
}

今後必要になる環境変数はあらかじめ設定しました。このように実装することで、先述した課題は解決されます。

  1. 環境変数名は文字列で渡す必要がある
    → 列挙型を用いて参照できるようになる

  2. 取得できない場合はnullが返ってしまう
    → 例外で落ちるようになる

  3. どこで環境変数を取得しているかがわかりづらくなる
    → エディタ・IDEの機能である参照ボタンをクリックするだけで確認できるようになる
    ref_button.png

  4. 実装したプログラムで利用している環境変数を一目で確認できない
    → 列挙型のメンバを見ればよくなる

Program.csを書き換える

using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using Server;
using Server.Authentications;
using Server.Databases;
using Server.Models.UserAuthentications;
using Server.UseCases.UserAuthentications;
using Server.UseCases.Users;

internal class Program
{
    public const string REGISTER_USER_RATE_LIMITER = "register";

    public const string TOKEN_RATE_LIMITER = "token";

    private static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        // Add services to the container.

        builder.Services.AddControllers(
            // デフォルトで認証が必要にする、
            // https://qiita.com/mkuwan/items/bd5ff882108998d76dca
            options => options.Filters.Add(
                new AuthorizeFilter(
                    new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
                        .RequireAuthenticatedUser()
                        .Build()
                )
            )
        );
        // ベアラートークン認証関連の登録
        builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidIssuer = ServerEnvironments.Get(VariableTypes.JWTIssuer),
                    ValidAudience = ServerEnvironments.Get(VariableTypes.JWTAudience),
                    IssuerSigningKey = new SymmetricSecurityKey(
                        Encoding.UTF8.GetBytes(
                            ServerEnvironments.Get(VariableTypes.JWTKeyForAccessToken))),
                    ValidateIssuer = true,
                    ValidateAudience = true,
                    ValidateLifetime = true,
                    ValidateIssuerSigningKey = true,
                    ClockSkew = TimeSpan.Zero
                };
            });
        // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
        builder.Services.AddEndpointsApiExplorer();
        builder.Services.AddSwaggerGen(option =>
        {
            option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
            {
                In = ParameterLocation.Header,
                Description = "JWTトークンを入力してください。",
                Name = "Authorization",
                Type = SecuritySchemeType.Http,
                BearerFormat = "JWT",
                Scheme = "Bearer"
            });
            option.AddSecurityRequirement(new OpenApiSecurityRequirement
            {
                {
                    new OpenApiSecurityScheme {
                        Reference = new OpenApiReference {
                            Type=ReferenceType.SecurityScheme, Id="Bearer"
                        }
                    },
                    []
                }
            });
        });

        // カラム名のアンダースコアを無視する。こうすることでキャメルケースでもマップできる。
        Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true;

        // Hasherを登録
        RegisterHasher(builder.Services);

        // トークン生成器を登録
        RegisterTokenGenerator(builder.Services);

        // リポジトリを登録
        RegisterRepositories(builder.Services);

        // ユースケースを登録
        RegisterUseCases(builder.Services);

        // レート制限を設定
        RegisterRateLimit(builder.Services);

        var app = builder.Build();

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

        // 認証
        app.UseAuthentication();
        // 認可
        app.UseAuthorization();

        app.MapControllers();

        // レート制限ミドルウェアを開始
        app.UseRateLimiter();

        app.Run();
    }

    /// <summary>
    /// レート制限をかけるための設定を行う
    /// </summary>
    /// <param name="services">サービス</param>
    private static void RegisterRateLimit(IServiceCollection services)
    {
        services.AddRateLimiter(options =>
        {
            options.AddFixedWindowLimiter(REGISTER_USER_RATE_LIMITER, options =>
            {
                // 時間枠内で受け取れるリクエスト数
                options.PermitLimit = 1;
                // リクエストを受け取る時間枠
                options.Window = TimeSpan.FromSeconds(60);
                // リクエストのキューの容量
                options.QueueLimit = 0;
            })
            .AddFixedWindowLimiter(TOKEN_RATE_LIMITER, options =>
            {
                options.PermitLimit = 3;
                options.Window = TimeSpan.FromSeconds(60);
                options.QueueLimit = 0;
            });
        });
    }

    /// <summary>
    /// ハッシュ化処理を登録
    /// </summary>
    /// <param name="services">DIコンテナ</param>
    private static void RegisterTokenGenerator(IServiceCollection services)
    {
        // トークン生成には機密情報を使うので、ここでのみ生成を行う。
        services
            .AddSingleton<IAccessTokenGenerator, AccessTokenGenerator>(services =>
            {
                var audience = ServerEnvironments.Get(VariableTypes.JWTAudience);
                var issuer = ServerEnvironments.Get(VariableTypes.JWTIssuer);
                var key = ServerEnvironments.Get(VariableTypes.JWTKeyForAccessToken);

                return new(key, issuer, audience);
            });
    }

    /// <summary>
    /// ハッシュ化処理を登録
    /// </summary>
    /// <param name="services">DIコンテナ</param>
    private static void RegisterHasher(IServiceCollection services)
    {
        services
            .AddSingleton<IHasher, HasherByPBKDF2>(services => new(ServerEnvironments.Get(VariableTypes.HasherPepper)));
    }

    /// <summary>
    /// リポジトリを登録する
    /// </summary>
    /// <param name="services">DIコンテナ</param>
    private static void RegisterRepositories(IServiceCollection services)
    {
        var connectionString = ServerEnvironments.Get(VariableTypes.DBConnectionString);
        services
            .AddSingleton<IUserRepository, UserRepository>(services => new(services.GetRequiredService<ILogger<UserRepository>>(), connectionString))
            .AddSingleton<IUserAuthenticationRepository, UserAuthenticationRepository>(
                    services => new(services.GetRequiredService<ILogger<UserAuthenticationRepository>>(), connectionString)
                );
    }

    /// <summary>
    /// DIコンテナにユースケースを登録する。
    /// </summary>
    /// <param name="services">DIコンテナ</param>
    private static void RegisterUseCases(IServiceCollection services)
    {
        services
            .AddSingleton(services =>
            {
                var userRepository = services.GetRequiredService<IUserRepository>();
                var userAuthRepository = services.GetRequiredService<IUserAuthenticationRepository>();
                var hasher = services.GetRequiredService<IHasher>();
                return new UserUseCase(userRepository, userAuthRepository, hasher);
            })
            .AddSingleton(services =>
            {
                var userRepository = services.GetRequiredService<IUserRepository>();
                var hasher = services.GetRequiredService<IHasher>();
                var tokenGenerator = services.GetRequiredService<IAccessTokenGenerator>();
                var userAuthRepository = services.GetRequiredService<IUserAuthenticationRepository>();
                return new UserAuthenticationUseCase(hasher, tokenGenerator, userAuthRepository, userRepository);
            });
    }
}

追加した個所が多いため、列挙します。

  • DIコンテナへ各処理を登録
  • swaggerで認証を利用できるようにする設定
  • Dapperでカラム名のアンダースコアを無視する設定
  • レート制限の設定
  • コントローラ側で特に指定しなければ必ず認証が必要になるよう設定
    環境変数へアクセスするクラスの実装をしていない場合はServerEnvironments.GetEnvironment.GetEnvironmentVariable(string)!に置き換えてください。

筆者はMainメソッドを書くスタイルに慣れているので直しましたが、この辺は好みの問題なので直さなくてもいいです。

レート制限設定部分の解説

    /// <summary>
    /// レート制限をかけるための設定を行う
    /// </summary>
    /// <param name="services">サービス</param>
    private static void RegisterRateLimit(IServiceCollection services)
    {
        services.AddRateLimiter(options =>
        {
            options.AddFixedWindowLimiter(REGISTER_USER_RATE_LIMITER, options =>
            {
                // 時間枠内で受け取れるリクエスト数
                options.PermitLimit = 1;
                // リクエストを受け取る時間枠
                options.Window = TimeSpan.FromSeconds(60);
                // リクエストのキューの容量
                options.QueueLimit = 0;
            })
            .AddFixedWindowLimiter(TOKEN_RATE_LIMITER, options =>
            {
                options.PermitLimit = 3;
                options.Window = TimeSpan.FromSeconds(60);
                options.QueueLimit = 0;
            });
        });
    }

ここの実装で設定しています。レート制限は複数作ることができ、文字列をキーとして利用できます。今回の実装ではコントローラの属性引数で利用するために定数として設定しています。REGISTER_USER_RATE_LIMITERのレート制限設定を利用する場合は、60秒の間に1回だけリクエストを受け付けるようにできます。TOKEN_RATE_LIMITERの設定を利用する場合は、60秒の間に3回リクエストを受け付けるようにできます。このように設定することで、ユーザ登録やサインインを短い間隔で繰り返し実行されることを防ぐことができます。特にサインインでは総当たり攻撃の妨害に利用できます。

コントローラを実装

UserController

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Server.Authentications;
using Server.UseCases;
using Server.UseCases.Users;

namespace Server.Controllers;

[ApiController]
[Route("users")]
public class UserController(ILogger<UserController> logger, UserUseCase userUseCase) : ControllerBase
{
    private readonly ILogger<UserController> _logger = logger;
    private readonly UserUseCase _userUseCase = userUseCase;

    public record RegisterParams(string Name, string SignInId, string Password);
    public record RegisterValidationResponse(bool UserNameOk, bool SignInIdOk, bool RawPasswordOk);
    [AllowAnonymous]
    [EnableRateLimiting(Program.REGISTER_USER_RATE_LIMITER)]
    [HttpPost("register")]
    public async Task<IActionResult> RegisterAsync(RegisterParams param)
    {
        // 登録ユースケースを実行
        var (resultTypes, validationResult) = await _userUseCase.RegisterUserAsync(param.Name, param.SignInId, param.Password);
        if (resultTypes is ResultTypes.Success)
        {
            return Ok();
        }

        if (resultTypes is ResultTypes.InternalError)
        {
            _logger.LogError("ユーザ登録時にサーバ内エラーが発生しました");
            return StatusCode(500, "Internal Server Error");
        }

        var validationResultResponse = new RegisterValidationResponse(validationResult.UserNameOk, validationResult.SignInIdOk, validationResult.RawPasswordOk);
        return BadRequest(validationResultResponse);
    }

    public record GetUserResponse(string Name);
    [HttpGet]
    public async Task<IActionResult> GetUserAsync()
    {
        var userId = AccessTokenGenerator.ParseUserId(Request);
        var (resultTypes, user) = await _userUseCase.GetUserAsync(userId);

        if (resultTypes is ResultTypes.Success)
        {
            return Ok(new GetUserResponse(user.Name.Value));
        }

        _logger.LogError("ユーザ取得時にサーバ内エラーが発生しました");
        return StatusCode(500, "Internal Server Error");
    }
}

コントローラではユースケースを実行・レスポンスの生成を行っています。コントローラはテストしないので、できる限り薄く実装します。また、認証されていない場合でもリクエストを受け付けるため、レート制限を設けています。

AuthenticationController

サインイン用のコントローラを実装します。

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Server.UseCases;
using Server.UseCases.UserAuthentications;

namespace Server.Controllers;

[ApiController]
[Route("auth")]
public class AuthenticationController(ILogger<AuthenticationController> logger, UserAuthenticationUseCase authUseCase) : ControllerBase
{
    private readonly ILogger<AuthenticationController> _logger = logger;
    private readonly UserAuthenticationUseCase _authUseCase = authUseCase;

    public record SignInParams(string SignInId, string Password);
    public record SignInResponse(string AccessToken);
    [AllowAnonymous]
    [EnableRateLimiting(Program.TOKEN_RATE_LIMITER)]
    [HttpPost("sign_in")]
    public async Task<IActionResult> RegisterAsync(SignInParams param)
    {
        // サインインユースケースを実行
        var (resultTypes, accessToken) = await _authUseCase.SignInAsync(param.SignInId, param.Password);
        if (resultTypes is ResultTypes.Success)
        {
            return Ok(new SignInResponse(accessToken));
        }

        if (resultTypes is ResultTypes.InternalError)
        {
            _logger.LogError("サインイン時にサーバ内エラーが発生しました");
            return StatusCode(500, "Internal Server Error");
        }

        return BadRequest();
    }
}

サインインのハンドラもレート制限を付けます。

docker-compose.ymlに環境変数を登録

    .
    .
    .
    depends_on:
      - "db"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - DOTNET_WATCH_RESTART_ON_RUDE_EDIT=true
      - PEPPER=pepper
      - DB_CONNECTION_STR=Data Source=db, 1433;Initial Catalog=basic_db;User ID=sa;Password=P@sswordP@ssword;TrustServerCertificate=true;
    command: dotnet watch run --project /api_src/Server/Server.csproj --launch-profile http    depends_on:
      - "db"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - DOTNET_WATCH_RESTART_ON_RUDE_EDIT=true
      - PEPPER=pepper
      - DB_CONNECTION_STR=Data Source=db, 1433;Initial Catalog=basic_db;User ID=sa;Password={管理者アカウントのパスワード};TrustServerCertificate=true;
      - JWT_AUDIENCE=クライアント側を登録
      - JWT_ISSURER=localhost:8080
      - JWT_KEY_ACCESS_TOKEN=The_encryption_algorithm_'HS256'_requires_a_key_size_of_at_least_'128'_bits.
    command: dotnet watch run --project /api_src/Server/Server.csproj --launch-profile http
    .
    .
    .

environment部分に環境変数を追記し、コンテナを再起動します。各環境変数の内容をまとめます。

動作確認

正しく起動できていればhttp://localhost:8080/swagger/index.htmlへアクセスすると、次の画像の通り表示されます。
image.png

/users/registerをクリックすると、パラメータのスキーマ等が表示されます。
Try it outをクリックすると、リクエストパラメータを編集し、実際にリクエストできます。
swagger_flow1.png

swagger_try_register.png
1のところでパラメータを編集し、2をクリックすると、3の位置に結果が表示されます。

リクエストを連続で送信すると、次の画像の通り失敗します。
rate_limit.png

DBを確認したい場合

SELECT
    * 
FROM [app].[users] users
    INNER JOIN [app].[user_authentications] user_authentications
        ON users.id = user_authentications.user_id

このようなクエリを適当なファイルに書き、右上の再生ボタンを押すとクエリを実行できます。
register_query.png

他にはA5:SQL Mk-2等のSQLクライアントソフトウェアを利用したり、DB初期化スクリプトで利用したsqlcmdをコンテナ内で実行したりする方法がありますが、割愛します。

認証が必要なAPIの動作確認

ユーザ登録後、サインインします。
image.png
サインインのレスポンスにあるアクセストークンをコピーします。
image.png
画面右上のAuthorizeボタンをクリックするとダイアログが表示されるので、そこにトークンを入力します。入力後、ダイアログ左下のAuthorizeボタンをクリックすることで、認証が必要なAPIを試すことができるようになります。

xUnitを利用したテストの実装

プロジェクトの準備

ソリューション・プロジェクト作成

リポジトリルートへ移動し、次のスクリプトを実行します。

cd ./API
# サーバテストプロジェクト用ディレクトリを作成
mkdir ./ServerTests
# プロジェクトを作成し、ソリューションにプロジェクトを追加
dotnet new xunit -f "net8.0" -lang "C#" -o "./ServerTests"
dotnet sln "API.sln" add "./ServerTests/ServerTests.csproj"
cd ..

ライブラリをインストール

モックを利用したテストをするので、Moqをインストールします。

dotnet add ./API/ServerTests/ServerTests.csproj package Moq --version 4.20.72

プロジェクト参照を追加する

テストプロジェクトからサーバプロジェクトを参照できるようにします。

dotnet add ./API/ServerTests/ServerTests.csproj reference ./API/Server/Server.csproj

テストコードを書く

すべてを示していません。完全なテストはサンプルプログラムを参照して下さい。

モデルの生成メソッドをテストする

サインインIDの生成メソッドを境界値テストしています。

using Server.Models.UserAuthentications;

namespace ServerTests.Models.UserAuthentications;
public class TestSignInId
{
    [Theory(DisplayName = "サインインIDのバリデーションの正常系")]
    // 10文字
    [InlineData("0123456789")]
    // 100文字
    [InlineData("0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789")]
    public void ValidateSignInId_Valid(string signInIdValue)
    {
        var ok = SignInId.TryCreate(signInIdValue, out var signInId);
        Assert.True(ok);
        Assert.Equal(signInIdValue, signInId?.Value);
    }

    [Theory(DisplayName = "サインインIDのバリデーションの異常系")]
    // 9文字
    [InlineData("012345678")]
    // 100文字 + 1
    [InlineData("01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890")]
    public void ValidateSignInId_InvalidFormat(string signInIdValue)
    {
        var ok = SignInId.TryCreate(signInIdValue, out var signInId);
        Assert.False(ok);
        Assert.Null(signInId);
    }
}

モックを利用してユースケースをテストする

モックを利用してユーザ登録のユースケースをテストします。

using Moq;
using Server.Models.UserAuthentications;
using Server.Models.Users;
using Server.UseCases;
using Server.UseCases.UserAuthentications;
using Server.UseCases.Users;

namespace ServerTests.UseCases.Users;
public class TestUserUseCase
{
    [Theory(DisplayName = "ユーザを挿入できるかの正常系テスト")]
    [InlineData("user", "aiueo@test.co.jp", "          ")]
    public async Task RegisterUser_Valid(string userName, string signInId, string password)
    {
        // Arrange
        var hasher = new Mock<IHasher>();
        var tokenGenerator = new Mock<IAccessTokenGenerator>();
        var userRepository = new Mock<IUserRepository>();
        var authRepository = new Mock<IUserAuthenticationRepository>();
        var hashedPassword = "hashed";
        if (!RawPassword.TryCreate(password, out var rawPassword) ||
            !UserName.TryCreate(userName, out var expectedUserName) ||
            !SignInId.TryCreate(signInId, out var expectedSignInId))
        {
            Assert.Fail($"インラインデータが不正です。{userName},{signInId},{password}");
            return;
        }
        // authRepositoryが値を返さないように設定する
        authRepository
            .Setup(r => r.TryFindAuthenticationAsync(expectedSignInId))
            .ReturnsAsync((false, new(), StoredPassword.Empty));
        var userId = UserId.CreateNew();
        var registeredAt = DateTime.Now;
        var user = User.Create(userId, expectedUserName, registeredAt);
        // ハッシュ化したパスワードを定義
        hasher
            .Setup(h => h.Generate(user, rawPassword))
            .Returns(hashedPassword);
        var expectedHashedPassword = HashedPassword.HashFromRawPassword(hasher.Object, rawPassword, user);
        // リポジトリのユーザ作成メソッドは成功を返すように設定
        userRepository
            .Setup(r => r.TryCreateUserAsync(user, expectedSignInId, expectedHashedPassword))
            .ReturnsAsync(true);
        var useCase = new UserUseCase(userRepository.Object, authRepository.Object, hasher.Object);

        // Act
        var (resultTypes, validationResult) = await useCase.RegisterUserAsync(userId, userName, registeredAt, signInId, password);

        // Assert
        // パスワードハッシュ化メソッドを呼び出しているかをテストする。
        hasher.Verify(hasher => hasher.Generate(user, rawPassword));
        // リポジトリのユーザ作成メソッドが呼び出されている。
        userRepository.Verify(repo =>
            repo.TryCreateUserAsync(user, expectedSignInId, expectedHashedPassword),
            Times.Once());
        // ユースケースの戻り値で成功した結果が返っているか
        Assert.Equal(ResultTypes.Success, resultTypes);
        // バリデーション結果が正しいか
        Assert.Equal(new UserUseCase.RegisterValidationResult(true, true, true), validationResult);
    }
}

モックを利用してメソッドが呼び出されているかをテストしています。大まかには、

  • パスワードハッシュ化メソッドを準備した生パスワードを渡して呼び出しているか
  • ユーザ作成メソッドを期待したハッシュ化済みパスワードを渡して呼び出しているか
  • 戻り値のテスト
    をテストしています。

モックを利用したテストの注意点

このようなテストはホワイトボックステストになるため、壊れやすいテストになります。今回はユーザ登録やサインインを扱っているため、具体的な実装までテストしています。このように、モックを利用する場合はテストの目的をよく考える必要があります。

CI(継続的インテグレーション)の構築

テストを実装できたので、GitHub Actionsを利用してテストを自動化します。リポジトリルートに.github/workflowsディレクトリを作成し、その中にCI_API.ymlを作成します。
image.png

アクションの実装例

プルリクエストが作られた時またはメインブランチへプッシュされた際にテストが自動で実行されます。

CI_API.yml
name: Build and Test

on:
  pull_request:
  push: 
    branches: ["main"]

jobs:

  build:
    runs-on: ubuntu-latest  

    steps:
    - name: Checkout
      uses: actions/checkout@v3
      with:
        fetch-depth: 0

    - name: .NET 8 をインストール
      uses: actions/setup-dotnet@v3
      with:
        dotnet-version: 8.0.x
    
    - name: リストアする
      run: dotnet restore API/Server/Server.csproj; dotnet restore API/ServerTests/ServerTests.csproj

    - name: API本体をビルドする
      run: dotnet build API/Server/Server.csproj --no-restore --warnaserror

    - name: テストプロジェクトをビルドする
      run: dotnet build API/ServerTests/ServerTests.csproj --no-restore

    - name: テストを実行
      run: dotnet test API/API.sln /p:Configuration=Release

実際にGitHubへプッシュするとこのように動作します。
image.png

Azureの準備

注意

この方法では年間で1万円(1日あたり25~26円)ほどかかります。Azureは初回登録時に無料クレジットを配布したり、学生向けサブスクリプションも利用できるため、上手く活用します。

利用するリソース

リソース名 概要
Azure Container Registry ビルドしたDockerイメージを置く場所です。
Azure Container Apps コンテナ環境でアプリケーションを動作させる場所です。APIサーバを動作させます。
Azure Conainer Apps Environments 証明書の設定やネットワークの設定などサーバが動作する環境を設定できます。
SQL Server データベースを動作する環境を設定できます。
Azure SQL Database データベースを動作させる場所です。

リソースグループを作成する

image.png
すべてのサービスからリソース グループを検索し、クリックします。
image.png
作成ボタンが左側に表示されるので、クリックします。

image.png]
クリックするとサブスクリプション、リソースグループ名、リージョンの選択をする画面が出てくるので入力します。リージョンは東日本と西日本を選ぶことができます。
最後に「確認及び作成タブ」からリソースを作成します。

Azure Container Registoryの準備

image.png
まず画像の通りにコンテナレジストリを作成します。プランは最も安いBasicにしています。パブリックアクセスを許可したくない場合はプレミアムプラン以上が必要です。

GitHub Actionsからイメージをプッシュするための設定

Cloud Shellを起動し、次のスクリプトを実行します。

resourcesName="<リソースグループ名>"

# リソースグループIDを取得する
groupId=$(az group show \
  --name $resourcesName \
  --query id --output tsv)

# 認証にはサービスプリンシパルが必要なので、作成する
az ad sp create-for-rbac \
  --scope $groupId \
  --role Contributor \
  --sdk-auth

ここまで実行すると、JSON形式の文字列がターミナルに出力されます。
出力されたJSONをメモします。

registryId=$(az acr show \
  --name <レジストリ名> \
  --resource-group $resourcesName \
  --query id --output tsv)

# プッシュの権限を付与
az role assignment create \
  --assignee <クライアントID> \
  --scope $registryId \
  --role AcrPush

管理者アカウントの有効化

image.png
作成したコンテナレジストリのアクセスキーページから管理者ユーザにチェックします。コンテナアプリの作成時に必要な設定です。

GitHub側の設定

リポジトリに次のシークレット変数を登録します。
image.png

変数名
AZURE_CREDENTIALS メモしたJSON
REGISTRY_USERNAME メモしたJSONから抜き出したクライアントID
REGISTRY_PASSWORD メモしたJSONから抜き出したクライアントシークレット
REGISTRY_LOGIN_SERVER <レジストリ名>.azurecr.io

Azure SQL Databaseの準備

サーバーを作成

SQLデータベースの作成ページへ移動します。
image.png
現時点ではサーバーが存在しないので、新規作成からサーバを作成します。
image.png
画像の通りに入力します。今回はSQL認証を用いるため、管理者アカウントを作成します。

データベースを作成

サーバーの作成が完了すると、データベース作成画面に戻ります。
image.png
初期設定で進めます。

Microsoft Defenderを無効化(任意)

セキュリティタブでMicrosoft Defenderがデフォルトでオンになっているため、「後で」に変更します。
image.png

照合順序の変更(任意)

システムに合わせて照合順序を変更します。
image.png

照合順序(learn.microsoft.com)

APIサーバからログインするユーザを作成する
CREATE USER [ユーザ名] WITH PASSWORD = 'パスワード';
ALTER ROLE db_datareader ADD MEMBER [ユーザ名];
ALTER ROLE db_datawriter ADD MEMBER [ユーザ名];

Azure Container Appsの準備

実際にAPIサーバを動作させる環境を作ります。
コンテナアプリを作成する画面に移動すると、次の画像のように表示されます。
image.png
現時点ではContainer Apps環境がないため、新規作成します。
image.png
名前だけ入力して初期設定のまま作成します。
環境を作成すると、元の画面に戻るので、ここでコンテナアプリ名等を入力します。今回はACRからデプロイするので、展開元はコンテナー イメージを選択します。
image.png

コンテナタブでは作成したコンテナレジストリを選択します。
image.png

イングレスの設定をし、外部からリクエストを受け取れるようにします。ターゲットポートは8080にします。
ここまで設定出来たら作成をクリックします。作成してしばらくするとデプロイに失敗した通知が来ますが、これはコンテナレジストリにイメージをプッシュしていないことによるものなので無視します。

REST APIサーバをAzureへ継続的デプロイする

APIサーバをGitHub Actionsを用いて自動デプロイできるようにします。.github/workflows配下にCD_API.ymlを作成します。下記アクションでは、productionブランチにプッシュした場合、または手動実行した場合にデプロイされます。

CD_API.yml
name: Deploy to Azure

on:
  push:
    branches:
      - production
  workflow_dispatch:

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: リポジトリをチェックアウトする。
        uses: actions/checkout@v4
        
      - name: Azure CLI経由でログインする。
        uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}
      
      - name: イメージをビルドしてAzure Container Registoryにプッシュする。
        uses: azure/docker-login@v1
        with:
          login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }}
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}
      - run: |
          docker build ./API -t ${{ secrets.REGISTRY_LOGIN_SERVER }}/<イメージ名>:${{ github.sha }}
          docker push ${{ secrets.REGISTRY_LOGIN_SERVER }}/<イメージ名>:${{ github.sha }}

      # https://learn.microsoft.com/ja-jp/azure/devops/pipelines/tasks/reference/azure-container-apps-v1?view=azure-pipelines
      - name: Azure Container Appsへデプロイする。
        uses: azure/container-apps-deploy-action@v1
        with:
          acrName: <コンテナレジストリ名>
          imageToDeploy: ${{ secrets.REGISTRY_LOGIN_SERVER }}/<イメージ名>:${{ github.sha }}
          resourceGroup: <リソースグループ名>
          containerAppName: '<コンテナアプリ名>'
          location: 'Japan East'

イメージ名は任意の名前ですが、筆者はコンテナレジストリ名と同じにしています。

image.png

デプロイ後、コンテナアプリが起動しますが、現状では動作しないのでコンテナアプリを停止します。

データベースプロジェクトをAzure SQL Databaseへパブリッシュする

Visual Studio Codeでデータベースプロジェクトを右クリックし、Publishを選択します。

image.png

既存のSQLサーバへパブリッシュします。
image.png

プロファイルを使わない方を選択します。
image.png

接続プロファイルの作成を選択します。
image.png

SQLデータベースのページからSQL認証の接続文字列を取得し、入力します。パスワードは置き換えてください。
image.png
image.png

接続に成功した後データベース名を選択します。今回はすでにデータベースを作成しているので、先ほど作成したSQLデータベース名を選択します。
image.png

Publishを選択するとデプロイが開始されます。
image.png

デプロイが完了すると画像のようにトースト通知が届きます。
image.png

APIからアクセスするためのユーザを作成する

ネットワークの設定変更

作成したサーバのページへ移動し、ネットワークの設定を画像の通り変更します。
image.png

ユーザ作成クエリを実行

作成した管理者アカウントでログインし、APIから利用するSQLユーザを作成します。Azure上からクエリを実行する場合はSQLデータベースのクエリエディタから実行できます。
image.png

CREATE USER [ユーザ名] WITH PASSWORD = 'パスワード';
ALTER ROLE db_datareader ADD MEMBER [api];
ALTER ROLE db_datawriter ADD MEMBER [api];

環境変数を設定する

APIサーバの環境変数を登録します。Azure Container Appsにはシークレットを環境変数として参照できるようにする機能があるので、それを利用します。

シークレットの登録

作成したContainer Appsのページからシークレットページへ移動し、本番環境用の値を設定します。docker-compose.ymlに記述した変数をここで登録します(変数の値は本番環境用です)。

シークレット名 設定内容
s-db-connection-str データベース接続文字列です。先ほど作成したAPI用ユーザにログインできる接続文字列を登録します。
s-jwt-audience クライアント側は未定なので、適当な値を入力します。
s-jwt-issurer イングレス画面から得たエンドポイントURLを入力します。
s-jwt-key-access JWT生成時に利用する秘密鍵を入力します。
s-pepper パスワードハッシュ化時に用いるペッパーを入力します。

エンドポイントURL

image.png

環境変数にシークレットを登録する

コンテナー画面から、新しいリビジョンを作成します。
image.png

コンテナーイメージを選択すると、画面右側で環境変数を設定できます。
image.png

リビジョンを作成後、サーバを開始すると動作するようになります。

以上で最低限の構築が完了しました。

おわりに

この記事では、C#を利用したREST APIサーバーの構築から、Azureへのデプロイまでの詳細な手順を解説しました。
C#はいまだにWindows or Unity専用言語のような偏見を持たれている気がしますが、実際はDocker上でも動作したり、Visual Studio Codeでも便利に開発できたりでWindows環境以外でも戦える言語ということがわかると思います。普段C#を書かない人でも興味を持った方は是非使ってみてください。きっと期待に応えてくれると思います。

謝辞

この記事を読んでいただき、ありがとうございます。
もし間違いや改善点が見つかった場合は、ぜひコメントをいただけると幸いです。

7
1
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
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?