Skip to content
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

Step up v2 #135

Merged
merged 22 commits into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5a99f13
Step up v1 with multiple contexts
jrmccannon Mar 26, 2024
d582fca
Consolidated requirements. Added consts for the policy names.
jrmccannon Mar 27, 2024
65c1802
Switched to the v2 of step up.
jrmccannon Apr 1, 2024
f78c017
Allow for strings to be sent for signin purpose.
jrmccannon Apr 11, 2024
1c8e6d6
Merge branch 'refs/heads/main' into step-up-v2
jrmccannon Apr 24, 2024
345fa05
Using code from updated client library
jrmccannon Apr 24, 2024
08869f8
Rename to step up purpose
jrmccannon Apr 24, 2024
14530f9
Passing through purpose and adding it to the step up text
jrmccannon May 15, 2024
b114e55
Added Purpose to verified user.
jrmccannon Jun 10, 2024
dd16c43
Updating recovery to use Magic Links
jrmccannon Jun 10, 2024
603c15d
Added way to validate what token is being validated by showing claims
jrmccannon Jun 12, 2024
1a150f2
formatting. updating step up method.
jrmccannon Jun 13, 2024
53d8f5e
Added new testing ability for manually generated tokens and magic lin…
jrmccannon Jun 18, 2024
491982c
Merge branch 'main' into step-up-v2
jrmccannon Jun 18, 2024
117ba31
Removed copy pasted code and using cdn mjs.
jrmccannon Jun 21, 2024
16a83a0
Merge remote-tracking branch 'origin/step-up-v2' into step-up-v2
jrmccannon Jun 21, 2024
bed9f98
Registered generic VerifiedUser to use in tests
jrmccannon Jun 24, 2024
71bf3a6
formatting.
jrmccannon Jun 24, 2024
118e9bd
Removed testing items and changed auth policy to use default step up …
jrmccannon Jun 24, 2024
9d296b2
Used TimeProvider
jrmccannon Jun 25, 2024
3979bf2
Removed nullability
jrmccannon Jun 25, 2024
e1dd0ad
formatting
jrmccannon Jun 25, 2024
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
@@ -0,0 +1,50 @@
using System;
using System.Globalization;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;

namespace Passwordless.AspNetIdentity.Example.Authorization;

public interface IStepUpAuthorizationRequirement : IAuthorizationRequirement
{
public string Name { get; }
}

public class StepUpAuthorizationHandler(StepUpPurpose stepUpPurpose) : AuthorizationHandler<IStepUpAuthorizationRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IStepUpAuthorizationRequirement requirement)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might not be correct, usually you want a specific permission here that is required, and do the step up procedure separately to add the claim to your token which then validates afterwards if you have a specific claim I think.

{
if (context.User.Identity is not { IsAuthenticated: true })
{
return Task.CompletedTask;
}

if (context.User.HasClaim(MatchesClaim(requirement))
&& IsExpired(GetExpiration(context.User.FindFirst(MatchesClaim(requirement))!)))
{
context.Succeed(requirement);
}
else
{
stepUpPurpose.Purpose = requirement.Name;
context.Fail();
}

return Task.CompletedTask;
}

private static Predicate<Claim> MatchesClaim(IStepUpAuthorizationRequirement requirement) => claim => claim.Type == requirement.Name;

private static bool IsExpired(DateTime expiration)
{
return expiration > DateTime.UtcNow;
jrmccannon marked this conversation as resolved.
Show resolved Hide resolved
}

private static DateTime GetExpiration(Claim claim)
{
var expiration = DateTime.Parse(claim.Value, null, DateTimeStyles.RoundtripKind);

return expiration;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Passwordless.AspNetIdentity.Example.Authorization;

public class StepUpPurpose
{
public string Purpose { get; set; } = string.Empty;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Passwordless.AspNetIdentity.Example.Authorization;

public static class StepUpPurposes
{
public const string Elevated = "Elevated";
public const string SecondContext = "SecondContext";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Passwordless.AspNetIdentity.Example.Authorization;

public class StepUpRequirement(string policyName) : IStepUpAuthorizationRequirement
{
public string Name => policyName;
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,19 @@
<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>

@if (canLogin)
{
<script src="https://cdn.passwordless.dev/dist/1.1.0/umd/passwordless.umd.js"></script>
<script>
async function login() {
<script type="module">
import { Client } from "https://cdn.passwordless.dev/dist/1.2.0-beta1/esm/passwordless.mjs";

async function login() {
const alias = document.getElementById("email").value;
const p = new Passwordless.Client(
const p = new Client(
{
apiKey: "@PasswordlessOptions.Value.ApiKey",
apiUrl: "@PasswordlessOptions.Value.ApiUrl"
Expand All @@ -62,5 +63,7 @@
window.location.href = '/Authorized/HelloWorld'; }
}
login();


</script>
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
Expand All @@ -10,6 +12,11 @@ public class Logout(SignInManager<IdentityUser> userSignInManager, ILogger<Logou
{
public async Task<IActionResult> OnGet()
{
var userManager = userSignInManager.UserManager;

var user = await userManager.GetUserAsync(User);
if (user is not null) await userManager.RemoveClaimsAsync(user, await userManager.GetClaimsAsync(user));

await userSignInManager.SignOutAsync();
logger.LogInformation("User has signed out.");

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using System.Threading;
using System;
using System.Globalization;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -20,6 +23,8 @@ public Magic(PasswordlessClient passwordlessClient,

public bool Success { get; set; }

private static readonly string[] GeneratedTokenTypes = ["magic_link", "generated_signin"];

public async Task<ActionResult> OnGet(string token)
{
if (User.Identity is { IsAuthenticated: true }) return RedirectToPage("/Authorized/HelloWorld");
Expand Down Expand Up @@ -47,6 +52,13 @@ public async Task<ActionResult> OnGet(string token)
}

await _signInManager.SignInAsync(user, true);


if (GeneratedTokenTypes.Contains(response.Type))
{
await _signInManager.UserManager.AddClaimAsync(user, new Claim(response.Type, DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)));
}

Success = true;
return LocalRedirect("/Authorized/HelloWorld");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model Passwordless.AspNetIdentity.Example.Pages.Account.Recovery

<style lang="css">
.block-element {
display: block;
}
</style>

@{
ViewData["Title"] = "Account Recovery";
}
Expand All @@ -17,7 +23,10 @@ and a "magic link" will be used to authenticate the intended user.
<label asp-for="Form.Email">Email: </label>
<input asp-for="Form.Email" type="email" id="email" placeholder="janedoe@example.org"/>
<span class="text-danger" asp-validation-for="Form.Email"></span>
<button type="submit" class="btn-primary">Send</button>
<p>Recovery Method:</p>
<label class="block-element"><input type="radio" asp-for="Form.RecoveryMethod" value="@Recovery.GeneratedSignIn"/>Generated Sign In Token</label>
<label class="block-element"><input type="radio" asp-for="Form.RecoveryMethod" value="@Recovery.MagicLink"/>Magic Link</label>
<button type="submit" class="btn-primary block-element">Send</button>
</form>

@if (!string.IsNullOrWhiteSpace(Model.RecoveryMessage))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System;
using System.Collections.Specialized;
using System.ComponentModel.DataAnnotations;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -22,6 +21,9 @@ public class Recovery : PageModel
private readonly IUrlHelperFactory _urlHelperFactory;
private readonly IActionContextAccessor _actionContextAccessor;

public const string MagicLink = "magic_link";
public const string GeneratedSignIn = "generated_signin";

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

public string RecoveryMessage { get; set; } = string.Empty;
Expand Down Expand Up @@ -60,26 +62,41 @@ public async Task<IActionResult> OnPostAsync(RecoveryForm form, CancellationToke
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["token"] = string.Empty;
uriBuilder.Query = query.ToString();
var successMessage = string.Empty;

if (string.Equals(form.RecoveryMethod, MagicLink, StringComparison.OrdinalIgnoreCase))
{
await _passwordlessClient.SendMagicLinkAsync(new SendMagicLinkRequest(form.Email!, uriBuilder.Uri + "$TOKEN", user.Id, null), cancellationToken);

successMessage = "Check your API magic link destination (mail.md if running local, email if using cloud.";
}
else if (string.Equals(form.RecoveryMethod, GeneratedSignIn, StringComparison.OrdinalIgnoreCase))
{
var token = await _passwordlessClient.GenerateAuthenticationTokenAsync(new AuthenticationOptions(user.Id), cancellationToken);
query["token"] = token.Token;
uriBuilder.Query = query.ToString();

var message = $"""
New message:

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

await System.IO.File.AppendAllTextAsync("mail.md", message, cancellationToken);
This was generated with manually generated authentication token.
<a href="{uriBuilder}">Link<a>
{Environment.NewLine}
""";

return RedirectToPage("Recovery", "SuccessfulRecovery", new { message });
await System.IO.File.AppendAllTextAsync("mail.md", message, cancellationToken);

successMessage = message;
}

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

Expand All @@ -88,4 +105,7 @@ public class RecoveryForm
[EmailAddress]
[Required]
public string? Email { get; set; }
jrmccannon marked this conversation as resolved.
Show resolved Hide resolved

[Required]
public string RecoveryMethod { get; set; } = string.Empty;
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@

@if (canAddPasskeys)
{
<script src="https://cdn.passwordless.dev/dist/1.1.0/umd/passwordless.umd.js"></script>
<script>
import { Client } from "https://cdn.passwordless.dev/dist/1.2.0-beta1/esm/passwordless.mjs";

async function register() {
const username = document.getElementById("username").value;
const email = document.getElementById("email").value;
Expand All @@ -60,8 +61,8 @@
const registrationResponseJson = await registrationResponse.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(
// we need to use Client from https://cdn.passwordless.dev/dist/1.2.0-beta1/esm/passwordless.mjs which is imported above.
const p = new Client(
{
apiKey: "@PasswordlessOptions.Value.ApiKey",
apiUrl: "@PasswordlessOptions.Value.ApiUrl"
Expand All @@ -70,5 +71,6 @@
}
}
register();

</script>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@page
@model Passwordless.AspNetIdentity.Example.Pages.Authorized.ElevatedAuthentication

@{
ViewData["Title"] = "Elevated Auth";
}
<h1>@ViewData["Title"]</h1>

<p>You should feel elevated.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Passwordless.AspNetIdentity.Example.Authorization;

namespace Passwordless.AspNetIdentity.Example.Pages.Authorized;

[Authorize(Policy = StepUpPurposes.Elevated)]
public class ElevatedAuthentication : PageModel
{
public void OnGet()
{
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,29 @@
<button type="submit" class="btn-primary">Add Passkey</button>
</div>
</form>
<div>
<h3>Current Claims:</h3>
<ul>
@foreach (var claim in Model.Claims)
{
<li>@claim.ClaimName : @claim.ClaimValue</li>
}
</ul>
</div>

<a asp-page="ElevatedAuthentication">To Elevated Area</a>

<a asp-page="SecondContext">To Other Area</a>

@if (Model.CanAddPassKeys)
{
<script src="https://cdn.passwordless.dev/dist/1.1.0/umd/passwordless.umd.js"></script>
<script>
import { Client } from "https://cdn.passwordless.dev/dist/1.2.0-beta1/esm/passwordless.mjs";

const p = new Client({
apiKey: "@PasswordlessOptions.Value.ApiKey",
apiUrl: "@PasswordlessOptions.Value.ApiUrl"
});
async function addPasskey() {
const addCredentialResponse = await fetch('/passwordless-add-credential', {
method: 'POST',
Expand All @@ -62,11 +79,6 @@
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');
}
}
Expand Down
Loading
Loading