Add authenticator support

This commit is contained in:
Hao Kung 2016-12-27 12:59:44 -08:00
parent a9ce48b911
commit 5aed9742a4
37 changed files with 1490 additions and 132 deletions

View File

@ -377,6 +377,11 @@ namespace IdentitySample.Controllers
return View("Error");
}
if (model.SelectedProvider == "Authenticator")
{
return RedirectToAction(nameof(VerifyAuthenticatorCode), new { ReturnUrl = model.ReturnUrl, RememberMe = model.RememberMe });
}
// Generate the token and send it
var code = await _userManager.GenerateTwoFactorTokenAsync(user, model.SelectedProvider);
if (string.IsNullOrWhiteSpace(code))
@ -444,6 +449,93 @@ namespace IdentitySample.Controllers
}
}
//
// GET: /Account/VerifyAuthenticatorCode
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> VerifyAuthenticatorCode(bool rememberMe, string returnUrl = null)
{
// Require that the user has already logged in via username/password or external login
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
return View("Error");
}
return View(new VerifyAuthenticatorCodeViewModel { ReturnUrl = returnUrl, RememberMe = rememberMe });
}
//
// POST: /Account/VerifyAuthenticatorCode
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> VerifyAuthenticatorCode(VerifyAuthenticatorCodeViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
// The following code protects for brute force attacks against the two factor codes.
// If a user enters incorrect codes for a specified amount of time then the user account
// will be locked out for a specified amount of time.
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(model.Code, model.RememberMe, model.RememberBrowser);
if (result.Succeeded)
{
return RedirectToLocal(model.ReturnUrl);
}
if (result.IsLockedOut)
{
_logger.LogWarning(7, "User account locked out.");
return View("Lockout");
}
else
{
ModelState.AddModelError(string.Empty, "Invalid code.");
return View(model);
}
}
//
// GET: /Account/UseRecoveryCode
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> UseRecoveryCode(string returnUrl = null)
{
// Require that the user has already logged in via username/password or external login
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
return View("Error");
}
return View(new UseRecoveryCodeViewModel { ReturnUrl = returnUrl });
}
//
// POST: /Account/UseRecoveryCode
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UseRecoveryCode(UseRecoveryCodeViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(model.Code);
if (result.Succeeded)
{
return RedirectToLocal(model.ReturnUrl);
}
else
{
ModelState.AddModelError(string.Empty, "Invalid code.");
return View(model);
}
}
#region Helpers
private void AddErrors(IdentityResult result)

View File

@ -58,7 +58,8 @@ namespace IdentitySamples.Controllers
PhoneNumber = await _userManager.GetPhoneNumberAsync(user),
TwoFactor = await _userManager.GetTwoFactorEnabledAsync(user),
Logins = await _userManager.GetLoginsAsync(user),
BrowserRemembered = await _signInManager.IsTwoFactorClientRememberedAsync(user)
BrowserRemembered = await _signInManager.IsTwoFactorClientRememberedAsync(user),
AuthenticatorKey = await _userManager.GetAuthenticatorKeyAsync(user)
};
return View(model);
}
@ -107,6 +108,37 @@ namespace IdentitySamples.Controllers
return RedirectToAction(nameof(VerifyPhoneNumber), new { PhoneNumber = model.PhoneNumber });
}
//
// POST: /Manage/ResetAuthenticatorKey
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetAuthenticatorKey()
{
var user = await GetCurrentUserAsync();
if (user != null)
{
await _userManager.ResetAuthenticatorKeyAsync(user);
_logger.LogInformation(1, "User reset authenticator key.");
}
return RedirectToAction(nameof(Index), "Manage");
}
//
// POST: /Manage/GenerateRecoveryCode
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> GenerateRecoveryCode()
{
var user = await GetCurrentUserAsync();
if (user != null)
{
var codes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 5);
_logger.LogInformation(1, "User generated new recovery code.");
return View("DisplayRecoveryCodes", new DisplayRecoveryCodesViewModel { Codes = codes });
}
return View("Error");
}
//
// POST: /Manage/EnableTwoFactorAuthentication
[HttpPost]

View File

@ -1,12 +1,12 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using IdentitySample.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using IdentitySample.Models;
namespace IdentitySample.Data.Migrations
{
@ -17,7 +17,7 @@ namespace IdentitySample.Data.Migrations
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
modelBuilder
.HasAnnotation("ProductVersion", "7.0.0-rc2")
.HasAnnotation("ProductVersion", "1.0.0-rc3")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRole", b =>
@ -36,9 +36,9 @@ namespace IdentitySample.Data.Migrations
b.HasKey("Id");
b.HasIndex("NormalizedName")
.HasAnnotation("Relational:Name", "RoleNameIndex");
.HasName("RoleNameIndex");
b.HasAnnotation("Relational:TableName", "AspNetRoles");
b.ToTable("AspNetRoles");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRoleClaim<string>", b =>
@ -57,7 +57,7 @@ namespace IdentitySample.Data.Migrations
b.HasIndex("RoleId");
b.HasAnnotation("Relational:TableName", "AspNetRoleClaims");
b.ToTable("AspNetRoleClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserClaim<string>", b =>
@ -76,7 +76,7 @@ namespace IdentitySample.Data.Migrations
b.HasIndex("UserId");
b.HasAnnotation("Relational:TableName", "AspNetUserClaims");
b.ToTable("AspNetUserClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserLogin<string>", b =>
@ -94,7 +94,7 @@ namespace IdentitySample.Data.Migrations
b.HasIndex("UserId");
b.HasAnnotation("Relational:TableName", "AspNetUserLogins");
b.ToTable("AspNetUserLogins");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserRole<string>", b =>
@ -109,10 +109,25 @@ namespace IdentitySample.Data.Migrations
b.HasIndex("UserId");
b.HasAnnotation("Relational:TableName", "AspNetUserRoles");
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("$safeprojectname$.Models.ApplicationUser", b =>
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId");
b.Property<string>("LoginProvider");
b.Property<string>("Name");
b.Property<string>("Value");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("WebApplication13.Models.ApplicationUser", b =>
{
b.Property<string>("Id");
@ -152,34 +167,35 @@ namespace IdentitySample.Data.Migrations
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasAnnotation("Relational:Name", "EmailIndex");
.HasName("EmailIndex");
b.HasIndex("NormalizedUserName")
.HasAnnotation("Relational:Name", "UserNameIndex");
.IsUnique()
.HasName("UserNameIndex");
b.HasAnnotation("Relational:TableName", "AspNetUsers");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRole")
.WithMany()
.WithMany("Claims")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserClaim<string>", b =>
{
b.HasOne("$safeprojectname$.Models.ApplicationUser")
.WithMany()
b.HasOne("WebApplication13.Models.ApplicationUser")
.WithMany("Claims")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserLogin<string>", b =>
{
b.HasOne("$safeprojectname$.Models.ApplicationUser")
.WithMany()
b.HasOne("WebApplication13.Models.ApplicationUser")
.WithMany("Logins")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
@ -187,12 +203,12 @@ namespace IdentitySample.Data.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRole")
.WithMany()
.WithMany("Users")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("$safeprojectname$.Models.ApplicationUser")
.WithMany()
b.HasOne("WebApplication13.Models.ApplicationUser")
.WithMany("Roles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@ -17,14 +17,28 @@ namespace IdentitySample.Data.Migrations
{
Id = table.Column<string>(nullable: false),
ConcurrencyStamp = table.Column<string>(nullable: true),
Name = table.Column<string>(nullable: true),
NormalizedName = table.Column<string>(nullable: true)
Name = table.Column<string>(maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(maxLength: 256, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(nullable: false),
LoginProvider = table.Column<string>(nullable: false),
Name = table.Column<string>(nullable: false),
Value = table.Column<string>(nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
@ -32,18 +46,18 @@ namespace IdentitySample.Data.Migrations
Id = table.Column<string>(nullable: false),
AccessFailedCount = table.Column<int>(nullable: false),
ConcurrencyStamp = table.Column<string>(nullable: true),
Email = table.Column<string>(nullable: true),
Email = table.Column<string>(maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(nullable: false),
LockoutEnabled = table.Column<bool>(nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(nullable: true),
NormalizedEmail = table.Column<string>(nullable: true),
NormalizedUserName = table.Column<string>(nullable: true),
NormalizedEmail = table.Column<string>(maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(maxLength: 256, nullable: true),
PasswordHash = table.Column<string>(nullable: true),
PhoneNumber = table.Column<string>(nullable: true),
PhoneNumberConfirmed = table.Column<bool>(nullable: false),
SecurityStamp = table.Column<string>(nullable: true),
TwoFactorEnabled = table.Column<bool>(nullable: false),
UserName = table.Column<string>(nullable: true)
UserName = table.Column<string>(maxLength: 256, nullable: true)
},
constraints: table =>
{
@ -174,7 +188,8 @@ namespace IdentitySample.Data.Migrations
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName");
column: "NormalizedUserName",
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
@ -191,6 +206,9 @@ namespace IdentitySample.Data.Migrations
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "AspNetRoles");

View File

@ -1,12 +1,12 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using IdentitySample.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using IdentitySample.Models;
namespace IdentitySample.Data.Migrations
{
@ -16,7 +16,7 @@ namespace IdentitySample.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
modelBuilder
.HasAnnotation("ProductVersion", "7.0.0-rc2")
.HasAnnotation("ProductVersion", "1.0.0-rc3")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRole", b =>
@ -35,9 +35,9 @@ namespace IdentitySample.Data.Migrations
b.HasKey("Id");
b.HasIndex("NormalizedName")
.HasAnnotation("Relational:Name", "RoleNameIndex");
.HasName("RoleNameIndex");
b.HasAnnotation("Relational:TableName", "AspNetRoles");
b.ToTable("AspNetRoles");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRoleClaim<string>", b =>
@ -56,7 +56,7 @@ namespace IdentitySample.Data.Migrations
b.HasIndex("RoleId");
b.HasAnnotation("Relational:TableName", "AspNetRoleClaims");
b.ToTable("AspNetRoleClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserClaim<string>", b =>
@ -75,7 +75,7 @@ namespace IdentitySample.Data.Migrations
b.HasIndex("UserId");
b.HasAnnotation("Relational:TableName", "AspNetUserClaims");
b.ToTable("AspNetUserClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserLogin<string>", b =>
@ -93,7 +93,7 @@ namespace IdentitySample.Data.Migrations
b.HasIndex("UserId");
b.HasAnnotation("Relational:TableName", "AspNetUserLogins");
b.ToTable("AspNetUserLogins");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserRole<string>", b =>
@ -108,10 +108,25 @@ namespace IdentitySample.Data.Migrations
b.HasIndex("UserId");
b.HasAnnotation("Relational:TableName", "AspNetUserRoles");
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("$safeprojectname$.Models.ApplicationUser", b =>
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId");
b.Property<string>("LoginProvider");
b.Property<string>("Name");
b.Property<string>("Value");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("IdentitySample.Models.ApplicationUser", b =>
{
b.Property<string>("Id");
@ -151,34 +166,35 @@ namespace IdentitySample.Data.Migrations
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasAnnotation("Relational:Name", "EmailIndex");
.HasName("EmailIndex");
b.HasIndex("NormalizedUserName")
.HasAnnotation("Relational:Name", "UserNameIndex");
.IsUnique()
.HasName("UserNameIndex");
b.HasAnnotation("Relational:TableName", "AspNetUsers");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRole")
.WithMany()
.WithMany("Claims")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserClaim<string>", b =>
{
b.HasOne("$safeprojectname$.Models.ApplicationUser")
.WithMany()
b.HasOne("IdentitySample.Models.ApplicationUser")
.WithMany("Claims")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserLogin<string>", b =>
{
b.HasOne("$safeprojectname$.Models.ApplicationUser")
.WithMany()
b.HasOne("IdentitySample.Models.ApplicationUser")
.WithMany("Logins")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
@ -186,12 +202,12 @@ namespace IdentitySample.Data.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRole")
.WithMany()
.WithMany("Users")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("$safeprojectname$.Models.ApplicationUser")
.WithMany()
b.HasOne("IdentitySample.Models.ApplicationUser")
.WithMany("Roles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});

View File

@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace IdentitySample.Models.AccountViewModels
{
public class UseRecoveryCodeViewModel
{
[Required]
public string Code { get; set; }
public string ReturnUrl { get; set; }
}
}

View File

@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace IdentitySample.Models.AccountViewModels
{
public class VerifyAuthenticatorCodeViewModel
{
[Required]
public string Code { get; set; }
public string ReturnUrl { get; set; }
[Display(Name = "Remember this browser?")]
public bool RememberBrowser { get; set; }
[Display(Name = "Remember me?")]
public bool RememberMe { get; set; }
}
}

View File

@ -1,8 +1,4 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using System.ComponentModel.DataAnnotations;
namespace IdentitySample.Models.AccountViewModels
{

View File

@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace IdentitySample.Models.ManageViewModels
{
public class DisplayRecoveryCodesViewModel
{
[Required]
public IEnumerable<string> Codes { get; set; }
}
}

View File

@ -17,5 +17,7 @@ namespace IdentitySample.Models.ManageViewModels
public bool TwoFactor { get; set; }
public bool BrowserRemembered { get; set; }
public string AuthenticatorKey { get; set; }
}
}

View File

@ -1,6 +1,5 @@
using System.IO;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
namespace IdentitySample
{
@ -8,11 +7,8 @@ namespace IdentitySample
{
public static void Main(string[] args)
{
var config = new ConfigurationBuilder().AddEnvironmentVariables("ASPNETCORE_").Build();
var host = new WebHostBuilder()
.UseKestrel()
.UseConfiguration(config)
.UseContentRoot(Directory.GetCurrentDirectory())
.UseIISIntegration()
.UseStartup<Startup>()

View File

@ -12,7 +12,7 @@
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNET_ENVIRONMENT": "Development"
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"web": {

View File

@ -21,18 +21,11 @@ namespace IdentitySample
{
public Startup(IHostingEnvironment env)
{
// Set up configuration sources.
var builder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.SetBasePath(env.ContentRootPath)
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true);
if (env.IsDevelopment())
{
// For more details on using the user secret store see http://go.microsoft.com/fwlink/?LinkID=532709
builder.AddUserSecrets();
}
builder.AddEnvironmentVariables();
Configuration = builder.Build();
}
@ -44,13 +37,9 @@ namespace IdentitySample
{
// Add framework services.
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(Configuration["Data:DefaultConnection:ConnectionString"]));
options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
services.AddIdentity<ApplicationUser, IdentityRole>(options => {
options.Cookies.ApplicationCookie.AuthenticationScheme = "ApplicationCookie";
options.Cookies.ApplicationCookie.CookieName = "Interop";
options.Cookies.ApplicationCookie.DataProtectionProvider = DataProtectionProvider.Create(new DirectoryInfo("C:\\Github\\Identity\\artifacts"));
})
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
@ -75,19 +64,8 @@ namespace IdentitySample
else
{
app.UseExceptionHandler("/Home/Error");
// For more details on creating database during deployment see http://go.microsoft.com/fwlink/?LinkID=615859
try
{
using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>()
.CreateScope())
{
serviceScope.ServiceProvider.GetService<ApplicationDbContext>()
.Database.Migrate();
}
}
catch { }
}
app.UseStaticFiles();
app.UseIdentity();

View File

@ -0,0 +1,28 @@
@model UseRecoveryCodeViewModel
@{
ViewData["Title"] = "Use recovery code";
}
<h2>@ViewData["Title"].</h2>
<form asp-controller="Account" asp-action="UseRecoveryCode" asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" class="form-horizontal" role="form">
<div asp-validation-summary="All" class="text-danger"></div>
<h4>@ViewData["Status"]</h4>
<hr />
<div class="form-group">
<label asp-for="Code" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="Code" class="form-control" />
<span asp-validation-for="Code" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
<button type="submit" class="btn btn-default">Submit</button>
</div>
</div>
</form>
@section Scripts {
@{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
}

View File

@ -0,0 +1,40 @@
@model VerifyAuthenticatorCodeViewModel
@{
ViewData["Title"] = "Verify";
}
<h2>@ViewData["Title"].</h2>
<form asp-controller="Account" asp-action="VerifyAuthenticatorCode" asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" class="form-horizontal" role="form">
<div asp-validation-summary="All" class="text-danger"></div>
<input asp-for="RememberMe" type="hidden" />
<h4>@ViewData["Status"]</h4>
<hr />
<div class="form-group">
<label asp-for="Code" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="Code" class="form-control" />
<span asp-validation-for="Code" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
<div class="checkbox">
<input asp-for="RememberBrowser" />
<label asp-for="RememberBrowser"></label>
</div>
</div>
</div>
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
<button type="submit" class="btn btn-default">Submit</button>
</div>
</div>
<p>
<a asp-action="UseRecoveryCode">Lost your authenticator?</a>
</p>
</form>
@section Scripts {
@{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
}

View File

@ -0,0 +1,21 @@
@model DisplayRecoveryCodesViewModel
@{
ViewData["Title"] = "Your recovery codes:";
}
<h2>@ViewData["Title"].</h2>
<p class="text-success">@ViewData["StatusMessage"]</p>
<div>
<h4>Here are your new recovery codes</h4>
<hr />
<dl class="dl-horizontal">
<dt>Codes:</dt>
@foreach (var code in Model.Codes)
{
<dd>
<text>@code</text>
</dd>
}
</dl>
</div>

View File

@ -32,7 +32,7 @@
See <a href="http://go.microsoft.com/fwlink/?LinkID=532713">this article</a>
for details on setting up this ASP.NET application to support two-factor authentication using SMS.
</p>
@*@(Model.PhoneNumber ?? "None")
@(Model.PhoneNumber ?? "None")
@if (Model.PhoneNumber != null)
{
<br />
@ -44,16 +44,16 @@
else
{
<text>[&nbsp;&nbsp;<a asp-controller="Manage" asp-action="AddPhoneNumber">Add</a>&nbsp;&nbsp;]</text>
}*@
}
</dd>
<dt>Two-Factor Authentication:</dt>
<dd>
<p>
<!--<p>
There are no two-factor authentication providers configured. See <a href="http://go.microsoft.com/fwlink/?LinkID=532713">this article</a>
for setting up this application to support two-factor authentication.
</p>
@*@if (Model.TwoFactor)
</p>-->
@if (Model.TwoFactor)
{
<form asp-controller="Manage" asp-action="DisableTwoFactorAuthentication" method="post" class="form-horizontal" role="form">
Enabled [<button type="submit" class="btn-link">Disable</button>]
@ -64,7 +64,23 @@
<form asp-controller="Manage" asp-action="EnableTwoFactorAuthentication" method="post" class="form-horizontal" role="form">
[<button type="submit" class="btn-link">Enable</button>] Disabled
</form>
}*@
}
</dd>
<dt>Authentication App:</dt>
<dd>
@if (Model.AuthenticatorKey == null)
{
<form asp-controller="Manage" asp-action="ResetAuthenticatorKey" method="post" class="form-horizontal" role="form">
Generate [<button type="submit" class="btn-link">Generate</button>]
</form>
}
else
{
<text>Your key is: @Model.AuthenticatorKey</text>
<form asp-controller="Manage" asp-action="GenerateRecoveryCode" method="post" class="form-horizontal" role="form">
Generate [<button type="submit" class="btn-link">Generate new recovery codes</button>]
</form>
}
</dd>
</dl>
</div>

View File

@ -31,7 +31,7 @@
@RenderBody()
<hr />
<footer>
<p>&copy; $year$ - $safeprojectname$</p>
<p>&copy; 2016 - IdentitySample</p>
</footer>
</div>

View File

@ -1,8 +1,6 @@
{
"Data": {
"DefaultConnection": {
"ConnectionString": "Server=(localdb)\\mssqllocaldb;Database=Interop-Shared;Trusted_Connection=True;MultipleActiveResultSets=true"
}
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=Interop-Shared-10-10;Trusted_Connection=True;MultipleActiveResultSets=true"
},
"Logging": {
"IncludeScopes": false,

View File

@ -201,7 +201,9 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore
IUserPhoneNumberStore<TUser>,
IQueryableUserStore<TUser>,
IUserTwoFactorStore<TUser>,
IUserAuthenticationTokenStore<TUser>
IUserAuthenticationTokenStore<TUser>,
IUserAuthenticatorKeyStore<TUser>,
IUserTwoFactorRecoveryCodeStore<TUser>
where TUser : IdentityUser<TKey, TUserClaim, TUserRole, TUserLogin>
where TRole : IdentityRole<TKey, TUserRole, TRoleClaim>
where TContext : DbContext
@ -1411,7 +1413,7 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore
/// <param name="name">The name of the token.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>The <see cref="Task"/> that represents the asynchronous operation.</returns>
public async virtual Task RemoveTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken)
public virtual async Task RemoveTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
@ -1435,7 +1437,7 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore
/// <param name="name">The name of the token.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>The <see cref="Task"/> that represents the asynchronous operation.</returns>
public async virtual Task<string> GetTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken)
public virtual async Task<string> GetTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
@ -1447,5 +1449,78 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore
var entry = await FindToken(user, loginProvider, name, cancellationToken);
return entry?.Value;
}
private const string InternalLoginProvider = "[AspNetUserStore]";
private const string AuthenticatorKeyTokenName = "AuthenticatorKey";
private const string RecoveryCodeTokenName = "RecoveryCodes";
/// <summary>
/// Sets the authenticator key for the specified <paramref name="user"/>.
/// </summary>
/// <param name="user">The user whose authenticator key should be set.</param>
/// <param name="key">The authenticator key to set.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>The <see cref="Task"/> that represents the asynchronous operation.</returns>
public virtual Task SetAuthenticatorKeyAsync(TUser user, string key, CancellationToken cancellationToken)
{
return SetTokenAsync(user, InternalLoginProvider, AuthenticatorKeyTokenName, key, cancellationToken);
}
/// <summary>
/// Get the authenticator key for the specified <paramref name="user" />.
/// </summary>
/// <param name="user">The user whose security stamp should be set.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>The <see cref="Task"/> that represents the asynchronous operation, containing the security stamp for the specified <paramref name="user"/>.</returns>
public virtual Task<string> GetAuthenticatorKeyAsync(TUser user, CancellationToken cancellationToken)
{
return GetTokenAsync(user, InternalLoginProvider, AuthenticatorKeyTokenName, cancellationToken);
}
/// <summary>
/// Updates the recovery codes for the user while invalidating any previous recovery codes.
/// </summary>
/// <param name="user">The user to store new recovery codes for.</param>
/// <param name="recoveryCodes">The new recovery codes for the user.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>The new recovery codes for the user.</returns>
public virtual Task ReplaceCodesAsync(TUser user, IEnumerable<string> recoveryCodes, CancellationToken cancellationToken)
{
var mergedCodes = string.Join(";", recoveryCodes);
return SetTokenAsync(user, InternalLoginProvider, RecoveryCodeTokenName, mergedCodes, cancellationToken);
}
/// <summary>
/// Returns whether a recovery code is valid for a user. Note: recovery codes are only valid
/// once, and will be invalid after use.
/// </summary>
/// <param name="user">The user who owns the recovery code.</param>
/// <param name="code">The recovery code to use.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>True if the recovery code was found for the user.</returns>
public virtual async Task<bool> RedeemCodeAsync(TUser user, string code, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
if (code == null)
{
throw new ArgumentNullException(nameof(code));
}
var mergedCodes = await GetTokenAsync(user, InternalLoginProvider, RecoveryCodeTokenName, cancellationToken) ?? "";
var splitCodes = mergedCodes.Split(';');
if (splitCodes.Contains(code))
{
var updatedCodes = new List<string>(splitCodes.Where(s => s != code));
await ReplaceCodesAsync(user, updatedCodes, cancellationToken);
return true;
}
return false;
}
}
}

View File

@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@ -13,6 +14,7 @@ using Microsoft.AspNetCore.Testing;
using Microsoft.AspNetCore.Testing.xunit;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Xunit;
namespace Microsoft.AspNetCore.Identity.Test
@ -2371,6 +2373,69 @@ namespace Microsoft.AspNetCore.Identity.Test
Assert.Null(await manager.GetAuthenticationTokenAsync(user, "provider", "name"));
}
/// <summary>
/// Test.
/// </summary>
/// <returns>Task</returns>
[Fact]
public async Task CanRedeemRecoveryCodeOnlyOnce()
{
if (ShouldSkipDbTests())
{
return;
}
var manager = CreateManager();
var user = CreateTestUser();
IdentityResultAssert.IsSuccess(await manager.CreateAsync(user));
var numCodes = 15;
var newCodes = await manager.GenerateNewTwoFactorRecoveryCodesAsync(user, numCodes);
Assert.Equal(numCodes, newCodes.Count());
foreach (var code in newCodes)
{
IdentityResultAssert.IsSuccess(await manager.RedeemTwoFactorRecoveryCodeAsync(user, code));
IdentityResultAssert.IsFailure(await manager.RedeemTwoFactorRecoveryCodeAsync(user, code));
}
// One last time to be sure
foreach (var code in newCodes)
{
IdentityResultAssert.IsFailure(await manager.RedeemTwoFactorRecoveryCodeAsync(user, code));
}
}
/// <summary>
/// Test.
/// </summary>
/// <returns>Task</returns>
[Fact]
public async Task RecoveryCodesInvalidAfterReplace()
{
if (ShouldSkipDbTests())
{
return;
}
var manager = CreateManager();
var user = CreateTestUser();
IdentityResultAssert.IsSuccess(await manager.CreateAsync(user));
var numCodes = 15;
var newCodes = await manager.GenerateNewTwoFactorRecoveryCodesAsync(user, numCodes);
Assert.Equal(numCodes, newCodes.Count());
var realCodes = await manager.GenerateNewTwoFactorRecoveryCodesAsync(user, numCodes);
Assert.Equal(numCodes, realCodes.Count());
foreach (var code in newCodes)
{
IdentityResultAssert.IsFailure(await manager.RedeemTwoFactorRecoveryCodeAsync(user, code));
}
foreach (var code in realCodes)
{
IdentityResultAssert.IsSuccess(await manager.RedeemTwoFactorRecoveryCodeAsync(user, code));
}
}
/// <summary>
/// Test.
/// </summary>
@ -2408,6 +2473,11 @@ namespace Microsoft.AspNetCore.Identity.Test
Assert.NotNull(factors);
Assert.Equal(1, factors.Count());
Assert.Equal("Phone", factors[0]);
IdentityResultAssert.IsSuccess(await manager.ResetAuthenticatorKeyAsync(user));
factors = await manager.GetValidTwoFactorProvidersAsync(user);
Assert.NotNull(factors);
Assert.Equal(2, factors.Count());
Assert.Equal("Authenticator", factors[1]);
}
/// <summary>

View File

@ -0,0 +1,71 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Security.Cryptography;
using System.Threading.Tasks;
namespace Microsoft.AspNetCore.Identity
{
/// <summary>
/// Used for authenticator code verification.
/// </summary>
public class AuthenticatorTokenProvider<TUser> : IUserTwoFactorTokenProvider<TUser> where TUser : class
{
/// <summary>
/// Checks if a two factor authentication token can be generated for the specified <paramref name="user"/>.
/// </summary>
/// <param name="manager">The <see cref="UserManager{TUser}"/> to retrieve the <paramref name="user"/> from.</param>
/// <param name="user">The <typeparamref name="TUser"/> to check for the possibility of generating a two factor authentication token.</param>
/// <returns>True if the user has an authenticator key set, otherwise false.</returns>
public async virtual Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<TUser> manager, TUser user)
{
var key = await manager.GetAuthenticatorKeyAsync(user);
return !string.IsNullOrWhiteSpace(key);
}
/// <summary>
/// Returns an empty string since no authenticator codes are sent.
/// </summary>
/// <param name="purpose">Ignored.</param>
/// <param name="manager">The <see cref="UserManager{TUser}"/> to retrieve the <paramref name="user"/> from.</param>
/// <param name="user">The <typeparamref name="TUser"/>.</param>
/// <returns>string.Empty.</returns>
public virtual Task<string> GenerateAsync(string purpose, UserManager<TUser> manager, TUser user)
{
return Task.FromResult(string.Empty);
}
/// <summary>
///
/// </summary>
/// <param name="purpose"></param>
/// <param name="token"></param>
/// <param name="manager"></param>
/// <param name="user"></param>
/// <returns></returns>
public virtual async Task<bool> ValidateAsync(string purpose, string token, UserManager<TUser> manager, TUser user)
{
var key = await manager.GetAuthenticatorKeyAsync(user);
int code;
if (!int.TryParse(token, out code))
{
return false;
}
var hash = new HMACSHA1(Base32.FromBase32(key));
var unixTimestamp = Convert.ToInt64(Math.Round((DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds));
var timestep = Convert.ToInt64(unixTimestamp / 30);
// Allow codes from 90s in each direction (we could make this configurable?)
for (int i = -2; i <= 2; i++)
{
var expectedCode = Rfc6238AuthenticationService.ComputeTotp(hash, (ulong)(timestep + i), modifier: null);
if (expectedCode == code)
{
return true;
}
}
return false;
}
}
}

View File

@ -0,0 +1,119 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Text;
namespace Microsoft.AspNetCore.Identity
{
// See http://tools.ietf.org/html/rfc3548#section-5
internal static class Base32
{
private static readonly string _base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
public static string ToBase32(byte[] input)
{
if (input == null)
{
throw new ArgumentNullException(nameof(input));
}
StringBuilder sb = new StringBuilder();
for (int offset = 0; offset < input.Length;)
{
byte a, b, c, d, e, f, g, h;
int numCharsToOutput = GetNextGroup(input, ref offset, out a, out b, out c, out d, out e, out f, out g, out h);
sb.Append((numCharsToOutput >= 1) ? _base32Chars[a] : '=');
sb.Append((numCharsToOutput >= 2) ? _base32Chars[b] : '=');
sb.Append((numCharsToOutput >= 3) ? _base32Chars[c] : '=');
sb.Append((numCharsToOutput >= 4) ? _base32Chars[d] : '=');
sb.Append((numCharsToOutput >= 5) ? _base32Chars[e] : '=');
sb.Append((numCharsToOutput >= 6) ? _base32Chars[f] : '=');
sb.Append((numCharsToOutput >= 7) ? _base32Chars[g] : '=');
sb.Append((numCharsToOutput >= 8) ? _base32Chars[h] : '=');
}
return sb.ToString();
}
public static byte[] FromBase32(string input)
{
if (input == null)
{
throw new ArgumentNullException(nameof(input));
}
input = input.TrimEnd('=').ToUpperInvariant();
if (input.Length == 0)
{
return new byte[0];
}
var output = new byte[input.Length * 5 / 8];
var bitIndex = 0;
var inputIndex = 0;
var outputBits = 0;
var outputIndex = 0;
while (outputIndex < output.Length)
{
var byteIndex = _base32Chars.IndexOf(input[inputIndex]);
if (byteIndex < 0)
{
throw new FormatException();
}
var bits = Math.Min(5 - bitIndex, 8 - outputBits);
output[outputIndex] <<= bits;
output[outputIndex] |= (byte)(byteIndex >> (5 - (bitIndex + bits)));
bitIndex += bits;
if (bitIndex >= 5)
{
inputIndex++;
bitIndex = 0;
}
outputBits += bits;
if (outputBits >= 8)
{
outputIndex++;
outputBits = 0;
}
}
return output;
}
// returns the number of bytes that were output
private static int GetNextGroup(byte[] input, ref int offset, out byte a, out byte b, out byte c, out byte d, out byte e, out byte f, out byte g, out byte h)
{
uint b1, b2, b3, b4, b5;
int retVal;
switch (offset - input.Length)
{
case 1: retVal = 2; break;
case 2: retVal = 4; break;
case 3: retVal = 5; break;
case 4: retVal = 7; break;
default: retVal = 8; break;
}
b1 = (offset < input.Length) ? input[offset++] : 0U;
b2 = (offset < input.Length) ? input[offset++] : 0U;
b3 = (offset < input.Length) ? input[offset++] : 0U;
b4 = (offset < input.Length) ? input[offset++] : 0U;
b5 = (offset < input.Length) ? input[offset++] : 0U;
a = (byte)(b1 >> 3);
b = (byte)(((b1 & 0x07) << 2) | (b2 >> 6));
c = (byte)((b2 >> 1) & 0x1f);
d = (byte)(((b2 & 0x01) << 4) | (b3 >> 4));
e = (byte)(((b3 & 0x0f) << 1) | (b4 >> 7));
f = (byte)((b4 >> 2) & 0x1f);
g = (byte)(((b4 & 0x3) << 3) | (b5 >> 5));
h = (byte)(b5 & 0x1f);
return retVal;
}
}
}

View File

@ -0,0 +1,33 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.AspNetCore.Identity
{
/// <summary>
/// Provides an abstraction for a store which stores info about user's authenticator.
/// </summary>
/// <typeparam name="TUser">The type encapsulating a user.</typeparam>
public interface IUserAuthenticatorKeyStore<TUser> : IUserStore<TUser> where TUser : class
{
/// <summary>
/// Sets the authenticator key for the specified <paramref name="user"/>.
/// </summary>
/// <param name="user">The user whose authenticator key should be set.</param>
/// <param name="key">The authenticator key to set.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>The <see cref="Task"/> that represents the asynchronous operation.</returns>
Task SetAuthenticatorKeyAsync(TUser user, string key, CancellationToken cancellationToken);
/// <summary>
/// Get the authenticator key for the specified <paramref name="user" />.
/// </summary>
/// <param name="user">The user whose security stamp should be set.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>The <see cref="Task"/> that represents the asynchronous operation, containing the security stamp for the specified <paramref name="user"/>.</returns>
Task<string> GetAuthenticatorKeyAsync(TUser user, CancellationToken cancellationToken);
}
}

View File

@ -0,0 +1,35 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.AspNetCore.Identity
{
/// <summary>
/// Provides an abstraction for a store which stores a user's recovery codes.
/// </summary>
/// <typeparam name="TUser">The type encapsulating a user.</typeparam>
public interface IUserTwoFactorRecoveryCodeStore<TUser> : IUserStore<TUser> where TUser : class
{
/// <summary>
/// Updates the recovery codes for the user while invalidating any previous recovery codes.
/// </summary>
/// <param name="user">The user to store new recovery codes for.</param>
/// <param name="recoveryCodes">The new recovery codes for the user.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>The new recovery codes for the user.</returns>
Task ReplaceCodesAsync(TUser user, IEnumerable<string> recoveryCodes, CancellationToken cancellationToken);
/// <summary>
/// Returns whether a recovery code is valid for a user. Note: recovery codes are only valid
/// once, and will be invalid after use.
/// </summary>
/// <param name="user">The user who owns the recovery code.</param>
/// <param name="code">The recovery code to use.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>True if the recovery code was found for the user.</returns>
Task<bool> RedeemCodeAsync(TUser user, string code, CancellationToken cancellationToken);
}
}

View File

@ -169,9 +169,11 @@ namespace Microsoft.AspNetCore.Identity
var dataProtectionProviderType = typeof(DataProtectorTokenProvider<>).MakeGenericType(UserType);
var phoneNumberProviderType = typeof(PhoneNumberTokenProvider<>).MakeGenericType(UserType);
var emailTokenProviderType = typeof(EmailTokenProvider<>).MakeGenericType(UserType);
var authenticatorProviderType = typeof(AuthenticatorTokenProvider<>).MakeGenericType(UserType);
return AddTokenProvider(TokenOptions.DefaultProvider, dataProtectionProviderType)
.AddTokenProvider(TokenOptions.DefaultEmailProvider, emailTokenProviderType)
.AddTokenProvider(TokenOptions.DefaultPhoneProvider, phoneNumberProviderType);
.AddTokenProvider(TokenOptions.DefaultPhoneProvider, phoneNumberProviderType)
.AddTokenProvider(TokenOptions.DefaultAuthenticatorProvider, authenticatorProviderType);
}
/// <summary>

View File

@ -63,6 +63,19 @@ namespace Microsoft.AspNetCore.Identity
};
}
/// <summary>
/// Returns an <see cref="IdentityError"/> indicating a recovery code was not redeemed.
/// </summary>
/// <returns>An <see cref="IdentityError"/> indicating a recovery code was not redeemed.</returns>
public virtual IdentityError RecoveryCodeRedemptionFailed()
{
return new IdentityError
{
Code = nameof(RecoveryCodeRedemptionFailed),
Description = Resources.RecoveryCodeRedemptionFailed
};
}
/// <summary>
/// Returns an <see cref="IdentityError"/> indicating an external login is already associated with an account.
/// </summary>

View File

@ -426,6 +426,22 @@ namespace Microsoft.AspNetCore.Identity
return GetString("StoreNotIRoleClaimStore");
}
/// <summary>
/// Store does not implement IUserAuthenticationTokenStore&lt;User&gt;.
/// </summary>
internal static string StoreNotIUserAuthenticationTokenStore
{
get { return GetString("StoreNotIUserAuthenticationTokenStore"); }
}
/// <summary>
/// Store does not implement IUserAuthenticationTokenStore&lt;User&gt;.
/// </summary>
internal static string FormatStoreNotIUserAuthenticationTokenStore()
{
return GetString("StoreNotIUserAuthenticationTokenStore");
}
/// <summary>
/// Store does not implement IUserClaimStore&lt;TUser&gt;.
/// </summary>
@ -570,6 +586,22 @@ namespace Microsoft.AspNetCore.Identity
return GetString("StoreNotIUserSecurityStampStore");
}
/// <summary>
/// Store does not implement IUserAuthenticatorKeyStore&lt;User&gt;.
/// </summary>
internal static string StoreNotIUserAuthenticatorKeyStore
{
get { return GetString("StoreNotIUserAuthenticatorKeyStore"); }
}
/// <summary>
/// Store does not implement IUserAuthenticatorKeyStore&lt;User&gt;.
/// </summary>
internal static string FormatStoreNotIUserAuthenticatorKeyStore()
{
return GetString("StoreNotIUserAuthenticatorKeyStore");
}
/// <summary>
/// Store does not implement IUserTwoFactorStore&lt;TUser&gt;.
/// </summary>
@ -586,6 +618,22 @@ namespace Microsoft.AspNetCore.Identity
return GetString("StoreNotIUserTwoFactorStore");
}
/// <summary>
/// Recovery code redemption failed.
/// </summary>
internal static string RecoveryCodeRedemptionFailed
{
get { return GetString("RecoveryCodeRedemptionFailed"); }
}
/// <summary>
/// Recovery code redemption failed.
/// </summary>
internal static string FormatRecoveryCodeRedemptionFailed()
{
return GetString("RecoveryCodeRedemptionFailed");
}
/// <summary>
/// User already has a password set.
/// </summary>
@ -682,6 +730,22 @@ namespace Microsoft.AspNetCore.Identity
return string.Format(CultureInfo.CurrentCulture, GetString("UserNotInRole"), p0);
}
/// <summary>
/// Store does not implement IUserTwoFactorRecoveryCodeStore&lt;User&gt;.
/// </summary>
internal static string StoreNotIUserTwoFactorRecoveryCodeStore
{
get { return GetString("StoreNotIUserTwoFactorRecoveryCodeStore"); }
}
/// <summary>
/// Store does not implement IUserTwoFactorRecoveryCodeStore&lt;User&gt;.
/// </summary>
internal static string FormatStoreNotIUserTwoFactorRecoveryCodeStore()
{
return GetString("StoreNotIUserTwoFactorRecoveryCodeStore");
}
private static string GetString(string name, params string[] formatterNames)
{
var value = _resourceManager.GetString(name);

View File

@ -221,6 +221,10 @@
<value>Store does not implement IRoleClaimStore&lt;TRole&gt;.</value>
<comment>Error when the store does not implement this interface</comment>
</data>
<data name="StoreNotIUserAuthenticationTokenStore" xml:space="preserve">
<value>Store does not implement IUserAuthenticationTokenStore&lt;User&gt;.</value>
<comment>Error when the store does not implement this interface</comment>
</data>
<data name="StoreNotIUserClaimStore" xml:space="preserve">
<value>Store does not implement IUserClaimStore&lt;TUser&gt;.</value>
<comment>Error when the store does not implement this interface</comment>
@ -257,10 +261,18 @@
<value>Store does not implement IUserSecurityStampStore&lt;TUser&gt;.</value>
<comment>Error when the store does not implement this interface</comment>
</data>
<data name="StoreNotIUserAuthenticatorKeyStore" xml:space="preserve">
<value>Store does not implement IUserAuthenticatorKeyStore&lt;User&gt;.</value>
<comment>Error when the store does not implement this interface</comment>
</data>
<data name="StoreNotIUserTwoFactorStore" xml:space="preserve">
<value>Store does not implement IUserTwoFactorStore&lt;TUser&gt;.</value>
<comment>Error when the store does not implement this interface</comment>
</data>
<data name="RecoveryCodeRedemptionFailed" xml:space="preserve">
<value>Recovery code redemption failed.</value>
<comment>Error when a recovery code is not redeemed.</comment>
</data>
<data name="UserAlreadyHasPassword" xml:space="preserve">
<value>User already has a password set.</value>
<comment>Error when AddPasswordAsync called when a user already has a password</comment>
@ -285,4 +297,8 @@
<value>User is not in role '{0}'.</value>
<comment>Error when a user is not in the role</comment>
</data>
<data name="StoreNotIUserTwoFactorRecoveryCodeStore" xml:space="preserve">
<value>Store does not implement IUserTwoFactorRecoveryCodeStore&lt;User&gt;.</value>
<comment>Error when the store does not implement this interface</comment>
</data>
</root>

View File

@ -1,21 +1,31 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Diagnostics;
using System.Net;
using System.Security.Cryptography;
using System.Text;
namespace Microsoft.AspNetCore.Identity
{
using System;
using System.Text;
internal static class Rfc6238AuthenticationService
{
private static readonly DateTime _unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly TimeSpan _timestep = TimeSpan.FromMinutes(3);
private static readonly Encoding _encoding = new UTF8Encoding(false, true);
private static readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create();
private static int ComputeTotp(HashAlgorithm hashAlgorithm, ulong timestepNumber, string modifier)
// Generates a new 80-bit security token
public static byte[] GenerateRandomKey()
{
byte[] bytes = new byte[20];
_rng.GetBytes(bytes);
return bytes;
}
internal static int ComputeTotp(HashAlgorithm hashAlgorithm, ulong timestepNumber, string modifier)
{
// # of 0's = length of pin
const int Mod = 1000000;

View File

@ -5,6 +5,8 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
@ -362,6 +364,92 @@ namespace Microsoft.AspNetCore.Identity
return Context.Authentication.SignOutAsync(Options.Cookies.TwoFactorRememberMeCookieAuthenticationScheme);
}
/// <summary>
/// Signs in the user without two factor authentication using a two factor recovery code.
/// </summary>
/// <param name="recoveryCode">The two factor recovery code.</param>
/// <returns></returns>
public virtual async Task<SignInResult> TwoFactorRecoveryCodeSignInAsync(string recoveryCode)
{
var twoFactorInfo = await RetrieveTwoFactorInfoAsync();
if (twoFactorInfo == null || twoFactorInfo.UserId == null)
{
return SignInResult.Failed;
}
var user = await UserManager.FindByIdAsync(twoFactorInfo.UserId);
if (user == null)
{
return SignInResult.Failed;
}
var result = await UserManager.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode);
if (result.Succeeded)
{
await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent: false, rememberClient: false);
return SignInResult.Success;
}
// We don't protect against brute force attacks since codes are expected to be random.
return SignInResult.Failed;
}
private async Task DoTwoFactorSignInAsync(TUser user, TwoFactorAuthenticationInfo twoFactorInfo, bool isPersistent, bool rememberClient)
{
// When token is verified correctly, clear the access failed count used for lockout
await ResetLockout(user);
// Cleanup external cookie
if (twoFactorInfo.LoginProvider != null)
{
await Context.Authentication.SignOutAsync(Options.Cookies.ExternalCookieAuthenticationScheme);
}
// Cleanup two factor user id cookie
await Context.Authentication.SignOutAsync(Options.Cookies.TwoFactorUserIdCookieAuthenticationScheme);
if (rememberClient)
{
await RememberTwoFactorClientAsync(user);
}
await SignInAsync(user, isPersistent, twoFactorInfo.LoginProvider);
}
/// <summary>
/// Validates the sign in code from an authenticator app and creates and signs in the user, as an asynchronous operation.
/// </summary>
/// <param name="code">The two factor authentication code to validate.</param>
/// <param name="isPersistent">Flag indicating whether the sign-in cookie should persist after the browser is closed.</param>
/// <param name="rememberClient">Flag indicating whether the current browser should be remember, suppressing all further
/// two factor authentication prompts.</param>
/// <returns>The task object representing the asynchronous operation containing the <see name="SignInResult"/>
/// for the sign-in attempt.</returns>
public virtual async Task<SignInResult> TwoFactorAuthenticatorSignInAsync(string code, bool isPersistent, bool rememberClient)
{
var twoFactorInfo = await RetrieveTwoFactorInfoAsync();
if (twoFactorInfo == null || twoFactorInfo.UserId == null)
{
return SignInResult.Failed;
}
var user = await UserManager.FindByIdAsync(twoFactorInfo.UserId);
if (user == null)
{
return SignInResult.Failed;
}
var error = await PreSignInCheck(user);
if (error != null)
{
return error;
}
if (await UserManager.VerifyTwoFactorTokenAsync(user, Options.Tokens.AuthenticatorTokenProvider, code))
{
await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent, rememberClient);
return SignInResult.Success;
}
// If the token is incorrect, record the failure which also may cause the user to be locked out
await UserManager.AccessFailedAsync(user);
return SignInResult.Failed;
}
/// <summary>
/// Validates the two faction sign in code and creates and signs in the user, as an asynchronous operation.
/// </summary>
@ -393,21 +481,7 @@ namespace Microsoft.AspNetCore.Identity
}
if (await UserManager.VerifyTwoFactorTokenAsync(user, provider, code))
{
// When token is verified correctly, clear the access failed count used for lockout
await ResetLockout(user);
// Cleanup external cookie
if (twoFactorInfo.LoginProvider != null)
{
await Context.Authentication.SignOutAsync(Options.Cookies.ExternalCookieAuthenticationScheme);
}
// Cleanup two factor user id cookie
await Context.Authentication.SignOutAsync(Options.Cookies.TwoFactorUserIdCookieAuthenticationScheme);
if (rememberClient)
{
await RememberTwoFactorClientAsync(user);
}
await UserManager.ResetAccessFailedCountAsync(user);
await SignInAsync(user, isPersistent, twoFactorInfo.LoginProvider);
await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent, rememberClient);
return SignInResult.Success;
}
// If the token is incorrect, record the failure which also may cause the user to be locked out
@ -599,7 +673,6 @@ namespace Microsoft.AspNetCore.Identity
return identity;
}
private async Task<SignInResult> SignInOrTwoFactorAsync(TUser user, bool isPersistent, string loginProvider = null, bool bypassTwoFactor = false)
{
if (!bypassTwoFactor &&

View File

@ -26,6 +26,11 @@ namespace Microsoft.AspNetCore.Identity
/// </summary>
public static readonly string DefaultPhoneProvider = "Phone";
/// <summary>
/// Default token provider name used by the <see cref="AuthenticatorTokenProvider{TUser}"/>.
/// </summary>
public static readonly string DefaultAuthenticatorProvider = "Authenticator";
/// <summary>
/// Will be used to construct UserTokenProviders with the key used as the providerName.
/// </summary>
@ -54,5 +59,13 @@ namespace Microsoft.AspNetCore.Identity
/// The <see cref="ChangeEmailTokenProvider"/> used to generate tokens used in email change confirmation emails.
/// </value>
public string ChangeEmailTokenProvider { get; set; } = DefaultProvider;
/// <summary>
/// Gets or sets the <see cref="AuthenticatorTokenProvider"/> used to validate two factor sign ins with an authenticator.
/// </summary>
/// <value>
/// The <see cref="AuthenticatorTokenProvider"/> used to validate two factor sign ins with an authenticator.
/// </value>
public string AuthenticatorTokenProvider { get; set; } = DefaultAuthenticatorProvider;
}
}

View File

@ -158,7 +158,7 @@ namespace Microsoft.AspNetCore.Identity
/// Gets a flag indicating whether the backing user store supports authentication tokens.
/// </summary>
/// <value>
/// true if the backing user store supports authentication tokens, otherwise false.
/// true if the backing user store supports authentication tokens, otherwise false.
/// </value>
public virtual bool SupportsUserAuthenticationTokens
{
@ -169,6 +169,36 @@ namespace Microsoft.AspNetCore.Identity
}
}
/// <summary>
/// Gets a flag indicating whether the backing user store supports a user authenticator.
/// </summary>
/// <value>
/// true if the backing user store supports a user authenticatior, otherwise false.
/// </value>
public virtual bool SupportsUserAuthenticatorKey
{
get
{
ThrowIfDisposed();
return Store is IUserAuthenticatorKeyStore<TUser>;
}
}
/// <summary>
/// Gets a flag indicating whether the backing user store supports recovery codes.
/// </summary>
/// <value>
/// true if the backing user store supports a user authenticatior, otherwise false.
/// </value>
public virtual bool SupportsUserTwoFactorRecoveryCodes
{
get
{
ThrowIfDisposed();
return Store is IUserTwoFactorRecoveryCodeStore<TUser>;
}
}
/// <summary>
/// Gets a flag indicating whether the backing user store supports two factor authentication.
/// </summary>
@ -2013,11 +2043,11 @@ namespace Microsoft.AspNetCore.Identity
/// <param name="user"></param>
/// <param name="loginProvider">The authentication scheme for the provider the token is associated with.</param>
/// <param name="tokenName">The name of the token.</param>
/// <returns></returns>
/// <returns>The authentication token for a user</returns>
public virtual Task<string> GetAuthenticationTokenAsync(TUser user, string loginProvider, string tokenName)
{
ThrowIfDisposed();
var store = GetTokenStore();
var store = GetAuthenticationTokenStore();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
@ -2041,11 +2071,11 @@ namespace Microsoft.AspNetCore.Identity
/// <param name="loginProvider">The authentication scheme for the provider the token is associated with.</param>
/// <param name="tokenName">The name of the token.</param>
/// <param name="tokenValue">The value of the token.</param>
/// <returns></returns>
/// <returns>Whether the user was successfully updated.</returns>
public virtual async Task<IdentityResult> SetAuthenticationTokenAsync(TUser user, string loginProvider, string tokenName, string tokenValue)
{
ThrowIfDisposed();
var store = GetTokenStore();
var store = GetAuthenticationTokenStore();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
@ -2074,7 +2104,7 @@ namespace Microsoft.AspNetCore.Identity
public virtual async Task<IdentityResult> RemoveAuthenticationTokenAsync(TUser user, string loginProvider, string tokenName)
{
ThrowIfDisposed();
var store = GetTokenStore();
var store = GetAuthenticationTokenStore();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
@ -2092,6 +2122,110 @@ namespace Microsoft.AspNetCore.Identity
return await UpdateUserAsync(user);
}
/// <summary>
/// Returns the authenticator key for the user.
/// </summary>
/// <param name="user">The user.</param>
/// <returns>The authenticator key</returns>
public virtual Task<string> GetAuthenticatorKeyAsync(TUser user)
{
ThrowIfDisposed();
var store = GetAuthenticatorKeyStore();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
return store.GetAuthenticatorKeyAsync(user, CancellationToken);
}
/// <summary>
/// Resets the authenticator key for the user.
/// </summary>
/// <param name="user">The user.</param>
/// <returns>Whether the user was successfully updated.</returns>
public virtual async Task<IdentityResult> ResetAuthenticatorKeyAsync(TUser user)
{
ThrowIfDisposed();
var store = GetAuthenticatorKeyStore();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
await store.SetAuthenticatorKeyAsync(user, GenerateNewAuthenticatorKey(), CancellationToken);
return await UpdateAsync(user);
}
/// <summary>
/// Generates a new base32 encoded 160-bit security secret (size of SHA1 hash).
/// </summary>
/// <returns>The new security secret.</returns>
public virtual string GenerateNewAuthenticatorKey()
{
return Base32.ToBase32(Rfc6238AuthenticationService.GenerateRandomKey());
}
/// <summary>
/// Generates recovery codes for the user, this invalidates any previous recovery codes for the user.
/// </summary>
/// <param name="user">The user to generate recovery codes for.</param>
/// <param name="number">The number of codes to generate.</param>
/// <returns>The new recovery codes for the user.</returns>
public virtual async Task<IEnumerable<string>> GenerateNewTwoFactorRecoveryCodesAsync(TUser user, int number)
{
ThrowIfDisposed();
var store = GetRecoveryCodeStore();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
var newCodes = new List<string>(number);
for (var i = 0; i < number; i++)
{
newCodes.Add(CreateTwoFactorRecoveryCode());
}
await store.ReplaceCodesAsync(user, newCodes, CancellationToken);
var update = await UpdateAsync(user);
if (update.Succeeded)
{
return newCodes;
}
return null;
}
/// <summary>
/// Generate a new recovery code.
/// </summary>
/// <returns></returns>
protected virtual string CreateTwoFactorRecoveryCode()
{
return Guid.NewGuid().ToString();
}
/// <summary>
/// Returns whether a recovery code is valid for a user. Note: recovery codes are only valid
/// once, and will be invalid after use.
/// </summary>
/// <param name="user">The user who owns the recovery code.</param>
/// <param name="code">The recovery code to use.</param>
/// <returns>True if the recovery code was found for the user.</returns>
public virtual async Task<IdentityResult> RedeemTwoFactorRecoveryCodeAsync(TUser user, string code)
{
ThrowIfDisposed();
var store = GetRecoveryCodeStore();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
var success = await store.RedeemCodeAsync(user, code, CancellationToken);
if (success)
{
return await UpdateAsync(user);
}
return IdentityResult.Failed(ErrorDescriber.RecoveryCodeRedemptionFailed());
}
/// <summary>
/// Releases the unmanaged resources used by the role manager and optionally releases the managed resources.
@ -2292,12 +2426,32 @@ namespace Microsoft.AspNetCore.Identity
return await Store.UpdateAsync(user, CancellationToken);
}
private IUserAuthenticationTokenStore<TUser> GetTokenStore()
private IUserAuthenticatorKeyStore<TUser> GetAuthenticatorKeyStore()
{
var cast = Store as IUserAuthenticatorKeyStore<TUser>;
if (cast == null)
{
throw new NotSupportedException(Resources.StoreNotIUserAuthenticatorKeyStore);
}
return cast;
}
private IUserTwoFactorRecoveryCodeStore<TUser> GetRecoveryCodeStore()
{
var cast = Store as IUserTwoFactorRecoveryCodeStore<TUser>;
if (cast == null)
{
throw new NotSupportedException(Resources.StoreNotIUserTwoFactorRecoveryCodeStore);
}
return cast;
}
private IUserAuthenticationTokenStore<TUser> GetAuthenticationTokenStore()
{
var cast = Store as IUserAuthenticationTokenStore<TUser>;
if (cast == null)
{
throw new NotSupportedException("Resources.StoreNotIUserAuthenticationTokenStore");
throw new NotSupportedException(Resources.StoreNotIUserAuthenticationTokenStore);
}
return cast;
}

View File

@ -24,7 +24,9 @@ namespace Microsoft.AspNetCore.Identity.InMemory
IUserTwoFactorStore<TUser>,
IQueryableRoleStore<TRole>,
IRoleClaimStore<TRole>,
IUserAuthenticationTokenStore<TUser>
IUserAuthenticationTokenStore<TUser>,
IUserAuthenticatorKeyStore<TUser>,
IUserTwoFactorRecoveryCodeStore<TUser>
where TRole : TestRole
where TUser : TestUser
{
@ -548,6 +550,39 @@ namespace Microsoft.AspNetCore.Identity.InMemory
return Task.FromResult(tokenEntity?.TokenValue);
}
private const string AuthenticatorStoreLoginProvider = "[AspNetAuthenticatorStore]";
private const string AuthenticatorKeyTokenName = "AuthenticatorKey";
private const string RecoveryCodeTokenName = "RecoveryCodes";
public Task SetAuthenticatorKeyAsync(TUser user, string key, CancellationToken cancellationToken)
{
return SetTokenAsync(user, AuthenticatorStoreLoginProvider, AuthenticatorKeyTokenName, key, cancellationToken);
}
public Task<string> GetAuthenticatorKeyAsync(TUser user, CancellationToken cancellationToken)
{
return GetTokenAsync(user, AuthenticatorStoreLoginProvider, AuthenticatorKeyTokenName, cancellationToken);
}
public Task ReplaceCodesAsync(TUser user, IEnumerable<string> recoveryCodes, CancellationToken cancellationToken)
{
var mergedCodes = string.Join(";", recoveryCodes);
return SetTokenAsync(user, AuthenticatorStoreLoginProvider, RecoveryCodeTokenName, mergedCodes, cancellationToken);
}
public async Task<bool> RedeemCodeAsync(TUser user, string code, CancellationToken cancellationToken)
{
var mergedCodes = await GetTokenAsync(user, AuthenticatorStoreLoginProvider, RecoveryCodeTokenName, cancellationToken) ?? "";
var splitCodes = mergedCodes.Split(';');
if (splitCodes.Contains(code))
{
var updatedCodes = new List<string>(splitCodes.Where(s => s != code));
await ReplaceCodesAsync(user, updatedCodes, cancellationToken);
return true;
}
return false;
}
public IQueryable<TRole> Roles
{
get { return _roles.Values.AsQueryable(); }

View File

@ -140,7 +140,7 @@ namespace Microsoft.AspNetCore.Identity.Test
var provider = services.BuildServiceProvider();
var tokenProviders = provider.GetRequiredService<IOptions<IdentityOptions>>().Value.Tokens.ProviderMap.Values;
Assert.Equal(3, tokenProviders.Count());
Assert.Equal(4, tokenProviders.Count());
}
[Fact]

View File

@ -360,6 +360,111 @@ namespace Microsoft.AspNetCore.Identity.Test
auth.Verify();
}
private class GoodTokenProvider : AuthenticatorTokenProvider<TestUser>
{
public override Task<bool> ValidateAsync(string purpose, string token, UserManager<TestUser> manager, TestUser user)
{
return Task.FromResult(true);
}
}
[Theory]
[InlineData(null, true, true)]
[InlineData("Authenticator", false, true)]
[InlineData("Gooblygook", true, false)]
[InlineData("--", false, false)]
public async Task CanTwoFactorAuthenticatorSignIn(string providerName, bool isPersistent, bool rememberClient)
{
// Setup
var user = new TestUser { UserName = "Foo" };
const string code = "3123";
var manager = SetupUserManager(user);
manager.Setup(m => m.SupportsUserLockout).Returns(true).Verifiable();
manager.Setup(m => m.VerifyTwoFactorTokenAsync(user, providerName ?? TokenOptions.DefaultAuthenticatorProvider, code)).ReturnsAsync(true).Verifiable();
manager.Setup(m => m.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success).Verifiable();
var context = new Mock<HttpContext>();
var auth = new Mock<AuthenticationManager>();
var twoFactorInfo = new SignInManager<TestUser>.TwoFactorAuthenticationInfo { UserId = user.Id };
var helper = SetupSignInManager(manager.Object, context.Object);
if (providerName != null)
{
helper.Options.Tokens.AuthenticatorTokenProvider = providerName;
}
var id = helper.StoreTwoFactorInfo(user.Id, null);
SetupSignIn(auth, user.Id, isPersistent);
auth.Setup(a => a.AuthenticateAsync(helper.Options.Cookies.TwoFactorUserIdCookieAuthenticationScheme)).ReturnsAsync(id).Verifiable();
context.Setup(c => c.Authentication).Returns(auth.Object).Verifiable();
if (rememberClient)
{
auth.Setup(a => a.SignInAsync(
helper.Options.Cookies.TwoFactorRememberMeCookieAuthenticationScheme,
It.Is<ClaimsPrincipal>(i => i.FindFirstValue(ClaimTypes.Name) == user.Id
&& i.Identities.First().AuthenticationType == helper.Options.Cookies.TwoFactorRememberMeCookieAuthenticationScheme),
It.IsAny<AuthenticationProperties>())).Returns(Task.FromResult(0)).Verifiable();
}
// Act
var result = await helper.TwoFactorAuthenticatorSignInAsync(code, isPersistent, rememberClient);
// Assert
Assert.True(result.Succeeded);
manager.Verify();
context.Verify();
auth.Verify();
}
[Theory]
[InlineData(true, true)]
[InlineData(true, false)]
[InlineData(false, true)]
[InlineData(false, false)]
public async Task CanTwoFactorRecoveryCodeSignIn(bool supportsLockout, bool externalLogin)
{
// Setup
var user = new TestUser { UserName = "Foo" };
const string bypassCode = "someCode";
var manager = SetupUserManager(user);
manager.Setup(m => m.SupportsUserLockout).Returns(supportsLockout).Verifiable();
manager.Setup(m => m.RedeemTwoFactorRecoveryCodeAsync(user, bypassCode)).ReturnsAsync(IdentityResult.Success).Verifiable();
if (supportsLockout)
{
manager.Setup(m => m.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success).Verifiable();
}
var context = new Mock<HttpContext>();
var auth = new Mock<AuthenticationManager>();
var twoFactorInfo = new SignInManager<TestUser>.TwoFactorAuthenticationInfo { UserId = user.Id };
var loginProvider = "loginprovider";
var helper = SetupSignInManager(manager.Object, context.Object);
var id = helper.StoreTwoFactorInfo(user.Id, externalLogin ? loginProvider : null);
if (externalLogin)
{
auth.Setup(a => a.SignInAsync(
helper.Options.Cookies.ApplicationCookieAuthenticationScheme,
It.Is<ClaimsPrincipal>(i => i.FindFirstValue(ClaimTypes.AuthenticationMethod) == loginProvider
&& i.FindFirstValue(ClaimTypes.NameIdentifier) == user.Id),
It.IsAny<AuthenticationProperties>())).Returns(Task.FromResult(0)).Verifiable();
auth.Setup(a => a.SignOutAsync(helper.Options.Cookies.ExternalCookieAuthenticationScheme)).Returns(Task.FromResult(0)).Verifiable();
auth.Setup(a => a.SignOutAsync(helper.Options.Cookies.TwoFactorUserIdCookieAuthenticationScheme)).Returns(Task.FromResult(0)).Verifiable();
}
else
{
SetupSignIn(auth, user.Id);
}
auth.Setup(a => a.AuthenticateAsync(helper.Options.Cookies.TwoFactorUserIdCookieAuthenticationScheme)).ReturnsAsync(id).Verifiable();
context.Setup(c => c.Authentication).Returns(auth.Object).Verifiable();
// Act
var result = await helper.TwoFactorRecoveryCodeSignInAsync(bypassCode);
// Assert
Assert.True(result.Succeeded);
manager.Verify();
context.Verify();
auth.Verify();
}
[Theory]
[InlineData(true, true)]
[InlineData(true, false)]

View File

@ -5,6 +5,8 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
@ -691,6 +693,146 @@ namespace Microsoft.AspNetCore.Identity.Test
Assert.ThrowsAsync<NotImplementedException>(() => manager.GenerateUserTokenAsync(new TestUser(), "A", "purpose"));
}
[Fact]
public void TOTPTest()
{
//var verify = new TotpAuthenticatorVerification();
//var secret = "abcdefghij";
//var secret = Base32.FromBase32(authKey);
// Assert.Equal(bytes, secret);
//var code = verify.VerifyCode(secret, -1);
//Assert.Equal(code, 287004);
//var bytes = new byte[] { (byte)'H', (byte)'e', (byte)'l', (byte)'l', (byte)'o', (byte)'!', (byte)0xDE, (byte)0xAD, (byte)0xBE, (byte)0xEF };
//var base32 = Base32.ToBase32(bytes);
// var code = Rfc6238AuthenticationService.GenerateCode(bytes);
// Assert.Equal(Rfc6238AuthenticationService.GenerateCode(bytes), Rfc6238AuthenticationService.CalculateOneTimePassword(new HMACSHA1(bytes)));
//Assert.True(Rfc6238AuthenticationService.ValidateCode(bytes, code));
}
public static byte[] ToBytes(string input)
{
if (string.IsNullOrEmpty(input))
{
throw new ArgumentNullException("input");
}
input = input.TrimEnd('='); //remove padding characters
int byteCount = input.Length * 5 / 8; //this must be TRUNCATED
byte[] returnArray = new byte[byteCount];
byte curByte = 0, bitsRemaining = 8;
int mask = 0, arrayIndex = 0;
foreach (char c in input)
{
int cValue = CharToValue(c);
if (bitsRemaining > 5)
{
mask = cValue << (bitsRemaining - 5);
curByte = (byte)(curByte | mask);
bitsRemaining -= 5;
}
else
{
mask = cValue >> (5 - bitsRemaining);
curByte = (byte)(curByte | mask);
returnArray[arrayIndex++] = curByte;
curByte = (byte)(cValue << (3 + bitsRemaining));
bitsRemaining += 3;
}
}
//if we didn't end with a full byte
if (arrayIndex != byteCount)
{
returnArray[arrayIndex] = curByte;
}
return returnArray;
}
public static string ToString(byte[] input)
{
if (input == null || input.Length == 0)
{
throw new ArgumentNullException("input");
}
int charCount = (int)Math.Ceiling(input.Length / 5d) * 8;
char[] returnArray = new char[charCount];
byte nextChar = 0, bitsRemaining = 5;
int arrayIndex = 0;
foreach (byte b in input)
{
nextChar = (byte)(nextChar | (b >> (8 - bitsRemaining)));
returnArray[arrayIndex++] = ValueToChar(nextChar);
if (bitsRemaining < 4)
{
nextChar = (byte)((b >> (3 - bitsRemaining)) & 31);
returnArray[arrayIndex++] = ValueToChar(nextChar);
bitsRemaining += 5;
}
bitsRemaining -= 3;
nextChar = (byte)((b << bitsRemaining) & 31);
}
//if we didn't end with a full char
if (arrayIndex != charCount)
{
returnArray[arrayIndex++] = ValueToChar(nextChar);
while (arrayIndex != charCount) returnArray[arrayIndex++] = '='; //padding
}
return new string(returnArray);
}
private static int CharToValue(char c)
{
var value = (int)c;
//65-90 == uppercase letters
if (value < 91 && value > 64)
{
return value - 65;
}
//50-55 == numbers 2-7
if (value < 56 && value > 49)
{
return value - 24;
}
//97-122 == lowercase letters
if (value < 123 && value > 96)
{
return value - 97;
}
throw new ArgumentException("Character is not a Base32 character.", "c");
}
private static char ValueToChar(byte b)
{
if (b < 26)
{
return (char)(b + 65);
}
if (b < 32)
{
return (char)(b + 24);
}
throw new ArgumentException("Byte is not a value Base32 value.", "b");
}
[Fact]
public void UserManagerWillUseTokenProviderInstanceOverDefaults()
{
@ -741,6 +883,43 @@ namespace Microsoft.AspNetCore.Identity.Test
await Assert.ThrowsAsync<NotSupportedException>(async () => await manager.IsInRoleAsync(null, "bogus"));
}
[Fact]
public async Task AuthTokenMethodsFailWhenStoreNotImplemented()
{
var error = Resources.StoreNotIUserAuthenticationTokenStore;
var manager = MockHelpers.TestUserManager(new NoopUserStore());
Assert.False(manager.SupportsUserAuthenticationTokens);
await VerifyException<NotSupportedException>(async () => await manager.GetAuthenticationTokenAsync(null, null, null), error);
await VerifyException<NotSupportedException>(async () => await manager.SetAuthenticationTokenAsync(null, null, null, null), error);
await VerifyException<NotSupportedException>(async () => await manager.RemoveAuthenticationTokenAsync(null, null, null), error);
}
[Fact]
public async Task AuthenticatorMethodsFailWhenStoreNotImplemented()
{
var error = Resources.StoreNotIUserAuthenticatorKeyStore;
var manager = MockHelpers.TestUserManager(new NoopUserStore());
Assert.False(manager.SupportsUserAuthenticatorKey);
await VerifyException<NotSupportedException>(async () => await manager.GetAuthenticatorKeyAsync(null), error);
await VerifyException<NotSupportedException>(async () => await manager.ResetAuthenticatorKeyAsync(null), error);
}
[Fact]
public async Task RecoveryMethodsFailWhenStoreNotImplemented()
{
var error = Resources.StoreNotIUserTwoFactorRecoveryCodeStore;
var manager = MockHelpers.TestUserManager(new NoopUserStore());
Assert.False(manager.SupportsUserTwoFactorRecoveryCodes);
await VerifyException<NotSupportedException>(async () => await manager.RedeemTwoFactorRecoveryCodeAsync(null, null), error);
await VerifyException<NotSupportedException>(async () => await manager.GenerateNewTwoFactorRecoveryCodesAsync(null, 10), error);
}
private async Task VerifyException<TException>(Func<Task> code, string expectedMessage) where TException : Exception
{
var error = await Assert.ThrowsAsync<TException>(code);
Assert.Equal(expectedMessage, error.Message);
}
[Fact]
public void DisposeAfterDisposeDoesNotThrow()
{