はじめに
この記事では、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に拡張機能をインストールする
名前: 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のリンク
プロジェクトファイルを作成する
- リポジトリルートに
DB
ディレクトリを作ります - 画面左側のDatabase Projectsをクリックします
- Create newをクリックし、Azure SQL Databaseを選択します
- プロジェクト名を入力します
- プロジェクトファイルの場所を指定します。
リポジトリルート>DB
ディレクトリを指定してください - SDK スタイルのプロジェクトファイルを使うかを聞かれるので、
yes
を選択します
作成すると、この画面になります(プロジェクト名をbasic_dbとしました)。
この画面でbasic_db
を右クリックし、テーブル定義のファイルなどを追加・記述していく開発フローとなります。
また、エクスプローラーの画面を見ると、このような結果となっています。
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
をプロジェクト名ディレクトリに作成します(dockerfile
、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.sh
をDockerfile
と同じ階層に作成します。
大まかな処理の流れを示します。
- 環境変数が登録されているかの確認を行います
- SQL Serverを起動します
- SQL Serverの起動を制限時間付きで待ちます
- 起動完了後、
.sqlproj
をビルド・パブリッシュします
#!/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関数はバックグラウンドで動かす必要があります。
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
を作成します。
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図を示します。
ユーザ情報とログイン等認証に関する情報を分けて設計しています。このように分ける理由は、認証方法は頻繁に変わるからです。
今回はパスワードとログインIDによる認証ですが、2要素認証や、ソーシャルログイン、IdPを利用した認証など様々な方法があります。これらへの対応を容易に行えるよう切り分けています。
ユーザIDはプログラム側から生成したいため、uniqueidentifier
型にしました。C#からはGuid
構造体として利用できます。
もしuser_authentications
のIDもプログラム側から生成したい場合は、同様にuniqueidentifier
型を利用できます。
ここでの命名ルール
- テーブル名は複数形
- 主キーは
id
で統一 - 外部キーは
外部テーブル名_id
この辺は個人の好みなので自由に決めていいです。
T-SQLを記述する
スキーマを定義
SQL ServerはMySQL等と異なりスキーマを持つため、スキーマの定義から行います。
まず、Database Projects
タブに移動し、プロジェクト名を右クリック->Add Item...
をクリックします。
Scriptをクリックし、ファイル名の入力を求められたらschemata
と入力します(自由に決めてOK)。
ファイルが作成されたら、そこにスキーマを定義します(スキーマ名は自由に決めてOK)。
CREATE SCHEMA [app];
GO
この流れでT-SQLを記述します。
テーブルを定義
テーブルごとにファイルを作り、tables
ディレクトリ配下に保存する構成です。
次に、ER図の通りにCREATE TABLE
します。
users
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
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
でログを確認できます)。
DBに接続して確認する
SQL Server
タブに移動し、接続の追加をクリックします。クリックするとSQL Serverへ接続するための画面が出てくるので、画像の通りに入力します。
接続に成功すると、テーブル等を見ることができるようになるので、正しく作成できているかを確認します。
以上でデータベースの準備が完了しました。
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 ..
動作内容はコメントアウトの通りです。実行すると、次の画像の通りとなります。
プロジェクトにライブラリをインストールする
Microsoft.Data.SqlClient
とDapper
、Microsoft.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
不要なファイル・実装を削除
次に、ソリューションエクスプローラーでソリューションを開きます。
WeatherForecastController.cs
、Server.http
、WeatherForecast.cs
は削除します。
また、Properties/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": "*"
の記述を削除します。
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
ビルドが通るか確認
リポジトリルートでdotnet build ./API/API.sln
を実行すると、コンパイルに成功します。プロジェクトの準備はここで一区切りつけます。
Dockerfileを準備
API用のDockerfile
はデプロイ時も利用するため、マルチステージビルドでイメージサイズを削減します。
APIディレクトリ(ソリューションファイルと同じ階層)にDockerfile
を作成し、次の通りに記述します。
.NET SDKのイメージ
ASP.NET Core Runtimeのイメージ
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
を作成し、次の通りに記述します。
#!/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
へ追記します。
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
の画面が表示されます。
APIの動作確認もここで行うので、お気に入りやブックマークですぐにアクセスできるようにすると便利です。
Visual Studio Codeからデバッグする方法
普段の開発では、ホットリロードだけで十分開発できますが、デバッガを利用したいときもあるのでその準備をします。まだデバッグ環境については模索中なので、もっと便利な方法があれば教えてほしいです。
Dev Containers拡張機能をインストールする
名前: Dev Containers
ID: ms-vscode-remote.remote-containers
バージョン: 0.388.0
パブリッシャー: Microsoft
リモートエクスプローラーからコンテナへアタッチする
-
docker-compose.yml
の環境変数USE_DEBUGGER
をtrue
に変更し、コンテナを再起動します
コンテナ内に拡張機能をインストールする
名前: C# Dev Kit
ID: ms-dotnettools.csdevkit
説明: Official C# extension from Microsoft
バージョン: 1.13.9
パブリッシャー: Microsoft
コンテナ外でも利用したい場合はコンテナ外でもインストールしてください。
デバッグ用の設定
まず、コンテナにアタッチしたVisual Studio Codeで、api_src
ディレクトリ配下に.vscode/launch.json
を作成します。
{
"version": "0.2.0",
"configurations": [
{
"name": "サーバをデバッグ",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/Server/Server.csproj",
}
]
}
次に、ホットリロードを有効にします。試験的機能なので、デフォルトでは無効になっています。
/root/.vscode-server/data/Machine/settings.json
を開き、次の設定を追加します。
{
"csharp.experimental.debug.hotReload": true
}
サーバを起動
実行とデバッグタブに移動し、再生ボタンをクリックするだけで起動します。
これでブレークポイントを設定し、デバッグできるようになります。
以上で開発環境の構築が完了しました。
セキュリティについて
SQLの文字列でフォーマット文字列を使う際は注意
INSERT文などでパラメータを与える際はプレースホルダを利用します。直接文字列を埋め込む場合はSQLインジェクション攻撃の注意が必要です。
var userInput = "1;SELECT * FROM 認証情報"
var query = $"SELECT id FROM 認証情報 WHERE id = {userInput}"
この例ではSELECT * FROM 認証情報
も実行されてしまいます。
パスワードはハッシュ化してからDBへ
パスワード流出時の被害を軽減するための対策法です。攻撃者は入手したパスワードを逆ハッシュ化する必要が生じるため、生のパスワードが流出するまでの時間を稼ぐ事ができます。ここでは、ハッシュ化する際にできる工夫について説明します。
複数回ハッシュ化する(ストレッチング)
パスワードのハッシュ化にかかる計算コストを大きくすることで、総当たり攻撃を困難にする目的があります。したがって、ストレッチング回数はコンピュータの性能が向上するほど多くした方が良いことになります。また、予め計算してあるハッシュ値とパスワードの対応表を元にパスワードを推測するレインボーテーブル攻撃の対策にもなります。
今回作るサーバでは約30万回ハッシュ化していますが、ASP.NETでの規定では10万回ハッシュ化しているようです。
パスワードに文字列を付加してからハッシュ化する
パスワードに文字列を付加してからハッシュ化することでレインボーテーブル攻撃の対策を強化できます。
付加する文字列は推測が困難なものにします。この文字列のことをソルトといいます。今回の実装では2種類のソルトを利用します。
ユーザ固有の情報を利用したソルト
ユーザごとに生成する文字列を利用したソルトです。今回はユーザ登録日時をソルトとして利用しますが、ユーザ登録時にソルト用のランダムな文字列を生成し、データベースへ保存する方法もあります。
データベース外に定義したソルト
データベースにソルトを保存すると、データベースが流出した場合ソルトも流出してしまいます。そこで、環境変数などデータベース外に別のソルトを用意し、これも付加して使うようにします。このようなソルトをペッパーまたはシークレットソルトと言います。パスワードが流出する場合データベース内にあるソルトも流出している可能性が高いため、この対策はした方が良いと考えています。
フローの例
ハッシュ関数の選択
ハッシュ関数にも種類があり、パスワードハッシュ化に特化したハッシュ関数があります。
- PBKDF2(Password-Based Key Derivation Function 2)
- bcrypt
- Argon2
実装例ではPBKDF2を利用したパスワードハッシュ化方法を紹介します。
パスワードの情報はクライアントに渡さない
パスワード認証では、サインイン時にユーザが入力したパスワードをハッシュ化し、データベース内のパスワードと比較することで実現できます。パスワードをクライアントへ渡す必要はありません。
アクセストークンの有効期限を短く設定する
JWTはデータベースに保存する必要なくユーザを識別、認証できるメリットがあります。しかし、サーバ側で無効化できないデメリットもあります。このデメリットを軽減するため有効期限を短くします。
有効期限を短くするとログインする頻度が多く不便になってしまう
トークンが切れる度にログインを求めると利便性の低下につながります。この問題を解決する方法には、リフレッシュトークンの発行があります。リフレッシュトークンとはアクセストークンを再発行するためのトークンです。リフレッシュトークンはアクセストークンより長い有効期限を持つ事がポイントです。アクセストークンより送信頻度が少ないため、アクセストークンの有効期限を長くする場合より、リスクが小さいです。
上図の方法ではデータベースを利用したリフレッシュトークン管理が必要になりますが、リフレッシュトークンの検証頻度はアクセストークンの検証頻度より少ないので、アクセストークンをデータベース管理するよりデータベースへアクセスする頻度は少なく済みます。
この記事で実装するAPIサーバは上図の流れでリフレッシュトークンを実装できるようにテーブル設計をしています。今回の記事では実装方法を示しませんが、図の流れ通りに実装すれば実現できます。
設計
MVCパターンとオニオンアーキテクチャを拡張した設計にしています。依存関係の図を示します。
Server(名前空間のルート)
ここはサーバのエントリポイント、環境変数ヘアクセスする処理などサーバ全体の設定を扱う役割を持ちます。特にサーバのエントリポイントではサービスの登録などサーバの機能をセットアップする役割を持ちます。
Models
ユーザ、サインインID、パスワードなどのモデルやビジネスロジックを実装します。今回はサインインIDの文字数など、ルールが存在します。これらのルールもモデル内に実装します。
UseCases
ユースケース毎にメソッドを実装し、各ユースケースのメインルーチンの役割を持ちます。リポジトリや認証処理はインターフェイスを介して利用します。
Databases
ここではモデルの永続化を行う処理を実装します。これらの処理はRepositoryと命名しています。ユースケースでトランザクションを張ることが多いですが、ユースケースのテストをしたい都合でトランザクションもここで張ります。
Databases.DBClients
クエリを実行し、実際にデータベースへ問い合わせする役割を持ちます。リポジトリからのみ呼び出されます。
メリット・デメリット
複雑な集計処理がないことを前提としている設計なので、メリット・デメリットがあります。
メリット
- 単純なクエリで実装できる
- 再利用が容易
- テーブルを参照している箇所の特定が容易
デメリット
- 問い合わせ回数が増加する
- トランザクションを張る回数が増え、デッドロックなどのリスクが高まる
- Repositoryがモデル詰め替え場所のような実装になる
実装例では偶然テーブル単位になっていますが、集約単位で実装します。
Controllers
コントローラを実装します。ハンドラメソッドはユースケース単位で実装します。
処理内容は、
- リクエストパラメータをユースケース呼び出し用に詰め替える
- ユースケースを呼び出す
- 結果を返す
程度のものとなるため、薄いレイヤとなります。「外部との窓口を作る役割」だけを持たせます。
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
ではないことを保証するための属性です。今回の場合は、userName
がnullable
ですが、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
と命名し、実装しました。実装内容はUserName
やSignInId
と同様です。
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
と命名し、実装しました。
HashedPassword
もHashFromRawPassword
メソッド経由で生成しますが、ハッシュ化処理と生パスワード、パスワードの所有者を受け取ります。
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)
を利用しますが、これを直接利用すると少し不便な点があります。
- 環境変数名は文字列で渡す必要がある
→ 同じ環境変数を複数個所で参照している場合、変数名が変わった際の変更箇所が複数生じる - 取得できない場合はnullが返ってしまう
→ 取得できない場合はサーバが動作しないはずなのでサーバを落としていい - どこで環境変数を取得しているかがわかりづらくなる
→ 調べるためにEnvironment.GetEnvironmentVariable
で検索をかけたくない - 利用している環境変数を一目で確認できない
→ 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));
}
}
今後必要になる環境変数はあらかじめ設定しました。このように実装することで、先述した課題は解決されます。
-
環境変数名は文字列で渡す必要がある
→ 列挙型を用いて参照できるようになる -
取得できない場合はnullが返ってしまう
→ 例外で落ちるようになる -
どこで環境変数を取得しているかがわかりづらくなる
→ エディタ・IDEの機能である参照ボタンをクリックするだけで確認できるようになる
-
実装したプログラムで利用している環境変数を一目で確認できない
→ 列挙型のメンバを見ればよくなる
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.Get
をEnvironment.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
へアクセスすると、次の画像の通り表示されます。
/users/register
をクリックすると、パラメータのスキーマ等が表示されます。
Try it out
をクリックすると、リクエストパラメータを編集し、実際にリクエストできます。
1のところでパラメータを編集し、2をクリックすると、3の位置に結果が表示されます。
DBを確認したい場合
SELECT
*
FROM [app].[users] users
INNER JOIN [app].[user_authentications] user_authentications
ON users.id = user_authentications.user_id
このようなクエリを適当なファイルに書き、右上の再生ボタンを押すとクエリを実行できます。
他にはA5:SQL Mk-2
等のSQLクライアントソフトウェアを利用したり、DB初期化スクリプトで利用したsqlcmd
をコンテナ内で実行したりする方法がありますが、割愛します。
認証が必要なAPIの動作確認
ユーザ登録後、サインインします。
サインインのレスポンスにあるアクセストークンをコピーします。
画面右上の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
を作成します。
アクションの実装例
プルリクエストが作られた時またはメインブランチへプッシュされた際にテストが自動で実行されます。
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
Azureの準備
注意
この方法では年間で1万円(1日あたり25~26円)ほどかかります。Azureは初回登録時に無料クレジットを配布したり、学生向けサブスクリプションも利用できるため、上手く活用します。
利用するリソース
リソース名 | 概要 |
---|---|
Azure Container Registry | ビルドしたDockerイメージを置く場所です。 |
Azure Container Apps | コンテナ環境でアプリケーションを動作させる場所です。APIサーバを動作させます。 |
Azure Conainer Apps Environments | 証明書の設定やネットワークの設定などサーバが動作する環境を設定できます。 |
SQL Server | データベースを動作する環境を設定できます。 |
Azure SQL Database | データベースを動作させる場所です。 |
リソースグループを作成する
すべてのサービスからリソース グループ
を検索し、クリックします。
作成ボタンが左側に表示されるので、クリックします。
]
クリックするとサブスクリプション、リソースグループ名、リージョンの選択をする画面が出てくるので入力します。リージョンは東日本と西日本を選ぶことができます。
最後に「確認及び作成タブ」からリソースを作成します。
Azure Container Registoryの準備
まず画像の通りにコンテナレジストリを作成します。プランは最も安い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
管理者アカウントの有効化
作成したコンテナレジストリのアクセスキーページから管理者ユーザにチェックします。コンテナアプリの作成時に必要な設定です。
GitHub側の設定
変数名 | 値 |
---|---|
AZURE_CREDENTIALS |
メモしたJSON |
REGISTRY_USERNAME |
メモしたJSONから抜き出したクライアントID |
REGISTRY_PASSWORD |
メモしたJSONから抜き出したクライアントシークレット |
REGISTRY_LOGIN_SERVER |
<レジストリ名>.azurecr.io |
Azure SQL Databaseの準備
サーバーを作成
SQLデータベースの作成ページへ移動します。
現時点ではサーバーが存在しないので、新規作成からサーバを作成します。
画像の通りに入力します。今回はSQL認証を用いるため、管理者アカウントを作成します。
データベースを作成
サーバーの作成が完了すると、データベース作成画面に戻ります。
初期設定で進めます。
Microsoft Defenderを無効化(任意)
セキュリティタブでMicrosoft Defenderがデフォルトでオンになっているため、「後で」に変更します。
照合順序の変更(任意)
CREATE USER [ユーザ名] WITH PASSWORD = 'パスワード';
ALTER ROLE db_datareader ADD MEMBER [ユーザ名];
ALTER ROLE db_datawriter ADD MEMBER [ユーザ名];
Azure Container Appsの準備
実際にAPIサーバを動作させる環境を作ります。
コンテナアプリを作成する画面に移動すると、次の画像のように表示されます。
現時点ではContainer Apps環境がないため、新規作成します。
名前だけ入力して初期設定のまま作成します。
環境を作成すると、元の画面に戻るので、ここでコンテナアプリ名等を入力します。今回はACRからデプロイするので、展開元はコンテナー イメージ
を選択します。
イングレスの設定をし、外部からリクエストを受け取れるようにします。ターゲットポートは8080
にします。
ここまで設定出来たら作成をクリックします。作成してしばらくするとデプロイに失敗した通知が来ますが、これはコンテナレジストリにイメージをプッシュしていないことによるものなので無視します。
REST APIサーバをAzureへ継続的デプロイする
APIサーバをGitHub Actionsを用いて自動デプロイできるようにします。.github/workflows
配下にCD_API.yml
を作成します。下記アクションでは、production
ブランチにプッシュした場合、または手動実行した場合にデプロイされます。
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'
イメージ名は任意の名前ですが、筆者はコンテナレジストリ名と同じにしています。
デプロイ後、コンテナアプリが起動しますが、現状では動作しないのでコンテナアプリを停止します。
データベースプロジェクトをAzure SQL Databaseへパブリッシュする
Visual Studio Codeでデータベースプロジェクトを右クリックし、Publishを選択します。
SQLデータベースのページからSQL認証の接続文字列を取得し、入力します。パスワードは置き換えてください。
接続に成功した後データベース名を選択します。今回はすでにデータベースを作成しているので、先ほど作成したSQLデータベース名を選択します。
APIからアクセスするためのユーザを作成する
ネットワークの設定変更
作成したサーバのページへ移動し、ネットワークの設定を画像の通り変更します。
ユーザ作成クエリを実行
作成した管理者アカウントでログインし、APIから利用するSQLユーザを作成します。Azure上からクエリを実行する場合はSQLデータベースのクエリエディタから実行できます。
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 |
パスワードハッシュ化時に用いるペッパーを入力します。 |
環境変数にシークレットを登録する
コンテナーイメージを選択すると、画面右側で環境変数を設定できます。
リビジョンを作成後、サーバを開始すると動作するようになります。
以上で最低限の構築が完了しました。
おわりに
この記事では、C#を利用したREST APIサーバーの構築から、Azureへのデプロイまでの詳細な手順を解説しました。
C#はいまだにWindows or Unity専用言語
のような偏見を持たれている気がしますが、実際はDocker上でも動作したり、Visual Studio Codeでも便利に開発できたりでWindows環境以外でも戦える言語ということがわかると思います。普段C#を書かない人でも興味を持った方は是非使ってみてください。きっと期待に応えてくれると思います。
謝辞
この記事を読んでいただき、ありがとうございます。
もし間違いや改善点が見つかった場合は、ぜひコメントをいただけると幸いです。