Skip to content

Adding Account Recovery Example #105

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jan 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@
<form class="needs-validation" action="" method="POST">
<div class="mb-3">
<label asp-for="Form.Email" class="form-label">Email</label>
<input placeholder="[email protected]" type="text" asp-for="Form.Email" class="form-control" id="email">
<input placeholder="[email protected]" type="email" asp-for="Form.Email" class="form-control" id="email">
<span class="text-danger" asp-validation-for="Form.Email"></span>
</div>
<div class="text-danger" asp-validation-summary="ModelOnly"></div>
<div>
<button type="submit" class="btn-primary">Login</button>
</div>
</form>

<a asp-page="/Account/Recovery">If you have lost your passkey, please click here.</a>
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@page
@using Microsoft.AspNetCore.Authorization
@model Passwordless.AspNetIdentity.Example.Pages.Account.Magic
@attribute [AllowAnonymous]

@{
ViewBag.Title = "Magic Link";
}

@if (!Model.Success)
{
<p>Sorry, something went wrong. The token was not valid.</p>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace Passwordless.AspNetIdentity.Example.Pages.Account;

public class Magic : PageModel
{
private readonly PasswordlessClient _passwordlessClient;
private readonly SignInManager<IdentityUser> _signInManager;

public Magic(PasswordlessClient passwordlessClient,
SignInManager<IdentityUser> signInManager)
{
_passwordlessClient = passwordlessClient;
_signInManager = signInManager;
}

public bool Success { get; set; }

public async Task<ActionResult> OnGet(string token, string email)
{
if (User.Identity is { IsAuthenticated: true }) return RedirectToPage("/Authorized/HelloWorld");

if (string.IsNullOrWhiteSpace(token))
{
Success = false;
return Page();
}

var user = await _signInManager.UserManager.FindByEmailAsync(email);

if (user == null || !(await _signInManager.CanSignInAsync(user)))
{
Success = false;
return Page();
}

var response = await _passwordlessClient.VerifyAuthenticationTokenAsync(token);

if (!response.Success) return Page();

await _signInManager.SignInAsync(user, true);
Success = true;
return LocalRedirect("/Authorized/HelloWorld");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
@page
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model Passwordless.AspNetIdentity.Example.Pages.Account.Recovery

@{
ViewData["Title"] = "Account Recovery";
}
<h1>@ViewData["Title"]</h1>

<p>
If a user loses their passkey, they can request a manually generated verification token and a "magic link" can be created
to authenticate them. They can then add a new credential in order to log back in safely. To send a magic link, enter your email
and a "magic link" will be used to authenticate the intended user.
</p>

<form method="POST">
<label asp-for="Form.Email">Email: </label>
<input asp-for="Form.Email" type="email" id="email" placeholder="[email protected]"/>
<span class="text-danger" asp-validation-for="Form.Email"></span>
<button type="submit" class="btn-primary">Send</button>
</form>

@if (!string.IsNullOrWhiteSpace(Model.RecoveryMessage))
{
<p>@Html.Raw(Model.RecoveryMessage)</p>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System;
using System.Collections.Specialized;
using System.ComponentModel.DataAnnotations;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.Extensions.Logging;
using Passwordless.Models;

namespace Passwordless.AspNetIdentity.Example.Pages.Account;

public class Recovery : PageModel
{
private readonly ILogger<Recovery> _logger;
private readonly SignInManager<IdentityUser> _signInManager;
private readonly PasswordlessClient _passwordlessClient;
private readonly IUrlHelperFactory _urlHelperFactory;
private readonly IActionContextAccessor _actionContextAccessor;

public RecoveryForm Form { get; } = new();

public string RecoveryMessage { get; set; } = string.Empty;

public Recovery(ILogger<Recovery> logger,
SignInManager<IdentityUser> signInManager,
PasswordlessClient passwordlessClient,
IUrlHelperFactory urlHelperFactory,
IActionContextAccessor actionContextAccessor)
{
_logger = logger;
_signInManager = signInManager;
_passwordlessClient = passwordlessClient;
_urlHelperFactory = urlHelperFactory;
_actionContextAccessor = actionContextAccessor;
}

public void OnGetSuccessfulRecovery(string message)
{
if (!string.IsNullOrWhiteSpace(message)) RecoveryMessage = message;
}

public async Task<IActionResult> OnPostAsync(RecoveryForm form, CancellationToken cancellationToken)
{
if (!ModelState.IsValid) return Page();

var user = await _signInManager.UserManager.FindByEmailAsync(form.Email!);

if (user == null) return Page();

_logger.LogInformation("Sending magic link.");

if (_actionContextAccessor.ActionContext == null)
{
_logger.LogError("ActionContext is null");
throw new InvalidOperationException("ActionContext is null");
}

var token = await _passwordlessClient.GenerateAuthenticationTokenAsync(new AuthenticationOptions(user.Id), cancellationToken);
var urlBuilder = _urlHelperFactory.GetUrlHelper(_actionContextAccessor.ActionContext);
var url = urlBuilder.PageLink("/Account/Magic") ?? urlBuilder.Content("~/");

var uriBuilder = new UriBuilder(url);
var query = HttpUtility.ParseQueryString(uriBuilder.Query);
query["token"] = token.Token;
query["email"] = user.Email;
uriBuilder.Query = query.ToString();

var message = $"""
New message:

Click the link to recover your account
<a href="{uriBuilder}">Link<a>
{Environment.NewLine}
""";

await System.IO.File.AppendAllTextAsync("mail.md", message, cancellationToken);

return RedirectToPage("Recovery", "SuccessfulRecovery", new { message });
}
}

public class RecoveryForm
{
[EmailAddress]
[Required]
public string? Email { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
@page
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using Microsoft.Extensions.Options
@using Passwordless.AspNetCore
@model HelloWorldModel
@inject IOptions<PasswordlessAspNetCoreOptions> PasswordlessOptions;

@{
ViewData["Title"] = "Hello World";
Expand Down Expand Up @@ -27,6 +31,48 @@
</div>
</div>
</div>
<form class="row my-2" action="" method="POST">
<div class="col-12">
<h3>Set new passkey</h3>
<div class="mb-3">
<label asp-for="Nickname">Nickname (Optional): </label>
<input type="text" asp-for="Nickname" class="form-control" />
</div>

<button type="submit" class="btn-primary">Add Passkey</button>
</div>
</form>


@if (Model.CanAddPassKeys)
{
<script src="https://cdn.passwordless.dev/dist/1.1.0/umd/passwordless.umd.js"></script>
<script>
async function addPasskey() {
const addCredentialResponse = await fetch('/passwordless-add-credential', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ displayName: '@Model.Nickname' })
});

// if no error then deserialize and use returned token to create now our passkeys
if (addCredentialResponse.ok) {
const registrationResponseJson = await addCredentialResponse.json();
const token = registrationResponseJson.token;

// we need to use Client from https://cdn.passwordless.dev/dist/1.1.0/umd/passwordless.umd.js which is imported above.
const p = new Passwordless.Client({
apiKey: "@PasswordlessOptions.Value.ApiKey",
apiUrl: "@PasswordlessOptions.Value.ApiUrl"
});
await p.register(token, '@Model.Nickname');
}
}
addPasskey();
</script>
}
}
else
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;

namespace Passwordless.AspNetIdentity.Example.Pages.Authorized;

public class HelloWorldModel : PageModel
public class HelloWorldModel(ILogger<HelloWorldModel> logger) : PageModel
{
public void OnGet()
{
Expand All @@ -12,7 +13,19 @@ public void OnGet()
AuthenticatedUser = new AuthenticatedUserModel(identity.Name!, email);
}

public void OnPost(string? nickname)
{
if (!ModelState.IsValid) return;
logger.LogInformation("Adding new credential for user {userName}", HttpContext.User.Identity!.Name);
CanAddPassKeys = true;
Nickname = nickname ?? HttpContext.User.Identity.Name;
}

public AuthenticatedUserModel? AuthenticatedUser { get; private set; }

public string? Nickname { get; set; }

public bool CanAddPassKeys { get; set; }
}

public record AuthenticatedUserModel(string Username, string Email);
3 changes: 3 additions & 0 deletions examples/Passwordless.AspNetIdentity.Example/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -15,6 +16,8 @@
.AddEntityFrameworkStores<PasswordlessContext>()
.AddPasswordless(builder.Configuration.GetRequiredSection("Passwordless"));

builder.Services.AddTransient<IActionContextAccessor, ActionContextAccessor>();

builder.Services.AddRazorPages(options =>
{
options.Conventions.AuthorizeFolder("/Authorized");
Expand Down