EF から Dapper に移行すると、認証まわりの「いつ・どこでパスワードをハッシュするか」が一気に露出します。本記事では Clean Architecture+Repository+CQRS 前提で、ハッシュ処理の最適配置と実装指針(BCrypt/PBKDF2、Identity 併用時のカスタムストア)を、テーブルとコードで具体化します。
Dapper を用いた登録/ログイン時のパスワードハッシュ処理の配置場所
前提と結論の先出し
結論から言うと、ハッシュ処理は「認証アプリケーションサービス層」に集約し、Repository はハッシュ済みデータを永続化するだけに留めるのが、Clean Architecture の責務分離(SRP)に最も適合します。ASP.NET Core Identity を使う場合は、ハッシュは Identity(Manager 層)に委譲し、データアクセスのみ Dapper のカスタムストアに置き換えます。
設計オプションの比較(結論表)
| 方針 | 内容 | メリット | 留意点 |
|---|---|---|---|
| A. 自前実装(推奨) | 認証アプリケーションサービスのみが「ハッシュ化」「検証」を知る。Repository は ハッシュ済み値+ソルト等のメタ情報の保存・取得に徹する。 | 層の責務が明確/アルゴリズム差し替えが容易/ユニットテストしやすい | サービスを「認証専用」に分離して SRP を堅持。ハッシュは IPasswordHasher 経由で抽象化。 |
| B. ASP.NET Core Identity を利用 | パスワードハッシュは Identity(PBKDF2 など)に委譲。EF 依存部分を Dapper のカスタムストア(UserStore 等)で置換。 | 方式更新やロックアウト等をフレームワークが包括。Cookie/Token 発行と整合。 | Identity のモデル・ライフサイクルに合わせた設計が必要。独自ドメインとの橋渡しを設計。 |
なぜ Repository にハッシュを入れないのか
- 関心の分離:Repository は永続化の抽象化に限定(I/O の詳細を隠蔽)。暗号領域(コスト調整・再ハッシュ判断)はアプリケーションポリシー。
- テスト容易性:ハッシュを抽象化すれば、検証時に高速スタブを注入できる。Repository に混ぜると DB テストが不可避。
- 差し替え可能性:BCrypt→PBKDF2 の移行やパラメータ変更を、永続化層に触れずに行える。
- セキュリティ運用:rehash-needed の判定や段階的再ハッシュ(ログイン時に更新)をサービス側で一元管理。
推奨アーキテクチャ(A案:自前実装)
レイヤ別の責務配分
| 層 | 責務 | このトピックに関する役割 |
|---|---|---|
| Presentation(API/Controller) | 入力バリデーション、ユースケース起動 | 登録・ログイン API からコマンドを送る |
| Application(UseCase/CQRS) | ユースケースオーケストレーション | ハッシュ化・検証・再ハッシュ判定を行う。Repository を呼ぶ。 |
| Domain | ビジネスルール(不変条件) | パスワード文字列の規約(長さ・構成)や資格情報の状態遷移(初期化・失効)等。アルゴリズムは知らない。 |
| Infrastructure(Dapper) | DB アクセス実装・暗号サービス実装 | IUserRepository の Dapper 実装/IPasswordHasher の具体実装(BCrypt など) |
データモデルの例
BCrypt はハッシュ文字列にソルトとコストを内包しますが、将来のアルゴリズム差し替えや統計監視のため、メタ情報を分離して持つ構成が扱いやすいです。
CREATE TABLE users (
id BIGINT IDENTITY PRIMARY KEY,
email NVARCHAR(256) NOT NULL UNIQUE,
password_hash NVARCHAR(512) NOT NULL,
password_salt VARBINARY(32) NULL, -- BCrypt では null 可。PBKDF2/Argon2 では使用
algo_id TINYINT NOT NULL, -- 1=BCrypt, 2=PBKDF2, 3=Argon2 等
work_factor INT NULL, -- 例: BCrypt cost、PBKDF2 の iteration
updated_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);
CREATE INDEX IX_users_email ON users(email);
アプリケーション契約(インターフェース)
public enum PasswordAlgorithm { BCrypt = 1, PBKDF2 = 2, Argon2 = 3 }
public record PasswordHash(
string Hash,
byte[]? Salt,
PasswordAlgorithm Algorithm,
int WorkFactor);
public interface IPasswordHasher
{
PasswordHash Hash(string plain);
bool Verify(string plain, PasswordHash stored);
bool NeedsRehash(PasswordHash stored); // コストやポリシー変更時に true
}
BCrypt 実装の例(Infrastructure)
BCrypt 実装はスレッドセーフなライブラリを利用し、コスト(work_factor)はコンフィグで調整できるようにします。pepper を導入する場合はサーバー側の安全な保管(KeyVault 等)を前提に、平文に連結してからハッシュします。
public sealed class BCryptPasswordHasher : IPasswordHasher
{
private readonly int _cost; // 例: 12
private readonly string? _pepper;
public BCryptPasswordHasher(IOptions<PasswordHashingOptions> opt)
{
_cost = opt.Value.BCryptCost;
_pepper = opt.Value.Pepper; // null 可
}
public PasswordHash Hash(string plain)
{
var input = _pepper is null ? plain : plain + _pepper;
var hash = BCrypt.Net.BCrypt.HashPassword(input, workFactor: _cost);
return new PasswordHash(hash, null, PasswordAlgorithm.BCrypt, _cost);
}
public bool Verify(string plain, PasswordHash stored)
{
var input = _pepper is null ? plain : plain + _pepper;
return BCrypt.Net.BCrypt.Verify(input, stored.Hash);
}
public bool NeedsRehash(PasswordHash stored)
{
// コストが上がったら再ハッシュ
return stored.Algorithm == PasswordAlgorithm.BCrypt
&& stored.WorkFactor < _cost;
}
}
public sealed class PasswordHashingOptions
{
public int BCryptCost { get; init; } = 12;
public string? Pepper { get; init; }
}
Repository(Dapper)とドメインの橋渡し
public interface IUserRepository
{
Task<User?> FindByEmailAsync(string email, CancellationToken ct);
Task<long> CreateAsync(User user, CancellationToken ct);
Task UpdatePasswordAsync(long userId, PasswordHash hash, CancellationToken ct);
}
public sealed class DapperUserRepository : IUserRepository
{
private readonly IDbConnection _conn;
public DapperUserRepository(IDbConnection conn) => _conn = conn;
public async Task<User?> FindByEmailAsync(string email, CancellationToken ct)
{
const string sql = @"
SELECT TOP 1 id, email, password_hash, password_salt, algo_id, work_factor
FROM users WHERE email = @email";
var row = await _conn.QuerySingleOrDefaultAsync(sql, new { email });
if (row is null) return null;
var hash = new PasswordHash(
row.password_hash,
(byte[]?)row.password_salt,
(PasswordAlgorithm)row.algo_id,
(int)row.work_factor);
return User.Rehydrate(row.id, row.email, hash); // ドメイン再構築
}
public async Task<long> CreateAsync(User user, CancellationToken ct)
{
const string sql = @"
INSERT INTO users (email, password_hash, password_salt, algo_id, work_factor)
VALUES (@Email, @Hash, @Salt, @Algo, @Work);
SELECT CAST(SCOPE_IDENTITY() as bigint);";
return await _conn.ExecuteScalarAsync<long>(sql, new {
Email = user.Email,
Hash = user.Password.Hash,
Salt = user.Password.Salt,
Algo = (int)user.Password.Algorithm,
Work = user.Password.WorkFactor
});
}
public async Task UpdatePasswordAsync(long userId, PasswordHash hash, CancellationToken ct)
{
const string sql = @"
UPDATE users SET
password_hash = @Hash,
password_salt = @Salt,
algo_id = @Algo,
work_factor = @Work,
updated_at = SYSUTCDATETIME()
WHERE id = @Id";
await _conn.ExecuteAsync(sql, new {
Id = userId,
Hash = hash.Hash,
Salt = hash.Salt,
Algo = (int)hash.Algorithm,
Work = hash.WorkFactor
});
}
}
CQRS:登録・ログインのフロー(アプリケーション層)
// Command
public sealed record RegisterUserCommand(string Email, string PlainPassword);
public sealed record LoginCommand(string Email, string PlainPassword);
// Handler
public sealed class RegisterUserHandler : IRequestHandler
{
private readonly IUserRepository _repo;
private readonly IPasswordHasher _hasher;
public RegisterUserHandler(IUserRepository repo, IPasswordHasher hasher)
=> (_repo, _hasher) = (repo, hasher);
public async Task<long> Handle(RegisterUserCommand cmd, CancellationToken ct)
{
// 1) ドメイン側で最低限の規約をチェック(長さ/文字種など)
var password = Password.Create(cmd.PlainPassword); // ドメインのバリデーション
// 2) ハッシュ化はアプリケーションサービスの責務
var hash = _hasher.Hash(password.Value);
// 3) 集合根(User)を生成し、Repository に永続化
var user = User.Create(cmd.Email, hash);
return await _repo.CreateAsync(user, ct);
}
}
public sealed class LoginHandler : IRequestHandler
{
private readonly IUserRepository _repo;
private readonly IPasswordHasher _hasher;
private readonly ITokenIssuer _token; // JWT 等
public LoginHandler(IUserRepository repo, IPasswordHasher hasher, ITokenIssuer token)
=> (_repo, _hasher, _token) = (repo, hasher, token);
public async Task<AuthResult> Handle(LoginCommand cmd, CancellationToken ct)
{
var user = await _repo.FindByEmailAsync(cmd.Email, ct);
if (user is null) return AuthResult.Fail();
if (!_hasher.Verify(cmd.PlainPassword, user.Password))
return AuthResult.Fail();
// コスト更新・方式変更時の段階的再ハッシュ(ログイン時アップグレード)
if (_hasher.NeedsRehash(user.Password))
{
var newHash = _hasher.Hash(cmd.PlainPassword);
await _repo.UpdatePasswordAsync(user.Id, newHash, ct);
}
var token = _token.Issue(user);
return AuthResult.Success(token);
}
}
API コントローラ(プレゼンテーション)
[ApiController]
[Route("api/auth")]
public sealed class AuthController : ControllerBase
{
private readonly IMediator _mediator;
public AuthController(IMediator mediator) => _mediator = mediator;
[HttpPost("register")]
public async Task<IActionResult> Register(RegisterUserCommand cmd, CancellationToken ct)
{
var id = await _mediator.Send(cmd, ct);
return CreatedAtAction(nameof(Register), new { id }, null);
}
[HttpPost("login")]
public async Task<IActionResult> Login(LoginCommand cmd, CancellationToken ct)
{
var result = await _mediator.Send(cmd, ct);
return result.Succeeded ? Ok(new { token = result.Token }) : Unauthorized();
}
}
DI 設定例
builder.Services.Configure<PasswordHashingOptions>(builder.Configuration.GetSection("Password"));
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
// + MediatR, IDbConnection, etc.
セキュリティ実務のチェックリスト
| 項目 | 要点 | 実装ヒント |
|---|---|---|
| ソルト | 必須(BCrypt は内部に含有)。PBKDF2/Argon2 はランダム 16–32B を推奨。 | CSPRNG(RandomNumberGenerator.GetBytes)で生成。ユーザー毎に一意。 |
| ストレッチング | BCrypt の cost、PBKDF2 の iteration、Argon2 のメモリ/時間/並列度。 | CPU/スループットの実測で決定。将来の上方調整を NeedsRehash で誘発。 |
| 比較の恒等時間化 | タイミング攻撃を避ける。 | ライブラリの Verify を利用(自前比較は不可)。 |
| pepper(任意) | サーバー側秘密鍵。流出時の被害抑制。 | KeyVault/HSM で保護し、平文連結してハッシュ。 |
| ロックアウト | ブルートフォース対策。 | 試行回数とクールダウンを Identity かアプリで制御。 |
| 監査 | パスワード更新・再ハッシュ・失敗回数。 | イベントログに行動証跡を残す(PII に注意)。 |
代替案(B案):ASP.NET Core Identity を Dapper で使う
パスワードハッシュは Identity の PasswordHasher(PBKDF2 等)に委譲し、Dapper で IUserStore<TUser> と IUserPasswordStore<TUser> を実装します。既存のクッキー発行・ロックアウト・2FA などを活かしつつ、EF 依存を外せます。
カスタム UserStore の骨格
public sealed class DapperUserStore :
IUserStore<AppUser>,
IUserPasswordStore<AppUser>
{
private readonly IDbConnection _conn;
public DapperUserStore(IDbConnection conn) => _conn = conn;
public Task<string> GetUserIdAsync(AppUser user, CancellationToken ct) => Task.FromResult(user.Id.ToString());
public Task<string?> GetUserNameAsync(AppUser user, CancellationToken ct) => Task.FromResult(user.Email);
public async Task<IdentityResult> CreateAsync(AppUser user, CancellationToken ct)
{
const string sql = @"INSERT INTO users (email, password_hash) VALUES (@Email, @Hash);";
await _conn.ExecuteAsync(sql, new { Email = user.Email, Hash = user.PasswordHash });
return IdentityResult.Success;
}
public Task SetPasswordHashAsync(AppUser user, string? passwordHash, CancellationToken ct)
{ user.PasswordHash = passwordHash; return Task.CompletedTask; }
public Task<string?> GetPasswordHashAsync(AppUser user, CancellationToken ct)
{ return Task.FromResult(user.PasswordHash); }
public Task<bool> HasPasswordAsync(AppUser user, CancellationToken ct)
{ return Task.FromResult(!string.IsNullOrEmpty(user.PasswordHash)); }
// ... FindByNameAsync, UpdateAsync など他メンバーも Dapper で実装
}
DI と認証設定
builder.Services
.AddIdentityCore<AppUser>(options => {
options.Password.RequiredLength = 10;
options.Lockout.MaxFailedAccessAttempts = 5;
})
.AddSignInManager()
.AddUserStore<DapperUserStore>();
builder.Services.AddAuthentication(/* Cookie or JwtBearer */);
この構成では ハッシュ戦略の更新や検証は Identity が担当するため、アプリ側はストア実装(Dapper)に集中できます。既存のロックアウト・2FA・パスワードポリシーを「設定で」適用できるのが大きな利点です。
SRP を守るための具体策
- 認証専用サービス(
AuthenticationService)をユースケース層に分離し、ハッシュ・検証・トークン発行・再ハッシュ判定を一手に引き受ける。 - 暗号実装(BCrypt/PBKDF2)は インターフェースで抽象化し、テストでは
FakePasswordHasherを注入。 - Repository は永続化のみ。ハッシュ文字列のフォーマット(例:
$2a$12$...)は一切解釈しない。
アルゴリズム選定の実務
| 方式 | 特徴 | 推奨設定の目安 | 備考 |
|---|---|---|---|
| BCrypt | 歴史が長く実績あり。コスト(work factor)で強度を上げられる。 | cost 10–14(CPU とログインレイテンシの実測で決定) | ソルトは内部に含有。文字列フォーマットで再ハッシュ判断が容易。 |
| PBKDF2 | .NET 標準の実装が豊富。ハードウェア互換性高い。 | 100k–600k 回以上(環境により調整) | ソルトは別保管が一般的。メタデータと紐付けて管理。 |
| Argon2 | メモリハード。GPU 耐性が高い。 | メモリ 64–256MB、時間 2–4、並列度 CPU 論理コアに応じて | .NET ではライブラリ採用と検証コストを見積もる。 |
移行(EF → Dapper)でつまずきやすい点
- 既存ハッシュの扱い:Identity の旧形式(例:
Versionedな PBKDF2)のまま読み取れるよう、複数方式の Verify をサポート。ログイン時に新方式へ再ハッシュ。 - トランザクション境界:登録時は ユーザー作成+監査ログ を同一トランザクションに。Dapper では
IDbTransactionをハンドラに伝播。 - ログ出力:平文パスワードやハッシュをログしない。失敗理由は曖昧(ユーザー体験と安全性のバランス)。
- 並行試行:メール重複は DB のユニーク制約で担保し、アプリ側は一般的な競合例外を整形。
テスト戦略
public sealed class FakePasswordHasher : IPasswordHasher
{
public PasswordHash Hash(string plain) => new("HASH:" + plain, null, PasswordAlgorithm.BCrypt, 0);
public bool Verify(string plain, PasswordHash stored) => stored.Hash == "HASH:" + plain;
public bool NeedsRehash(PasswordHash stored) => false;
}
// RegisterUserHandler のユニットテスト
[Fact]
public async Task Register_Creates_User_With_Hashed_Password()
{
var repo = new InMemoryUserRepository();
var hasher = new FakePasswordHasher();
var handler = new RegisterUserHandler(repo, hasher);
var id = await handler.Handle(new RegisterUserCommand("[email protected]", "P@ssw0rd!"), CancellationToken.None);
var saved = await repo.FindByEmailAsync("[email protected]", CancellationToken.None);
Assert.StartsWith("HASH:", saved!.Password.Hash);
}
API 設計上の具体ポイント
- エラーレスポンス統一:
401と400を使い分け、ボディは{ "code": "INVALID_CREDENTIALS" }のように機械可読。 - レート制御:匿名ログイン API に IP 単位のレートリミット。
- パスワードポリシー:UI 側は検証を重複実装(サーバー側は最終防衛)。
- セッション失効:再ハッシュ後のトークン再発行や、重要属性変更時の全セッション無効化。
よくある誤りと回避策
- Repository でハッシュ:DB アクセスと暗号が結合。差し替え・テスト・再ハッシュが困難。
- ソルトの再利用:ユーザーごとに一意なソルトを必須(方式に応じて長さ調整)。
- クライアント側ハッシュのみ:TLS 前提であっても、サーバー側で強ハッシュを必ず実施。
- 比較で
==:恒等時間比較ではない。必ずライブラリの Verify を使用。 - ハッシュのログ出力:一切禁止。監査はイベント ID やハッシュの ダイジェストで。
サンプル:PBKDF2 実装(比較用)
public sealed class Pbkdf2PasswordHasher : IPasswordHasher
{
private readonly int _iterations;
private readonly int _saltSize;
private readonly int _keySize;
private readonly HashAlgorithmName _algo = HashAlgorithmName.SHA256;
public Pbkdf2PasswordHasher(IOptions<PasswordHashingOptions> opt)
{
_iterations = opt.Value.Pbkdf2Iterations; // 例: 310000
_saltSize = opt.Value.SaltSize; // 例: 16
_keySize = opt.Value.KeySize; // 例: 32
}
public PasswordHash Hash(string plain)
{
var salt = RandomNumberGenerator.GetBytes(_saltSize);
var key = Rfc2898DeriveBytes.Pbkdf2(plain, salt, _iterations, _algo, _keySize);
var hash = Convert.ToBase64String(key);
return new PasswordHash(hash, salt, PasswordAlgorithm.PBKDF2, _iterations);
}
public bool Verify(string plain, PasswordHash stored)
{
if (stored.Salt is null) return false;
var key = Rfc2898DeriveBytes.Pbkdf2(plain, stored.Salt, stored.WorkFactor, _algo, _keySize);
var hash = Convert.ToBase64String(key);
return CryptographicOperations.FixedTimeEquals(
Convert.FromBase64String(stored.Hash),
Convert.FromBase64String(hash));
}
public bool NeedsRehash(PasswordHash stored) => stored.WorkFactor < _iterations;
}
運用時の再ハッシュ戦略(ゼロダウンタイム)
- 二重検証:旧方式で検証→成功なら新方式で再ハッシュ・保存。
- マイグレーションフラグ:ユーザーごとに algo_id と work_factor を保持し、
NeedsRehash判定を簡素化。 - バックグラウンド再ハッシュ:大量ユーザーはログイン時のみだと年月を要するため、認証不要領域のデータは別途キュー処理を検討(ただし平文不可のため別設計が必要)。
パフォーマンスと SLO の折り合い
ログイン API の P95/P99 レイテンシを計測し、BCrypt の cost や PBKDF2 の iteration を「ユーザー体験が許す範囲の上限」に調整します。サーバー台数を増やすよりも、まずはコストを適正化し、ついでにスレッドプール飽和・同期 I/O の有無(Dapper の接続プーリング含む)を点検します。
まとめ:設計判断の指針
- 最小構成で柔軟性を重視:A案(自前実装)。ハッシュは認証アプリケーションサービス、Repository は永続化専用。
- フレームワークの包括機能を活用:B案(Identity+Dapper ストア)。ハッシュは Identity、ストアだけ差し替え。
- いずれも「ハッシュ処理はドメインロジックではなく、認証サービスの専門責務」という原則を守ることで、SRP を満たしつつ将来のアルゴリズム変更に強い構成になる。
チェック用スニペット(最小動作セット)
// 最小 DI
services.AddScoped<IPasswordHasher, BCryptPasswordHasher>();
services.AddScoped<IUserRepository, DapperUserRepository>();
// CQRS
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(RegisterUserHandler).Assembly));
// 設定
services.Configure<PasswordHashingOptions>(cfg => cfg.BCryptCost = 12);
参考の設計テンプレ(配置の型)
| 対象 | 置き場所 | 備考 |
|---|---|---|
| Password の強度検査 | Domain(値オブジェクト) | 英数字・長さ・リスト禁止(辞書攻撃対策はアプリ層でブラックリストも可) |
| ハッシュ化/検証/再ハッシュ判定 | Application(認証サービス)+ Infrastructure(実装) | IPasswordHasher で抽象化 |
| 永続化(保存/取得) | Infrastructure(Dapper Repository) | SQL パラメータ化を徹底 |
| トークン発行 | Application(ITokenIssuer) | 失効・ローテーションと監査をセットで |
最後に:SRP を破らない「サービス層の使い方」
「サービス層に書くと SRP 違反?」という疑念は、“サービス層を機能別に分割”すれば解消します。認証専用サービス(AuthService)は、暗号・トークン・再ハッシュ判定というひとつの責務を担います。ビジネスユースケース(受注、請求、在庫など)のサービスとは別クラス/別名前空間に分離し、実装は インターフェース注入で疎結合化。これが Clean Architecture と Dapper の相性を最大化するポイントです。

コメント