Skip to content

Commit

Permalink
Skeleton authentication scheme setup for Authorize project (#1115)
Browse files Browse the repository at this point in the history
  • Loading branch information
gunndabad authored Jan 29, 2024
1 parent 0c42a80 commit 8a0687d
Show file tree
Hide file tree
Showing 14 changed files with 415 additions and 30 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.WebUtilities;
using TeachingRecordSystem.FormFlow;

namespace TeachingRecordSystem.AuthorizeAccessToATeacherRecord;

public class AuthorizeAccessLinkGenerator(LinkGenerator linkGenerator)
{
public string Start(JourneyInstanceId journeyInstanceId) => Nino(journeyInstanceId);

public string Nino(JourneyInstanceId journeyInstanceId) => GetRequiredPathByPage("/Nino", journeyInstanceId: journeyInstanceId);

private string GetRequiredPathByPage(string page, string? handler = null, object? routeValues = null, JourneyInstanceId? journeyInstanceId = null)
{
var url = linkGenerator.GetPathByPage(page, handler, values: routeValues) ?? throw new InvalidOperationException("Page was not found.");

if (journeyInstanceId?.UniqueKey is string journeyInstanceUniqueKey)
{
url = QueryHelpers.AddQueryString(url, Constants.UniqueKeyQueryParameterName, journeyInstanceUniqueKey);
}

return url;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using TeachingRecordSystem.SupportUi.Infrastructure.FormFlow;

namespace TeachingRecordSystem.AuthorizeAccessToATeacherRecord.Infrastructure.FormFlow;

public class DummyCurrentUserIdProvider : ICurrentUserIdProvider
{
public string GetCurrentUserId() => Guid.Empty.ToString();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Options;
using TeachingRecordSystem.FormFlow;
using TeachingRecordSystem.FormFlow.State;

namespace TeachingRecordSystem.AuthorizeAccessToATeacherRecord.Infrastructure.FormFlow;

public class SignInJourneyHelper
{
private readonly IUserInstanceStateProvider _userInstanceStateProvider;
private readonly JourneyDescriptor _journeyDescriptor;

public SignInJourneyHelper(IUserInstanceStateProvider userInstanceStateProvider, IOptions<FormFlowOptions> formFlowOptionsAccessor)
{
_userInstanceStateProvider = userInstanceStateProvider;

_journeyDescriptor = formFlowOptionsAccessor.Value.JourneyRegistry.GetJourneyByName(SignInJourneyState.JourneyName) ??
throw new InvalidOperationException($"Cannot find {SignInJourneyState.JourneyName} journey.");
}

public async Task<JourneyInstance<SignInJourneyState>?> GetInstanceAsync(HttpContext httpContext, JourneyInstanceId? instanceIdHint = null)
{
if (httpContext.Items.TryGetValue(typeof(JourneyInstance), out var journeyInstanceObj) &&
journeyInstanceObj is JourneyInstance<SignInJourneyState> instance)
{
return instance;
}

var valueProvider = CreateValueProvider(httpContext);

if (!JourneyInstanceId.TryResolve(_journeyDescriptor, valueProvider, out var instanceId) && instanceIdHint is null)
{
return null;
}

if (await _userInstanceStateProvider.GetInstanceAsync(instanceIdHint ?? instanceId, typeof(SignInJourneyState))
is not JourneyInstance<SignInJourneyState> persistedInstance)
{
return null;
}

httpContext.Items[typeof(JourneyInstance)] = persistedInstance;

return persistedInstance;
}

public async Task<JourneyInstance<SignInJourneyState>> GetOrCreateInstanceAsync(
HttpContext httpContext,
Func<SignInJourneyState> createState,
Action<SignInJourneyState> updateState)
{
var existingInstance = await GetInstanceAsync(httpContext);

if (existingInstance is not null)
{
await existingInstance.UpdateStateAsync(updateState);
return existingInstance;
}

var valueProvider = CreateValueProvider(httpContext);
var instanceId = JourneyInstanceId.Create(_journeyDescriptor, valueProvider);

var newState = createState();
var instance = (JourneyInstance<SignInJourneyState>)await _userInstanceStateProvider.CreateInstanceAsync(instanceId, typeof(SignInJourneyState), newState, properties: null);

httpContext.Items[typeof(JourneyInstance)] = instance;

return instance;
}

private static IValueProvider CreateValueProvider(HttpContext httpContext) =>
new QueryStringValueProvider(BindingSource.Query, httpContext.Request.Query, culture: null);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace TeachingRecordSystem.AuthorizeAccessToATeacherRecord.Infrastructure.Security;

public static class AuthenticationSchemes
{
public const string FormFlowJourney = nameof(FormFlowJourney);

public const string MatchToTeachingRecord = nameof(MatchToTeachingRecord);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using TeachingRecordSystem.AuthorizeAccessToATeacherRecord.Infrastructure.FormFlow;
using TeachingRecordSystem.FormFlow;

namespace TeachingRecordSystem.AuthorizeAccessToATeacherRecord.Infrastructure.Security;

/// <summary>
/// An <see cref="IAuthenticationSignInHandler"/> that persists an <see cref="AuthenticationTicket"/> to
/// the current FormFlow instance's state.
/// </summary>
public class FormFlowJourneySignInHandler(SignInJourneyHelper signInJourneyHelper) : IAuthenticationSignInHandler
{
private AuthenticationScheme? _scheme;
private HttpContext? _context;

public async Task<AuthenticateResult> AuthenticateAsync()
{
EnsureInitialized();

var journeyInstance = await signInJourneyHelper.GetInstanceAsync(_context);

if (journeyInstance is null || journeyInstance.State.OneLoginAuthenticationTicket is null)
{
return AuthenticateResult.NoResult();
}

return AuthenticateResult.Success(journeyInstance.State.OneLoginAuthenticationTicket);
}

public Task ChallengeAsync(AuthenticationProperties? properties)
{
throw new NotSupportedException();
}

public Task ForbidAsync(AuthenticationProperties? properties)
{
throw new NotSupportedException();
}

public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context)
{
_scheme = scheme;
_context = context;
return Task.CompletedTask;
}

public async Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties)
{
EnsureInitialized();

if (properties is null || !properties.Items.TryGetValue(PropertyKeys.JourneyInstanceId, out var serializedInstanceId) ||
serializedInstanceId is null)
{
throw new InvalidOperationException($"{PropertyKeys.JourneyInstanceId} must be specified in {nameof(properties)}.");
}

var journeyInstanceId = JourneyInstanceId.Deserialize(serializedInstanceId);

var journeyInstance = await signInJourneyHelper.GetInstanceAsync(_context, journeyInstanceId) ??
throw new InvalidOperationException("No FormFlow journey.");

var ticket = new AuthenticationTicket(user, properties, _scheme.Name);

await journeyInstance.UpdateStateAsync(state => state.OnSignedInWithOneLogin(ticket));
}

public async Task SignOutAsync(AuthenticationProperties? properties)
{
EnsureInitialized();

var journeyInstance = await signInJourneyHelper.GetInstanceAsync(_context) ??
throw new InvalidOperationException("No FormFlow journey.");

await journeyInstance.UpdateStateAsync(state => state.Reset());
}

[MemberNotNull(nameof(_context), nameof(_scheme))]
private void EnsureInitialized()
{
if (_context is null || _scheme is null)
{
throw new InvalidOperationException("Not initialized.");
}
}

public static class PropertyKeys
{
public const string JourneyInstanceId = nameof(JourneyInstanceId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System.Diagnostics.CodeAnalysis;
using GovUk.OneLogin.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using TeachingRecordSystem.AuthorizeAccessToATeacherRecord.Infrastructure.FormFlow;

namespace TeachingRecordSystem.AuthorizeAccessToATeacherRecord.Infrastructure.Security;

public class MatchToTeachingRecordAuthenticationHandler(
SignInJourneyHelper signInJourneyHelper,
AuthorizeAccessLinkGenerator linkGenerator) : IAuthenticationHandler
{
private AuthenticationScheme? _scheme;
private HttpContext? _context;

public async Task<AuthenticateResult> AuthenticateAsync()
{
EnsureInitialized();

var journeyInstance = await signInJourneyHelper.GetInstanceAsync(_context);

if (journeyInstance is null)
{
return AuthenticateResult.NoResult();
}

var ticket = journeyInstance.State.AuthenticationTicket;

if (ticket is null)
{
return AuthenticateResult.NoResult();
}

return AuthenticateResult.Success(ticket);
}

public async Task ChallengeAsync(AuthenticationProperties? properties)
{
EnsureInitialized();

properties ??= new();
properties.RedirectUri ??= "/";

var journeyInstance = await signInJourneyHelper.GetOrCreateInstanceAsync(
_context,
createState: () => new SignInJourneyState(properties.RedirectUri!, OneLoginDefaults.AuthenticationScheme),
updateState: state => state.Reset());

properties.RedirectUri = linkGenerator.Start(journeyInstance.InstanceId);
properties.Items.Add(FormFlowJourneySignInHandler.PropertyKeys.JourneyInstanceId, journeyInstance.InstanceId.Serialize());
await _context!.ChallengeAsync(OneLoginDefaults.AuthenticationScheme, properties);
}

public Task ForbidAsync(AuthenticationProperties? properties)
{
throw new NotSupportedException();
}

public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context)
{
_scheme = scheme;
_context = context;
return Task.CompletedTask;
}

[MemberNotNull(nameof(_context), nameof(_scheme))]
private void EnsureInitialized()
{
if (_context is null || _scheme is null)
{
throw new InvalidOperationException("Not initialized.");
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TeachingRecordSystem.AuthorizeAccessToATeacherRecord.Infrastructure.Security;
using TeachingRecordSystem.FormFlow;

namespace TeachingRecordSystem.AuthorizeAccessToATeacherRecord.Pages;

[Authorize]
[Journey(SignInJourneyState.JourneyName)]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.MatchToTeachingRecord)]
public class IndexModel : PageModel
{
public void OnGet()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@page "/nino"
@model TeachingRecordSystem.AuthorizeAccessToATeacherRecord.Pages.NinoModel
@{
}

<h1>NINO</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TeachingRecordSystem.FormFlow;

namespace TeachingRecordSystem.AuthorizeAccessToATeacherRecord.Pages;

[Authorize]
[Journey(SignInJourneyState.JourneyName), RequireJourneyInstance]
public class NinoModel : PageModel
{
public void OnGet()
{
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@
using GovUk.Frontend.AspNetCore;
using GovUk.OneLogin.AspNetCore;
using Joonasw.AspNetCore.SecurityHeaders;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Tokens;
using TeachingRecordSystem;
using TeachingRecordSystem.AuthorizeAccessToATeacherRecord;
using TeachingRecordSystem.AuthorizeAccessToATeacherRecord.Infrastructure.FormFlow;
using TeachingRecordSystem.AuthorizeAccessToATeacherRecord.Infrastructure.Logging;
using TeachingRecordSystem.AuthorizeAccessToATeacherRecord.Infrastructure.Security;
using TeachingRecordSystem.Core;
using TeachingRecordSystem.FormFlow;
using TeachingRecordSystem.ServiceDefaults;
using TeachingRecordSystem.SupportUi.Infrastructure.FormFlow;

var builder = WebApplication.CreateBuilder(args);

Expand All @@ -21,10 +26,9 @@
builder.Services.AddCsp(nonceByteAmount: 32);

builder.Services.AddAuthentication(defaultScheme: OneLoginDefaults.AuthenticationScheme)
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddOneLogin(options =>
{
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.SignInScheme = AuthenticationSchemes.FormFlowJourney;

var rsa = RSA.Create();
var privateKeyPem = builder.Configuration.GetRequiredValue("OneLogin:PrivateKeyPem");
Expand All @@ -49,9 +53,35 @@
options.SignedOutCallbackPath = "/_onelogin/aytq/logout-callback";
});

builder.Services.Configure<AuthenticationOptions>(options =>
{
options.AddScheme(AuthenticationSchemes.FormFlowJourney, scheme =>
{
scheme.HandlerType = typeof(FormFlowJourneySignInHandler);
});

options.AddScheme(AuthenticationSchemes.MatchToTeachingRecord, scheme =>
{
scheme.HandlerType = typeof(MatchToTeachingRecordAuthenticationHandler);
});
});

builder.Services
.AddRazorPages();

builder.Services
.AddSingleton<IClock, Clock>()
.AddTransient<AuthorizeAccessLinkGenerator>()
.AddTransient<FormFlowJourneySignInHandler>()
.AddTransient<MatchToTeachingRecordAuthenticationHandler>()
.AddFormFlow(options =>
{
options.JourneyRegistry.RegisterJourney(
new JourneyDescriptor(SignInJourneyState.JourneyName, typeof(SignInJourneyState), requestDataKeys: [], appendUniqueKey: true));
})
.AddSingleton<ICurrentUserIdProvider, DummyCurrentUserIdProvider>()
.AddSingleton<SignInJourneyHelper>();

var app = builder.Build();

app.MapDefaultEndpoints();
Expand Down
Loading

0 comments on commit 8a0687d

Please sign in to comment.