以前、JwtBearer Authentication の裏で ASP.NET Core Identity を使うことには成功した。
tnakamura.hatenablog.com
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 =>
{
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"])),
};
})
.AddJwtBearer("Bearer.TwoFactor", options =>
{
options.SaveToken = true;
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);
var token = await GenerateJwtTokenAsync(user);
return Ok(token);
}
else if (result.RequiresTwoFactor)
{
var user = await userManager.FindByNameAsync(model.UserName);
var token = await GenerateJwtTokenAsync(user, true);
return Ok(token);
}
}
return BadRequest(ModelState);
}
[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);
}
[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)
{
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; }
}
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; }
}
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 というようなものでも無さそうなので、今のところ問題は起きていない。あまりオススメのやり方では無いのは重々承知。