Dapper×Clean Architectureで実装するパスワードハッシュ完全ガイド|BCrypt・PBKDF2とASP.NET Core Identityの最適配置

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 側は検証を重複実装(サーバー側は最終防衛)。
  • セッション失効:再ハッシュ後のトークン再発行や、重要属性変更時の全セッション無効化。

よくある誤りと回避策

  1. Repository でハッシュ:DB アクセスと暗号が結合。差し替え・テスト・再ハッシュが困難。
  2. ソルトの再利用:ユーザーごとに一意なソルトを必須(方式に応じて長さ調整)。
  3. クライアント側ハッシュのみ:TLS 前提であっても、サーバー側で強ハッシュを必ず実施。
  4. 比較で ==:恒等時間比較ではない。必ずライブラリの Verify を使用。
  5. ハッシュのログ出力:一切禁止。監査はイベント 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&lt;IPasswordHasher, BCryptPasswordHasher&gt;();
services.AddScoped&lt;IUserRepository, DapperUserRepository&gt;();
// CQRS
services.AddMediatR(cfg =&gt; cfg.RegisterServicesFromAssembly(typeof(RegisterUserHandler).Assembly));
// 設定
services.Configure&lt;PasswordHashingOptions&gt;(cfg =&gt; cfg.BCryptCost = 12);

参考の設計テンプレ(配置の型)

対象置き場所備考
Password の強度検査Domain(値オブジェクト)英数字・長さ・リスト禁止(辞書攻撃対策はアプリ層でブラックリストも可)
ハッシュ化/検証/再ハッシュ判定Application(認証サービス)+ Infrastructure(実装)IPasswordHasher で抽象化
永続化(保存/取得)Infrastructure(Dapper Repository)SQL パラメータ化を徹底
トークン発行Application(ITokenIssuer)失効・ローテーションと監査をセットで

最後に:SRP を破らない「サービス層の使い方」

「サービス層に書くと SRP 違反?」という疑念は、“サービス層を機能別に分割”すれば解消します。認証専用サービス(AuthService)は、暗号・トークン・再ハッシュ判定というひとつの責務を担います。ビジネスユースケース(受注、請求、在庫など)のサービスとは別クラス/別名前空間に分離し、実装は インターフェース注入で疎結合化。これが Clean Architecture と Dapper の相性を最大化するポイントです。


この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次