以前、JwtBearer Authentication の裏で ASP.NET Core Identity を使うことには成功した。
ASP.NET Core Identity は 2 要素認証をサポートしているので、 JwtBearer Authentication を使う場合でもなんとか組み合わせることができないものか、 いろいろ試行錯誤してみた。
結論としては、少々無理やりではあるけど、なんとか使うことができた。 以下、サンプルコード。
using Microsoft.AspNetCore; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; using System.Threading; using System.Threading.Tasks; namespace JwtTwoFactAuthSample { public class Program { public static void Main(string[] args) { BuildWebHost(args).Run(); } public static IWebHost BuildWebHost(string[] args) => WebHost.CreateDefaultBuilder(args) .UseStartup<Startup>() .Build(); } public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration; } public IConfiguration Configuration { get; } public void ConfigureServices(IServiceCollection services) { services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"))); services.AddIdentity<ApplicationUser, ApplicationRole>() .AddDefaultTokenProviders(); services.AddTransient<IUserStore<ApplicationUser>, ApplicationUserStore>(); services.AddTransient<IRoleStore<ApplicationRole>, ApplicationRoleStore>(); services.AddAuthentication(options => { // JWT Bearer をデフォルトにする options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.SaveToken = true; options.TokenValidationParameters = new TokenValidationParameters { ValidIssuer = Configuration["JwtIssuer"], ValidAudience = Configuration["JwtIssuer"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["JwtKey"])), }; }) // 2 要素認証専用のスキームを登録 .AddJwtBearer("Bearer.TwoFactor", options => { options.SaveToken = true; // 2 要素認証用のトークンは 2 要素認証関連の API だけを呼び出せるように、 // JWT のシグネチャを変えておく。 options.TokenValidationParameters = new TokenValidationParameters { ValidIssuer = Configuration["JwtIssuer"], ValidAudience = Configuration["JwtIssuer"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["JwtTwoFactorKey"])), }; }); services.AddMvc(); } public void Configure(IApplicationBuilder app, IHostingEnvironment env, ApplicationDbContext context) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseAuthentication(); app.UseMvc(); // データベースが無ければ作成 context.Database.EnsureCreated(); } } public class ApplicationDbContext : DbContext { public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { } public DbSet<ApplicationUser> Users => Set<ApplicationUser>(); } public class ApplicationUser { [Key] public string Id { get; set; } = Guid.NewGuid().ToString(); [Required] public string UserName { get; set; } [Required] public string PasswordHash { get; set; } public bool IsTwoFactorEnabled { get; set; } = false; public string AuthenticatorKey { get; set; } public string RecoveryCode { get; set; } } public class ApplicationRole { public string Id { get; set; } = Guid.NewGuid().ToString(); public string RoleName { get; set; } } public class ApplicationUserStore : IUserStore<ApplicationUser>, IUserPasswordStore<ApplicationUser>, IUserTwoFactorStore<ApplicationUser>, IUserAuthenticatorKeyStore<ApplicationUser>, IUserTwoFactorRecoveryCodeStore<ApplicationUser>, IUserEmailStore<ApplicationUser>, IUserPhoneNumberStore<ApplicationUser> { readonly ApplicationDbContext context; public ApplicationUserStore(ApplicationDbContext context) { this.context = context; } #region IDisposable public void Dispose() { } #endregion #region IUserStore<ApplicationUser> public async Task<IdentityResult> CreateAsync(ApplicationUser user, CancellationToken cancellationToken) { context.Users.Add(user); await context.SaveChangesAsync(cancellationToken); return IdentityResult.Success; } public async Task<IdentityResult> DeleteAsync(ApplicationUser user, CancellationToken cancellationToken) { context.Users.Remove(user); await context.SaveChangesAsync(cancellationToken); return IdentityResult.Success; } public async Task<ApplicationUser> FindByIdAsync(string userId, CancellationToken cancellationToken) { return await context.Users .FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); } public async Task<ApplicationUser> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken) { return await context.Users .FirstOrDefaultAsync(u => u.UserName == normalizedUserName, cancellationToken); } public Task<string> GetNormalizedUserNameAsync(ApplicationUser user, CancellationToken cancellationToken) { return GetUserNameAsync(user, cancellationToken); } public Task<string> GetUserIdAsync(ApplicationUser user, CancellationToken cancellationToken) { return Task.FromResult(user.Id); } public Task<string> GetUserNameAsync(ApplicationUser user, CancellationToken cancellationToken) { return Task.FromResult(user.UserName); } public Task SetNormalizedUserNameAsync(ApplicationUser user, string normalizedName, CancellationToken cancellationToken) { return SetUserNameAsync(user, normalizedName, cancellationToken); } public Task SetUserNameAsync(ApplicationUser user, string userName, CancellationToken cancellationToken) { user.UserName = userName; return Task.CompletedTask; } public async Task<IdentityResult> UpdateAsync(ApplicationUser user, CancellationToken cancellationToken) { context.Users.Update(user); await context.SaveChangesAsync(cancellationToken); return IdentityResult.Success; } #endregion #region IUserPasswordStore<ApplicationUser> public Task<string> GetPasswordHashAsync(ApplicationUser user, CancellationToken cancellationToken) { return Task.FromResult(user.PasswordHash); } public Task<bool> HasPasswordAsync(ApplicationUser user, CancellationToken cancellationToken) { return Task.FromResult(user.PasswordHash != null); } public Task SetPasswordHashAsync(ApplicationUser user, string passwordHash, CancellationToken cancellationToken) { user.PasswordHash = passwordHash; return Task.CompletedTask; } #endregion #region IUserTwoFactorStore<ApplicationUser> public Task<bool> GetTwoFactorEnabledAsync(ApplicationUser user, CancellationToken cancellationToken) { return Task.FromResult(user.IsTwoFactorEnabled); } public Task SetTwoFactorEnabledAsync(ApplicationUser user, bool enabled, CancellationToken cancellationToken) { user.IsTwoFactorEnabled = enabled; return Task.CompletedTask; } #endregion #region IUserAuthenticatorKeyStore<ApplicationUser> public Task SetAuthenticatorKeyAsync(ApplicationUser user, string key, CancellationToken cancellationToken) { user.AuthenticatorKey = key; return Task.CompletedTask; } public Task<string> GetAuthenticatorKeyAsync(ApplicationUser user, CancellationToken cancellationToken) { return Task.FromResult(user.AuthenticatorKey); } #endregion #region IUserTwoFactorRecoveryCodeStore<ApplicationUser> public Task<int> CountCodesAsync(ApplicationUser user, CancellationToken cancellationToken) { var count = user.RecoveryCode?.Split(";").Length ?? 0; return Task.FromResult(count); } public async Task<bool> RedeemCodeAsync(ApplicationUser user, string code, CancellationToken cancellationToken) { var codes = user.RecoveryCode.Split(";"); if (codes.Contains(code)) { var updatedCodes = codes.Where(c => c != code).ToList(); await ReplaceCodesAsync(user, updatedCodes, cancellationToken); return true; } else { return false; } } public Task ReplaceCodesAsync(ApplicationUser user, IEnumerable<string> recoveryCodes, CancellationToken cancellationToken) { user.RecoveryCode = string.Join(";", recoveryCodes); return Task.CompletedTask; } #endregion #region IUserEmailStore<ApplicationUser> public Task<ApplicationUser> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken) { return FindByNameAsync(normalizedEmail, cancellationToken); } public Task<string> GetEmailAsync(ApplicationUser user, CancellationToken cancellationToken) { return Task.FromResult(user.UserName); } public Task<bool> GetEmailConfirmedAsync(ApplicationUser user, CancellationToken cancellationToken) { return Task.FromResult(true); } public Task<string> GetNormalizedEmailAsync(ApplicationUser user, CancellationToken cancellationToken) { return GetEmailAsync(user, cancellationToken); } public Task SetEmailAsync(ApplicationUser user, string email, CancellationToken cancellationToken) { user.UserName = email; return Task.CompletedTask; } public Task SetEmailConfirmedAsync(ApplicationUser user, bool confirmed, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task SetNormalizedEmailAsync(ApplicationUser user, string normalizedEmail, CancellationToken cancellationToken) { return SetEmailAsync(user, normalizedEmail, cancellationToken); } #endregion #region IUserPhoneNumberStore<ApplicationUser> public Task<string> GetPhoneNumberAsync(ApplicationUser user, CancellationToken cancellationToken) { return Task.FromResult(default(string)); } public Task<bool> GetPhoneNumberConfirmedAsync(ApplicationUser user, CancellationToken cancellationToken) { return Task.FromResult(false); } public Task SetPhoneNumberAsync(ApplicationUser user, string phoneNumber, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task SetPhoneNumberConfirmedAsync(ApplicationUser user, bool confirmed, CancellationToken cancellationToken) { return Task.CompletedTask; } #endregion } public class ApplicationRoleStore : IRoleStore<ApplicationRole> { public Task<IdentityResult> CreateAsync(ApplicationRole role, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task<IdentityResult> DeleteAsync(ApplicationRole role, CancellationToken cancellationToken) => throw new NotImplementedException(); public void Dispose() { } public Task<ApplicationRole> FindByIdAsync(string roleId, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task<ApplicationRole> FindByNameAsync(string normalizedRoleName, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task<string> GetNormalizedRoleNameAsync(ApplicationRole role, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task<IdentityResult> UpdateAsync(ApplicationRole role, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task<string> GetRoleIdAsync(ApplicationRole role, CancellationToken cancellationToken) => Task.FromResult(role.Id); public Task<string> GetRoleNameAsync(ApplicationRole role, CancellationToken cancellationToken) => Task.FromResult(role.RoleName); public Task SetNormalizedRoleNameAsync(ApplicationRole role, string normalizedName, CancellationToken cancellationToken) => SetRoleNameAsync(role, normalizedName, cancellationToken); public Task SetRoleNameAsync(ApplicationRole role, string roleName, CancellationToken cancellationToken) { role.RoleName = roleName; return Task.CompletedTask; } } [Route("[controller]/[action]")] public class UserController : Controller { const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6"; readonly UserManager<ApplicationUser> userManager; readonly SignInManager<ApplicationUser> signInManager; readonly IConfiguration configuration; readonly UrlEncoder urlEncoder; public UserController( SignInManager<ApplicationUser> signInManager, UserManager<ApplicationUser> userManager, IConfiguration configuration, UrlEncoder urlEncoder) { this.signInManager = signInManager; this.userManager = userManager; this.configuration = configuration; this.urlEncoder = urlEncoder; } [HttpPost] public async Task<IActionResult> Register([FromBody] RegisterViewModel model) { if (ModelState.IsValid) { var user = new ApplicationUser() { UserName = model.UserName, }; var result = await userManager.CreateAsync(user, model.Password); if (result.Succeeded) { await signInManager.SignInAsync(user, false); return Ok(); } } return BadRequest(ModelState); } [HttpPost] public async Task<IActionResult> Login([FromBody]LoginViewModel model) { if (ModelState.IsValid) { var result = await signInManager.PasswordSignInAsync(model.UserName, model.Password, true, false); if (result.Succeeded) { var user = await userManager.FindByNameAsync(model.UserName); // 2 要素認証が OFF のときは通常の JWT を返す var token = await GenerateJwtTokenAsync(user); return Ok(token); } else if (result.RequiresTwoFactor) { var user = await userManager.FindByNameAsync(model.UserName); // 2 要素認証が ON のときは 2 要素認証関連 API だけ利用できる JWT を返す var token = await GenerateJwtTokenAsync(user, true); return Ok(token); } } return BadRequest(ModelState); } // 2 要素認証関連 API は認証スキームを別にしておく [HttpPost] [Authorize(AuthenticationSchemes = "Bearer.TwoFactor")] public async Task<IActionResult> LoginWith2fa([FromBody]LoginWith2faViewModel model) { if (ModelState.IsValid) { var user = await userManager.GetUserAsync(User); var code = model.Code.Replace(" ", "").Replace("-", ""); var result = await userManager.VerifyTwoFactorTokenAsync(user, TokenOptions.DefaultAuthenticatorProvider, code); if (result) { var token = await GenerateJwtTokenAsync(user); return Ok(token); } } return BadRequest(ModelState); } // 2 要素認証関連 API は認証スキームを別にしておく [HttpPost] [Authorize(AuthenticationSchemes = "Bearer.TwoFactor")] public async Task<IActionResult> LoginWithRecoveryCode([FromBody]LoginWithRecoveryCodeViewModel model) { if (ModelState.IsValid) { var user = await userManager.GetUserAsync(User); var code = model.RecoveryCode.Replace(" ", ""); var result = await userManager.RedeemTwoFactorRecoveryCodeAsync(user, code); if (result.Succeeded) { var token = await GenerateJwtTokenAsync(user); return Ok(token); } } return BadRequest(ModelState); } async Task<TokenViewModel> GenerateJwtTokenAsync(ApplicationUser user, bool requiresTwoFactor = false) { var principal = await signInManager.CreateUserPrincipalAsync(user); var claims = new List<Claim>(principal.Claims); claims.Add(new Claim(JwtRegisteredClaimNames.Sub, user.UserName)); claims.Add(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())); SymmetricSecurityKey key; if (requiresTwoFactor) { // 2 要素認証関連 API のみ利用できる JWT を作成 claims.Add(new Claim(ClaimTypes.AuthenticationMethod, IdentityConstants.TwoFactorUserIdScheme)); key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JwtTwoFactorKey"])); } else { key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JwtKey"])); } var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var expires = DateTime.Now.AddDays(7); var token = new JwtSecurityToken( configuration["JwtIssuer"], configuration["JwtIssuer"], claims, expires: expires, signingCredentials: credentials ); var jwt = new JwtSecurityTokenHandler().WriteToken(token); return new TokenViewModel() { Token = jwt, RequiresTwoFactor = requiresTwoFactor, }; } [HttpGet] [Authorize] public async Task<IActionResult> Current() { var user = await userManager.GetUserAsync(User); return Ok(user); } [HttpGet] [Authorize] public async Task<IActionResult> EnableAuthenticator() { var user = await userManager.GetUserAsync(User); if (user == null) { throw new ApplicationException($"Unable to load user with ID '{userManager.GetUserId(User)}'."); } var model = new EnableAuthenticatorViewModel(); await LoadSharedKeyAndQrCodeUriAsync(user, model); return Ok(model); } [HttpPost] [Authorize] public async Task<IActionResult> EnableAuthenticator([FromBody] EnableAuthenticatorViewModel model) { var user = await userManager.GetUserAsync(User); if (user == null) { throw new ApplicationException( $"Unable to load user with ID '{userManager.GetUserId(User)}'."); } if (!ModelState.IsValid) { return BadRequest(ModelState); } // 半角スペースとハイフンを除去 var verificationCode = model.Code .Replace(" ", string.Empty) .Replace("-", string.Empty); var is2faTokenValid = await userManager.VerifyTwoFactorTokenAsync( user, userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode); if (!is2faTokenValid) { ModelState.AddModelError("Code", "Verification code is invalid."); return BadRequest(ModelState); } await userManager.SetTwoFactorEnabledAsync(user, true); var recoveryCodes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); var viewModel = new ShowRecoveryCodesViewModel { RecoveryCodes = recoveryCodes.ToArray() }; return Ok(viewModel); } [HttpPost] [Authorize] public async Task<IActionResult> Disable2fa() { var user = await userManager.GetUserAsync(User); if (user == null) { throw new ApplicationException( $"Unable to load user with ID '{userManager.GetUserId(User)}'."); } var disable2faResult = await userManager.SetTwoFactorEnabledAsync(user, false); if (!disable2faResult.Succeeded) { throw new ApplicationException($"Unexpected error occured disabling 2FA for user with ID '{user.Id}'."); } return Ok(); } [HttpPost] [Authorize] public async Task<IActionResult> ResetAuthenticator() { var user = await userManager.GetUserAsync(User); if (user == null) { throw new ApplicationException( $"Unable to load user with ID '{userManager.GetUserId(User)}'."); } await userManager.SetTwoFactorEnabledAsync(user, false); await userManager.ResetAuthenticatorKeyAsync(user); return Ok(); } #region Helpers private void AddErrors(IdentityResult result) { foreach (var error in result.Errors) { ModelState.AddModelError(string.Empty, error.Description); } } private string FormatKey(string unformattedKey) { var result = new StringBuilder(); int currentPosition = 0; while (currentPosition + 4 < unformattedKey.Length) { result.Append(unformattedKey.Substring(currentPosition, 4)).Append(" "); currentPosition += 4; } if (currentPosition < unformattedKey.Length) { result.Append(unformattedKey.Substring(currentPosition)); } return result.ToString().ToLowerInvariant(); } private string GenerateQrCodeUri(string email, string unformattedKey) { return string.Format( AuthenticatorUriFormat, urlEncoder.Encode("JwtTwoFactAuthSample"), urlEncoder.Encode(email), unformattedKey); } private async Task LoadSharedKeyAndQrCodeUriAsync(ApplicationUser user, EnableAuthenticatorViewModel model) { var unformattedKey = await userManager.GetAuthenticatorKeyAsync(user); if (string.IsNullOrEmpty(unformattedKey)) { await userManager.ResetAuthenticatorKeyAsync(user); unformattedKey = await userManager.GetAuthenticatorKeyAsync(user); } model.SharedKey = FormatKey(unformattedKey); model.AuthenticatorUri = GenerateQrCodeUri(user.UserName, unformattedKey); } #endregion } // ユーザー登録用 public class RegisterViewModel { [Required] public string UserName { get; set; } [Required] [DataType(DataType.Password)] public string Password { get; set; } } // ログイン用 public class LoginViewModel { [Required] public string UserName { get; set; } [Required] [DataType(DataType.Password)] public string Password { get; set; } } public class EnableAuthenticatorViewModel { [Required] [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] [DataType(DataType.Text)] [Display(Name = "Verification Code")] public string Code { get; set; } [BindNever] public string SharedKey { get; set; } [BindNever] public string AuthenticatorUri { get; set; } } // 2 要素認証の認証コードのバインド用 public class LoginWith2faViewModel { [Required] public string Code { get; set; } } public class LoginWithRecoveryCodeViewModel { [Required] public string RecoveryCode { get; set; } } public class ShowRecoveryCodesViewModel { public string[] RecoveryCodes { get; set; } } // JWT をレスポンスで返すとき用 public class TokenViewModel { public string Token { get; set; } public bool RequiresTwoFactor { get; set; } } }
Visual Studio で ASP.NET Core Identity を使う Web アプリケーションプロジェクトを作成すると、2 要素認証を行うコードまで作成される。ただし作成されたコードは Cookie を使うこと前提で、JwtBearer Authentication と組み合わせる際にそのままでは使えなかった。
そのため、内部で呼び出されているメソッドをASP.NET Core Identity のソースコードで調べ、そのメソッドを直接呼び出すことで無理やり実現している。呼び出しているのはどれも public なメソッドだし、内部 API というようなものでも無さそうなので、今のところ問題は起きていない。あまりオススメのやり方では無いのは重々承知。