Skip to content

Commit

Permalink
Store One Login user info in DB (#1123)
Browse files Browse the repository at this point in the history
  • Loading branch information
gunndabad authored Jan 31, 2024
1 parent d20e9ec commit 0974d56
Show file tree
Hide file tree
Showing 26 changed files with 1,990 additions and 75 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace TeachingRecordSystem.AuthorizeAccess;

public static class ClaimTypes
{
public const string Trn = "trn";
public const string PersonId = "person_id";
}
Original file line number Diff line number Diff line change
@@ -1,39 +1,29 @@
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Options;
using TeachingRecordSystem.FormFlow;
using TeachingRecordSystem.FormFlow.State;

namespace TeachingRecordSystem.AuthorizeAccess.Infrastructure.FormFlow;

public class SignInJourneyHelper
public static class Extensions
{
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)
public static async Task<JourneyInstance<SignInJourneyState>?> GetSignInJourneyInstanceAsync(
this IUserInstanceStateProvider userInstanceStateProvider,
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)
if (!JourneyInstanceId.TryResolve(SignInJourneyState.JourneyDescriptor, valueProvider, out var instanceId) && instanceIdHint is null)
{
return null;
}

if (await _userInstanceStateProvider.GetInstanceAsync(instanceIdHint ?? instanceId, typeof(SignInJourneyState))
if (await userInstanceStateProvider.GetInstanceAsync(instanceIdHint ?? instanceId, typeof(SignInJourneyState))
is not JourneyInstance<SignInJourneyState> persistedInstance)
{
return null;
Expand All @@ -44,12 +34,13 @@ public SignInJourneyHelper(IUserInstanceStateProvider userInstanceStateProvider,
return persistedInstance;
}

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

if (existingInstance is not null)
{
Expand All @@ -58,10 +49,10 @@ public async Task<JourneyInstance<SignInJourneyState>> GetOrCreateInstanceAsync(
}

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

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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace TeachingRecordSystem.AuthorizeAccess.Infrastructure.Security;
/// An <see cref="IAuthenticationSignInHandler"/> that persists an <see cref="AuthenticationTicket"/> to
/// the current FormFlow instance's state.
/// </summary>
public class FormFlowJourneySignInHandler(SignInJourneyHelper signInJourneyHelper) : IAuthenticationSignInHandler
public class FormFlowJourneySignInHandler(SignInJourneyHelper helper) : IAuthenticationSignInHandler
{
private AuthenticationScheme? _scheme;
private HttpContext? _context;
Expand All @@ -19,7 +19,7 @@ public async Task<AuthenticateResult> AuthenticateAsync()
{
EnsureInitialized();

var journeyInstance = await signInJourneyHelper.GetInstanceAsync(_context);
var journeyInstance = await helper.UserInstanceStateProvider.GetSignInJourneyInstanceAsync(_context);

if (journeyInstance is null || journeyInstance.State.OneLoginAuthenticationTicket is null)
{
Expand Down Expand Up @@ -58,19 +58,19 @@ public async Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? pr

var journeyInstanceId = JourneyInstanceId.Deserialize(serializedInstanceId);

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

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

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

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

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

await journeyInstance.UpdateStateAsync(state => state.Reset());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
namespace TeachingRecordSystem.AuthorizeAccess.Infrastructure.Security;

public class MatchToTeachingRecordAuthenticationHandler(
SignInJourneyHelper signInJourneyHelper,
SignInJourneyHelper helper,
AuthorizeAccessLinkGenerator linkGenerator) : IAuthenticationHandler
{
private AuthenticationScheme? _scheme;
Expand All @@ -16,7 +16,7 @@ public async Task<AuthenticateResult> AuthenticateAsync()
{
EnsureInitialized();

var journeyInstance = await signInJourneyHelper.GetInstanceAsync(_context);
var journeyInstance = await helper.UserInstanceStateProvider.GetSignInJourneyInstanceAsync(_context);

if (journeyInstance is null)
{
Expand All @@ -40,14 +40,15 @@ public async Task ChallengeAsync(AuthenticationProperties? properties)
properties ??= new();
properties.RedirectUri ??= "/";

var journeyInstance = await signInJourneyHelper.GetOrCreateInstanceAsync(
var journeyInstance = await helper.UserInstanceStateProvider.GetOrCreateSignInJourneyInstanceAsync(
_context,
createState: () => new SignInJourneyState(properties.RedirectUri!, OneLoginDefaults.AuthenticationScheme),
createState: () => new SignInJourneyState(properties.RedirectUri!, properties),
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);
var delegatedProperties = new AuthenticationProperties();
delegatedProperties.RedirectUri = linkGenerator.Start(journeyInstance.InstanceId);
delegatedProperties.Items.Add(FormFlowJourneySignInHandler.PropertyKeys.JourneyInstanceId, journeyInstance.InstanceId.Serialize());
await _context!.ChallengeAsync(OneLoginDefaults.AuthenticationScheme, delegatedProperties);
}

public Task ForbidAsync(AuthenticationProperties? properties)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,16 @@
.AddRazorPages();

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

var app = builder.Build();

Expand Down Expand Up @@ -128,3 +127,5 @@
app.MapControllers();

app.Run();

public partial class Program { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System.Security.Claims;
using System.Text.Json;
using GovUk.OneLogin.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using TeachingRecordSystem.AuthorizeAccess.Infrastructure.Security;
using TeachingRecordSystem.Core;
using TeachingRecordSystem.Core.DataStore.Postgres;
using TeachingRecordSystem.FormFlow.State;

namespace TeachingRecordSystem.AuthorizeAccess;

public class SignInJourneyHelper(TrsDbContext dbContext, IUserInstanceStateProvider userInstanceStateProvider, IClock clock)
{
public IUserInstanceStateProvider UserInstanceStateProvider { get; } = userInstanceStateProvider;

public async Task OnSignedInWithOneLogin(SignInJourneyState state, AuthenticationTicket ticket)
{
var subject = ticket.Principal.FindFirstValue("sub") ?? throw new InvalidOperationException("No sub claim.");
var email = ticket.Principal.FindFirstValue("email") ?? throw new InvalidOperationException("No email claim.");
var vc = ticket.Principal.FindFirstValue("vc") is string vcStr ? JsonDocument.Parse(vcStr) : null;

state.Reset();
state.OneLoginAuthenticationTicket = ticket;

if (vc is not null)
{
state.VerifiedNames = ticket.Principal.GetCoreIdentityNames().Select(n => n.NameParts.Select(part => part.Value).ToArray()).ToArray();
state.VerifiedDatesOfBirth = ticket.Principal.GetCoreIdentityBirthDates().Select(d => d.Value).ToArray();
}

var oneLoginUser = await dbContext.OneLoginUsers
.Include(o => o.Person)
.SingleOrDefaultAsync(o => o.Subject == subject);

if (oneLoginUser is not null)
{
oneLoginUser.CoreIdentityVc = vc;
oneLoginUser.LastOneLoginSignIn = clock.UtcNow;
oneLoginUser.Email = email;

if (oneLoginUser.Person is not null)
{
oneLoginUser.LastSignIn = clock.UtcNow;

CreateAndAssignPrincipal(state, oneLoginUser.Person.PersonId, oneLoginUser.Person.Trn!);
}
}
else
{
oneLoginUser = new()
{
Subject = subject,
Email = email,
FirstOneLoginSignIn = clock.UtcNow,
LastOneLoginSignIn = clock.UtcNow,
CoreIdentityVc = vc
};
dbContext.OneLoginUsers.Add(oneLoginUser);
}

await dbContext.SaveChangesAsync();
}

private static void CreateAndAssignPrincipal(SignInJourneyState state, Guid personId, string trn)
{
if (state.OneLoginAuthenticationTicket is null)
{
throw new InvalidOperationException("User is not authenticated with One Login.");
}

var oneLoginIdentity = (ClaimsIdentity)state.OneLoginAuthenticationTicket.Principal.Identity!;

var teachingRecordIdentity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Trn, trn),
new Claim(ClaimTypes.PersonId, personId.ToString())
});

var principal = new ClaimsPrincipal(new[] { oneLoginIdentity, teachingRecordIdentity });

state.AuthenticationTicket = new AuthenticationTicket(principal, state.AuthenticationProperties, AuthenticationSchemes.MatchToTeachingRecord);
}
}
Original file line number Diff line number Diff line change
@@ -1,46 +1,64 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Authentication;
using TeachingRecordSystem.FormFlow;

namespace TeachingRecordSystem.AuthorizeAccess;

public class SignInJourneyState
[method: JsonConstructor]
public class SignInJourneyState(string redirectUri, AuthenticationProperties? authenticationProperties)
{
public const string JourneyName = "SignInJourney";

private readonly TicketSerializer _ticketSerializer = TicketSerializer.Default;
public static JourneyDescriptor JourneyDescriptor { get; } =
new JourneyDescriptor(JourneyName, typeof(SignInJourneyState), requestDataKeys: [], appendUniqueKey: true);

[JsonInclude]
private byte[]? _oneLoginAuthenticationTicket;
public string RedirectUri { get; } = redirectUri;

[JsonConstructor]
public SignInJourneyState(string redirectUri, string oneLoginAuthenticationScheme)
{
RedirectUri = redirectUri;
OneLoginAuthenticationScheme = oneLoginAuthenticationScheme;
}
[JsonConverter(typeof(AuthenticationTicketJsonConverter))]
public AuthenticationTicket? AuthenticationTicket { get; set; }

public AuthenticationTicket? AuthenticationTicket { get; private set; }
[JsonConverter(typeof(AuthenticationTicketJsonConverter))]
public AuthenticationTicket? OneLoginAuthenticationTicket { get; set; }

[JsonIgnore]
public AuthenticationTicket? OneLoginAuthenticationTicket =>
_oneLoginAuthenticationTicket is not null ? _ticketSerializer.Deserialize(_oneLoginAuthenticationTicket) : null;
public string[][]? VerifiedNames { get; set; }

[JsonIgnore]
public bool AuthenticatedWithOneLogin => OneLoginAuthenticationTicket is not null;
public DateOnly[]? VerifiedDatesOfBirth { get; set; }

public string RedirectUri { get; }
public AuthenticationProperties? AuthenticationProperties { get; } = authenticationProperties;

public string OneLoginAuthenticationScheme { get; }
public void Reset()
{
AuthenticationTicket = null;
OneLoginAuthenticationTicket = null;
VerifiedNames = null;
VerifiedDatesOfBirth = null;
}
}

public void OnSignedInWithOneLogin(AuthenticationTicket ticket)
public class AuthenticationTicketJsonConverter : JsonConverter<AuthenticationTicket>
{
private readonly TicketSerializer _ticketSerializer = TicketSerializer.Default;

public override AuthenticationTicket? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
_oneLoginAuthenticationTicket = _ticketSerializer.Serialize(ticket);
// TODO Should we reset all other state here?
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}

if (reader.TokenType == JsonTokenType.String)
{
var bytes = reader.GetBytesFromBase64();
return _ticketSerializer.Deserialize(bytes);
}

throw new JsonException($"Unknown TokenType: '{reader.TokenType}'.");
}

public void Reset()
public override void Write(Utf8JsonWriter writer, AuthenticationTicket value, JsonSerializerOptions options)
{
AuthenticationTicket = null;
_oneLoginAuthenticationTicket = null;
var bytes = _ticketSerializer.Serialize(value);
writer.WriteBase64StringValue(bytes);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeachingRecordSystem.Core.DataStore.Postgres.Models;

namespace TeachingRecordSystem.Core.DataStore.Postgres.Mappings;

public class OneLoginUserMapping : IEntityTypeConfiguration<OneLoginUser>
{
public void Configure(EntityTypeBuilder<OneLoginUser> builder)
{
builder.HasKey(o => o.Subject);
builder.Property(o => o.Subject).HasMaxLength(200);
builder.Property(o => o.Email).HasMaxLength(200);
builder.HasOne<Person>(o => o.Person).WithOne().HasForeignKey<OneLoginUser>(o => o.PersonId);
}
}
Loading

0 comments on commit 0974d56

Please sign in to comment.