🎃

ASP.NET Core で素の認証に時間ベースの2段階認証を追加する

2022/02/07に公開約18,500字

はじめに

ASP.NET Core で2段階認証を実装する場合、ASP.NET Core Identity を利用すると最初から2段階認証が組み込まれているので最も簡単です。

https://docs.microsoft.com/ja-jp/aspnet/core/security/authentication/mfa?view=aspnetcore-6.0

http://surferonwww.info/BlogEngine/post/2021/11/06/aspnet-core-application-with-two-factor-authentication.aspx

ただ、独自の認証がすでに実装されていたりして、ASP.NET Core Identity が利用できないケースもあります。この記事では、このような場合に ASP.NET Core で2段階認証を利用する方法について説明します。

ASP.NET Core でフォーム認証を実装する

まずは、ASP.NET Core の新しいプロジェクトを作り単純な Cookie ベースの Form 認証を有効にします。
このサンプルのコード全体は下記のリポジトリを参照してください。

https://github.com/karuakun/DotNetCore2FactorSample/tree/master/src/1.Form/IdentitySample

プロジェクトの作成

dotnet コマンドを利用して webapp のテンプレートから単純な Razor Pages ベースのプロジェクトを作成します。データストアにインメモリデータベースを利用するので Microsoft.EntityFrameworkCore.InMemory パッケージを追加しています。また、ワンタイムパスワードの計算を行うために Otp.NET パッケージを追加しています。

❯ mkdir IdentitySample
❯ cd IdentitySample
❯ dotnet new webapp -o IdentitySample.WebApp
❯ dotnet new sln
❯ dotnet sln add .\IdentitySample.WebApp\IdentitySample.WebApp.csproj
❯ dotnet add .\IdentitySample.WebApp\IdentitySample.WebApp.csproj package Microsoft.EntityFrameworkCore.InMemory
❯ dotnet add .\IdentitySample.WebApp\IdentitySample.WebApp.csproj package Otp.NET

認証に利用するデータストアの定義

認証に利用するデータストアを作成します。
今回は単純にするため、User にはパスワード認証で利用する UserName と Password の他に、現在のアカウントで2段階認証を利用するかのフラグと、2段階認証で利用するシークレット情報を持つことにします。

Data.cs
namespace IdentitySample.WebApp.Data;

public class User
{
    public int Id { get; set; }
    public string UserName { get; set; } = null!;
    public string Password { get; set; } = null!;
    public bool UseTwoFactor { get; set; }
    public string? TwoFactorSecrets { get; set; }
}

public class IdentityDbContext : DbContext
{
    public DbSet<User> Users => Set<User>();

    public IdentityDbContext(DbContextOptions<MyDbContext> options) : base(options)
    { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<User>().HasData(
            new User { Id = 1, UserName = "TestUser", Password = "Password" }
        );
    }
}

データストアの操作を集約するリポジトリクラスも追加しておきましょう。

UserRepository.cs
namespace IdentitySample.WebApp.Services;

public interface IUserRepository
{
    Task<User?> GetUserAsync(string? userName, CancellationToken cancellationToken = default);
}

public class UserRepository : IUserRepository
{
    private readonly IdentityDbContext _identityDbContext;
    public UserRepository(IdentityDbContext identityDbContext)
    {
        _identityDbContext = identityDbContext;
    }
    public async Task<User?> GetUserAsync(string? userName, CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrEmpty(userName))
            return null;
        return await _identityDbContext
            .Users
            .FirstOrDefaultAsync(u => u.UserName == userName, cancellationToken: cancellationToken);
    }
}

アプリケーション構成でデータコンテキストと認証を追加する

Program.cs では、前の項で追加したデータコンテキストと、Cookie 認証に必要な設定をパイプラインに追加します。

Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.ExpireTimeSpan = TimeSpan.FromMinutes(20);
        options.SlidingExpiration = true;
        options.LoginPath = "/Login";
    });

builder.Services.AddDbContext<IdentityDbContext>(options =>
{
    options.UseInMemoryDatabase("db");
});
builder.Services.AddScoped<IUserRepository, UserRepository>();

var app = builder.Build();
if (app.Environment.IsDevelopment())
{
    using var scope = app.Services.CreateScope();
    var provider = scope.ServiceProvider;
    await using var context = provider.GetRequiredService<IdentityDbContext>();
    await context.Database.EnsureCreatedAsync();
}
else
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages();

app.Run();

認証の設定では、最後にアクセスしてから 20 分有効な認証 Cookie を追加し、Cookie を持っていない場合は /Login にリダイレクトするように設定します。

Program.cs
builder.Services
     .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
     .AddCookie(options =>
     {
         options.ExpireTimeSpan = TimeSpan.FromMinutes(20);
         options.SlidingExpiration = true;
         options.LoginPath = "/Login";
     });

認証を有効にするため、パイプラインの設定で UseAuthorization の前に UseAuthentication を追加するのを忘れないようにしましょう。

Program.cs
app.UseAuthentication();
app.UseAuthorization();

データストアはインメモリデータベースとして登録します。
また、忘れずにリポジトリクラスを DI コンテナに登録しておきましょう。

Program.cs
builder.Services.AddDbContext<MyDbContext>(options =>
{
    options.UseInMemoryDatabase("db");
});
builder.Services.AddScoped<IUserRepository, UserRepository>();

ログインページの追加

ユーザー名とパスワードを元にユーザーを検証する機能を追加します。
コマンドラインからログインページとページモデルを追加します。

❯ dotnet new page --name Login --namespace IdentitySample.WebApp.Pages -o .\IdentitySample.WebApp\Pages

Login.cshtml.cs では Post 要求で受け取った UserName と Password を元にデータベースを照合し、問題なければログインさせます。

:::note warn
実際に利用する場合はパスワードはハッシュするなどして不可逆な状態にする必要がありますが、今回は説明のため平文で保存しています
:::

Login.cshtml.cs
namespace IdentitySample.WebApp.Pages;

public class LoginModel : PageModel
{
    [BindProperty] public string? UserName { get; set; }
    [BindProperty] public string? Password { get; set; }
    public string? Message { get; set; }

    private readonly MyDbContext _dbContext;

    public LoginModel(MyDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public void OnGet() { }

    public async Task OnPostAsync(string returnUrl)
    {
        var user = await _userRepository.GetUserAsync(UserName);
        if (user == null || user.Password != Password)
        {
            Message = "ログインに失敗しました。";
            return;
        }

        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, user.UserName)
        };
        var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
        await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity));

        LocalRedirect(returnUrl);
    }
}

Login.cshtml は UserName と Password を Post するだけの単純なページです。

@page 
@model IdentitySample.WebApp.Pages.LoginModel 
@{ 
}
<div class="text-center">
  <form method="post">
    <div>
      <label asp-for="UserName"></label>
      <input type="text" asp-for="UserName" />
    </div>
    <div>
      <label asp-for="Password"></label>
      <input type="password" asp-for="Password" />
    </div>
    <div>
      <input type="submit" value="ログイン" />
    </div>
  </form>
</div>

今回はテンプレートに含まれる Privacy.cshtml を認証が必要なページとして定義して動作確認します。Privacy クラスに [Authorize] をクラス属性として追加します。

[Authorize]
public class PrivacyModel : PageModel
{
    // ... 略 ...
}

Cookie をベースにした単純なフォーム認証ができました。
認証を受けないまま /Privacy にアクセスすると、Login ページにリダイレクトされることが確認できます。

時間ベースの2段階認証の追加

時間ベースのワンタイムパスワード(TOTP)は2段階認証の代表的な実現方法の一つです。同じくワンタイムパスワードの生成の仕組みであるハッシュベースのワンタイムパスワード(HOTP)を利用し、クライアントとサーバーで共有している秘密鍵とそれぞれの現在時刻を使ってワンタイムパスワードを算出します。

このサンプルのコード全体は下記のリポジトリを参照してください。

https://github.com/karuakun/DotNetCore2FactorSample/tree/master/src/2.TwoFactor/IdentitySample/IdentitySample.WebApp

TOTP と HOTP の詳細については下記の RFC を参照してください。

C# での TOTP や HOTP の実装については次のサイトがとても分かりやすかったです。

https://csharp.keicode.com/topics/totp.php

TOTP はいくつか問題もありますが導入が簡単なのとパスワードでの認証に比べ安全性を維持できることから多くのサイトで導入されています。
.NET で TOTP や HOTP を計算するライブラリはいくつかありますが、今回は Otp.NET を利用します。

リポジトリに更新メソッドを追加

リポジトリに登録ページで利用するための項目更新用のメソッドを追加しておきます。

    public async Task SetUseTwoFactorAsync(string? userName, bool enable, CancellationToken cancellationToken = default)
    {
        var user = await GetUserAsync(userName, cancellationToken);
        if (user == null)
            throw new InvalidOperationException();

        user.UseTwoFactor = enable;
        await _identityDbContext.SaveChangesAsync(cancellationToken);
    }

    public async Task SetTwoFactorTokenAsync(string userName, string twoFactorSecrets, CancellationToken cancellationToken = default)
    {
        var user = await GetUserAsync(userName, cancellationToken);
        if (user == null)
            throw new InvalidOperationException();

        user.TwoFactorSecrets = twoFactorSecrets;
        await _identityDbContext.SaveChangesAsync(cancellationToken);
    }

2段階認証登録ページの追加

TOTP に利用するためのシークレットキーをデータベースに保存し、TOTP を Microsoft Authenticator や Google Authenticator に登録するためのページを作成します。

❯ dotnet new page --name EnableTwoFactor --namespace IdentitySample.WebApp.Pages -o .\IdentitySample.WebApp\Pages

このページでは画面上に表示したユーザーごとのシークレットキーを Microsoft Authenticator などの MFA デバイスに登録してもらい、登録したデバイスから発行されたコードが正しいかを検証し、問題なければ登録します。

namespace IdentitySample.WebApp.Pages;

[Authorize]
public class EnableTwoFactorModel : PageModel
{
    public string? AuthenticatorUri { get; private set; }
    public string? Secrets { get; private set; }
    [BindProperty] public string? VerifyCode { get; set; }
    public string? Message { get; set; }

    private readonly IUserRepository _userRepository;
    private readonly UrlEncoder _urlEncoder;

    public EnableTwoFactorModel(IUserRepository userRepository, UrlEncoder urlEncoder)
    {
        _userRepository = userRepository;
        _urlEncoder = urlEncoder;
    }

    public async Task<IActionResult> OnGet()
    {
        var user = await GetCurrentUserAsync();
        if (user == null)
            return NotFound();
        await SetPageModelFromCurrentUserAsync(user);

        return Page();
    }

    public async Task<IActionResult> OnPostAsync()
    {
        var user = await GetCurrentUserAsync();
        if (user == null)
            return NotFound();
        await SetPageModelFromCurrentUserAsync(user);

        var totp = new Totp(Base32Encoding.ToBytes(Secrets), totpSize: 6);
        if (!totp.VerifyTotp(DateTime.UtcNow, VerifyCode, out _, VerificationWindow.RfcSpecifiedNetworkDelay))
        {
            Message = "検証に失敗しました。もう一度入力してください。";
            return Page();
        }

        Message = "検証に成功しました。";
        await _userRepository.SetUseTwoFactorAsync(user.UserName, true);
        return Page();
    }

    private async Task<User?> GetCurrentUserAsync()
    {
        var userName = User.FindFirstValue(ClaimTypes.NameIdentifier);
        if (string.IsNullOrEmpty(userName))
            return null;
        return await _userRepository.GetUserAsync(userName);
    }
    private async Task SetPageModelFromCurrentUserAsync(User user)
    {
        var secrets = await GetOrGenerateTwoFactorSecretsAsync(user);
        AuthenticatorUri = GenerateQrCodeUri("test-app", user.UserName, secrets);
        Secrets = secrets;
    }
    private async Task<string> GetOrGenerateTwoFactorSecretsAsync(User user)
    {
        if (!string.IsNullOrEmpty(user.TwoFactorSecrets))
            return user.TwoFactorSecrets;

        var key = KeyGeneration.GenerateRandomKey();
        var secrets = Base32Encoding.ToString(key);

        user.TwoFactorSecrets = secrets;
        await _userRepository.SetTwoFactorTokenAsync(user.UserName, user.TwoFactorSecrets);
        return user.TwoFactorSecrets;
    }

    private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
    private string GenerateQrCodeUri(string appName, string userName, string unformattedSecrets)
    {
        return string.Format(
            AuthenticatorUriFormat,
            _urlEncoder.Encode(appName),
            _urlEncoder.Encode(userName),
            unformattedSecrets);
    }
}

ページが要求されたら現在の認証情報からユーザーを特定し、そのユーザーに TOTP のためのシークレットキーが登録されていない場合は生成して登録します。TOTP 標準である SHA1 でワンタイムパスワードを計算する場合、鍵の長さは 20 なので GenerateRandomKey メソッドは引数なしでシークレットキーを生成したのち Base32 でエンコードすればよいです。

    private async Task<string> GetOrGenerateTwoFactorSecretsAsync(User user)
    {
        if (!string.IsNullOrEmpty(user.TwoFactorSecrets))
            return user.TwoFactorSecrets;

        var key = KeyGeneration.GenerateRandomKey();
        var secrets = Base32Encoding.ToString(key);

        user.TwoFactorSecrets = secrets;
        await _userRepository.SetTwoFactorTokenAsync(user.UserName, user.TwoFactorSecrets);
        return user.TwoFactorSecrets;
    }

GetOrGenerateTwoFactorSecretsAsync で生成した Secrets があれば TOTP で計算はできますが、Microsoft Authenticator や Google Authenticator などのアプリ向けに登録用 QR コードの URL を生成します。

TOTP の RFC のサンプルで提示されている TOTP の長さは 8 桁ですが、多くのサイトでは 6 桁の TOTP が利用されているので、ここでも 6 桁の TOTP として URL を発行します。

    private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
    private string GenerateQrCodeUri(string appName, string userName, string unformattedSecrets)
    {
        return string.Format(
            AuthenticatorUriFormat,
            _urlEncoder.Encode(appName),
            _urlEncoder.Encode(userName),
            unformattedSecrets);
    }

View 側では、GenerateQrCodeUri メソッドで作成した URL を元に qrcode.js を使って QR コードを表示します。QR コードやシークレットキーを Microsoft Authenticator などの MFA デバイスに登録したのち生成されるコードを入力してもらい、サーバー側で正しく検証が行われたら2段階認証を有効にします。

QR コードの表示は ASP.NET Core Identity の QR コード表示をそのまま利用できるので、Microsoft Docs の ASP.NET Core で TOTP 認証アプリ用の QR コードを生成できるようにする を参考にしました。

@page
@model IdentitySample.WebApp.Pages.EnableTwoFactorModel
@{
}
<div>
  <form method="post">
    <div style="text-align: center">
      <p>@Model.Secrets</p>
    </div>
    <div
      style="width: 160px; padding: 15px; margin-left: auto; margin-right: auto"
    >
      <div id="qrCode"></div>
      <div id="qrCodeData" data-url="@Model.AuthenticatorUri"></div>
    </div>
    <div>
      <input asp-for="VerifyCode" autocomplete="off" />
    </div>
    <div>
      <input type="submit" value="登録" />
      <p>@Model.Message</p>
    </div>
  </form>
</div>

@section Scripts {
<script type="text/javascript" src="~/lib/qrcode/qrcode.min.js"></script>
<script type="text/javascript" src="~/js/qr.js"></script>
}

登録ボタンクリック後の Post 処理では、改めて現在ログイン中のユーザーに登録されたシークレットキーを取得し、ユーザーが MFA デバイスを使って生成したコードが正しいかを検証し、問題なければユーザーの2段階認証を有効にします。

    public async Task<IActionResult> OnPostAsync()
    {
        var user = await GetCurrentUserAsync();
        if (user == null)
            return NotFound();
        await SetPageModelFromCurrentUserAsync(user);

        var totp = new Totp(Base32Encoding.ToBytes(Secrets), totpSize: 6);
        if (!totp.VerifyTotp(VerifyCode, out _, VerificationWindow.RfcSpecifiedNetworkDelay))
        {
            Message = "検証に失敗しました。もう一度入力してください。";
            return Page();
        }

        Message = "検証に成功しました。";
        await _userRepository.SetUseTwoFactorAsync(user.UserName, true);
        return Page();
    }

ログイン処理に TOTP の認証を追加

最後にログイン画面に2段階認証用のコードを入力するテキストボックスを追加して2段階認証を実施します。
よくある実装ではパスワード入力後にそのユーザーが2段階認証を有効にしている場合に異なるページで TOTP のコードを入力させますが、今回は単純にするためにユーザー ID とパスワードを入力する画面で同時に検証を行っています。

<form method="post">
  <div>
    <label asp-for="UserName" class="control-label"></label>
    <input type="text" asp-for="UserName" />
    <span asp-validation-for="UserName" class="text-danger"></span>
  </div>
  <div>
    <label asp-for="Password" class="control-label"></label>
    <input type="password" asp-for="Password" />
    <span asp-validation-for="Password" class="text-danger"></span>
  </div>
  <div>
    <label asp-for="VerifyCode" class="control-label"></label>
    <input type="text" asp-for="VerifyCode" />
    <span asp-validation-for="VerifyCode" class="text-danger"></span>
  </div>
  <div>
    <input type="submit" value="ログイン" />
    <p>@Model.Message</p>
  </div>
</form>
    public async Task OnPostAsync(string returnUrl)
    {
        var user = await _userRepository.GetUserAsync(UserName);
        if (user == null || user.Password != Password)
        {
            Message = "ログインに失敗しました。";
            return;
        }

        if (user.UseTwoFactor)
        {
            var totp = new Totp(Base32Encoding.ToBytes(user.TwoFactorSecrets), totpSize: 6);
            if (!totp.VerifyTotp(VerifyCode, out _, VerificationWindow.RfcSpecifiedNetworkDelay))
            {
                Message = "ログインに失敗しました。";
                return;
            }
        }

        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, user.UserName)
        };
        var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
        await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity));

        LocalRedirect(returnUrl);
    }

時間のずれに対する考慮

TOTP はサーバーとクライアントで共有する秘密鍵を、現在のデバイスの時刻を元にハッシュしてワンタイムパスワードを生成、検証する仕組みです。このため MFA デバイスの周期以上の時間がサーバーとクライアントでずれてしまうと異なるハッシュが生成され認証に失敗します。

Microsoft Authenticator などは 30 秒のサイクルでワンタイムパスワードを生成しますが、どうしてもサーバーとクライアントで数秒の時刻のずれは出てしまいます。このため、ワンタイムパスワードの検証時に前後1回か2回分のサイクルは許容するように実装することが多いです。Totp.NET ではデフォルトでは RFC の推奨である前後1回(計3回分)分のずれを許容するようになっているので、最大 90 秒のずれが許容されます。

https://github.com/kspearrin/Otp.NET#expanded-time-window

次のように VerificationWindow を設定することで許容するずれを長く取ることができます。
ただし、あまり長くすると総当たり攻撃などに弱くなるので注意が必要です。

var totp = new Totp(Base32Encoding.ToBytes(Secrets), totpSize: 6);
var window = new VerificationWindow(previous:2, future:2);
totp.VerifyTotp(VerifyCode, out _, window);

認証済みコードの使いまわし

TOTP は特定の時間内(Microsoft Authenticator などは 30 秒)であれば何度でも同じキーが生成されるため、TOTP を利用するシステムではデータベースなどに一度使った TOTP のコードを保存して置き、同一ユーザーが同じキーを再利用しないための仕組みを組み込む必要があります。

VerifyTotp メソッドの2つ目の引数には、検証の結果正しかった場合に採用された TOTP のステップが返却されるので、ユーザー ID とこのキーを保存して置き、使いまわしを検知すると良いでしょう。

https://github.com/kspearrin/Otp.NET#one-time-use

おわりに

この頃は認証の仕組み自体外部の IDaaS に移譲したり、マイクロサービスを見据え既に Deende Identity Server などを利用した認証サーバーが別途用意されていることが多いので、以前ほど認証の仕組みを各アプリ側で作り込むことは少なくなってきました。

とはいえ、独自に認証を用意したいという要望は出てくるので、そのような際に参照してもらえると良いかもしれません。

参考

Discussion

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