Skip to content

Commit

Permalink
Store verification metadata against a One Login user
Browse files Browse the repository at this point in the history
  • Loading branch information
gunndabad committed Apr 12, 2024
1 parent 464271c commit fde9b48
Show file tree
Hide file tree
Showing 27 changed files with 4,732 additions and 381 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ public Task ExecuteResultAsync(ActionContext context)
return result.ExecuteAsync(context.HttpContext);
}
}

public static class ResultExtensions
{
public static IActionResult ToActionResult(this IResult result) => new HttpResultActionResult(result);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using TeachingRecordSystem.AuthorizeAccess.Infrastructure.FormFlow;
using TeachingRecordSystem.Core.DataStore.Postgres;
using TeachingRecordSystem.Core.DataStore.Postgres.Models;
using TeachingRecordSystem.FormFlow;
using static TeachingRecordSystem.AuthorizeAccess.Infrastructure.Security.FormFlowJourneySignInHandler;

namespace TeachingRecordSystem.AuthorizeAccess.Infrastructure.Security;

Expand Down Expand Up @@ -171,6 +174,25 @@ void IConfigureNamedOptions<OneLoginOptions>.Configure(string? name, OneLoginOpt
return Task.CompletedTask;
};

options.Events.OnAccessDenied = async context =>
{
// This handles the scenario where we've requested ID verification but One Login couldn't do it.

if (context.Properties!.TryGetVectorOfTrust(out var vtr) && vtr == SignInJourneyHelper.AuthenticationAndIdentityVerificationVtr &&
context.Properties?.Items.TryGetValue(PropertyKeys.JourneyInstanceId, out var serializedInstanceId) == true && serializedInstanceId is not null)
{
context.HandleResponse();

var journeyInstanceId = JourneyInstanceId.Deserialize(serializedInstanceId);

var signInJourneyHelper = context.HttpContext.RequestServices.GetRequiredService<SignInJourneyHelper>();
var journeyInstance = (await signInJourneyHelper.UserInstanceStateProvider.GetSignInJourneyInstanceAsync(context.HttpContext, journeyInstanceId))!;

var result = await signInJourneyHelper.OnUserVerificationWithOneLoginFailed(journeyInstance);
await result.ExecuteAsync(context.HttpContext);
}
};

options.CoreIdentityClaimIssuerSigningKey = _coreIdentityIssuerSigningKey;
options.CoreIdentityClaimIssuer = "https://identity.integration.account.gov.uk/";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ namespace TeachingRecordSystem.AuthorizeAccess.Pages;
public class DebugIdentityModel(
TrsDbContext dbContext,
SignInJourneyHelper helper,
IClock clock,
IOptions<AuthorizeAccessOptions> optionsAccessor) : PageModel
{
private OneLoginUser? _oneLoginUser;
Expand Down Expand Up @@ -105,9 +106,38 @@ public async Task<IActionResult> OnPost()
return this.PageWithErrors();
}

await JourneyInstance!.UpdateStateAsync(state =>
if (DetachPerson && _oneLoginUser!.PersonId is not null)
{
_oneLoginUser.PersonId = null;
}

if (_oneLoginUser!.PersonId is null)
{
if (IdentityVerified)
{
_oneLoginUser!.VerifiedOn = clock.UtcNow;
_oneLoginUser.VerificationRoute = OneLoginUserVerificationRoute.OneLogin;
_oneLoginUser.VerifiedNames = verifiedNames;
_oneLoginUser.VerifiedDatesOfBirth = verifiedDatesOfBirth;
}
else
{
_oneLoginUser!.VerifiedOn = null;
_oneLoginUser.VerificationRoute = null;
_oneLoginUser.VerifiedNames = null;
_oneLoginUser.VerifiedDatesOfBirth = null;
}
}

await dbContext.SaveChangesAsync();

await JourneyInstance!.UpdateStateAsync(state =>
{
if (_oneLoginUser!.PersonId is not null)
{
helper.Complete(state, _oneLoginUser.Person!.Trn!);
}
else if (IdentityVerified)
{
state.SetVerified(verifiedNames!, verifiedDatesOfBirth!);
}
Expand All @@ -117,14 +147,8 @@ public async Task<IActionResult> OnPost()
}
});

if (DetachPerson && _oneLoginUser?.PersonId is not null)
{
_oneLoginUser.PersonId = null;
await dbContext.SaveChangesAsync();
}

var nextPage = await helper.CreateOrUpdateOneLoginUser(JourneyInstance);
return new HttpResultActionResult(nextPage);
var nextPage = helper.GetNextPage(JourneyInstance);
return nextPage.ToActionResult();
}

public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public void OnGet()
{
}

public IActionResult OnPost() => Redirect(helper.GetNextPage(JourneyInstance!));
public IActionResult OnPost() => helper.GetNextPage(JourneyInstance!).ToActionResult();

public override void OnPageHandlerExecuting(PageHandlerExecutingContext context)
{
Expand All @@ -23,7 +23,7 @@ public override void OnPageHandlerExecuting(PageHandlerExecutingContext context)
if (state.AuthenticationTicket is null)
{
// Not matched
context.Result = Redirect(helper.GetNextPage(JourneyInstance));
context.Result = helper.GetNextPage(JourneyInstance).ToActionResult();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
namespace TeachingRecordSystem.AuthorizeAccess.Pages;

[Journey(SignInJourneyState.JourneyName), RequireJourneyInstance]
public class NotVerifiedModel : PageModel
public class NotVerifiedModel(SignInJourneyHelper helper) : PageModel
{
public JourneyInstance<SignInJourneyState>? JourneyInstance { get; set; }

Expand All @@ -17,7 +17,12 @@ public override void OnPageHandlerExecuting(PageHandlerExecutingContext context)
{
var state = JourneyInstance!.State;

if (state.OneLoginAuthenticationTicket is null)
if (state.AuthenticationTicket is not null)
{
// Already matched to a Teaching Record
context.Result = Redirect(helper.GetSafeRedirectUri(JourneyInstance));
}
else if (state.OneLoginAuthenticationTicket is null)
{
// Not authenticated with One Login
context.Result = BadRequest();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using System.Diagnostics;
using System.Security.Claims;
using System.Text.Json;
using GovUk.OneLogin.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.WebUtilities;
Expand Down Expand Up @@ -42,93 +42,127 @@ public async Task<IResult> OnSignedInWithOneLogin(JourneyInstance<SignInJourneyS
{
throw new InvalidOperationException("No vtr.");
}
var attemptedIdentityVerification = vtr == AuthenticationAndIdentityVerificationVtr;

await journeyInstance.UpdateStateAsync(state =>
{
state.Reset();
state.OneLoginAuthenticationTicket = ticket;
state.AttemptedIdentityVerification = attemptedIdentityVerification;
var sub = 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;
var identityVerified = vc is not null;
if (identityVerified)
{
state.SetVerified(
verifiedNames: ticket.Principal.GetCoreIdentityNames().Select(n => n.NameParts.Select(part => part.Value).ToArray()).ToArray(),
verifiedDatesOfBirth: ticket.Principal.GetCoreIdentityBirthDates().Select(d => d.Value).ToArray());
}
});
if (vtr == AuthenticationOnlyVtr)
{
await OnUserAuthenticated();
}
else
{
Debug.Assert(vtr == AuthenticationAndIdentityVerificationVtr);
Debug.Assert(journeyInstance.State.OneLoginAuthenticationTicket is not null);
await OnUserVerified();
}

if (ShowDebugPages)
{
return Results.Redirect(LinkGenerator.DebugIdentity(journeyInstance.InstanceId));
}

return await CreateOrUpdateOneLoginUser(journeyInstance);
}
return GetNextPage(journeyInstance);

public async Task<IResult> CreateOrUpdateOneLoginUser(JourneyInstance<SignInJourneyState> journeyInstance)
{
if (journeyInstance.State.OneLoginAuthenticationTicket is null)
async Task OnUserAuthenticated()
{
throw new InvalidOperationException($"{nameof(journeyInstance.State.OneLoginAuthenticationTicket)} is not set.");
}
var oneLoginUser = await dbContext.OneLoginUsers
.Include(u => u.Person)
.SingleOrDefaultAsync(u => u.Subject == sub);

var subject = journeyInstance.State.OneLoginAuthenticationTicket.Principal.FindFirstValue("sub") ?? throw new InvalidOperationException("No sub claim.");
var email = journeyInstance.State.OneLoginAuthenticationTicket.Principal.FindFirstValue("email") ?? throw new InvalidOperationException("No email claim.");
var vc = journeyInstance.State.OneLoginAuthenticationTicket.Principal.FindFirstValue("vc") is string vcStr ? JsonDocument.Parse(vcStr) : null;
if (oneLoginUser is null)
{
oneLoginUser = new()
{
Subject = sub,
Email = email,
FirstOneLoginSignIn = clock.UtcNow,
LastOneLoginSignIn = clock.UtcNow
};
dbContext.OneLoginUsers.Add(oneLoginUser);
}
else
{
oneLoginUser.LastOneLoginSignIn = clock.UtcNow;

var oneLoginUser = await dbContext.OneLoginUsers
.Include(o => o.Person)
.SingleOrDefaultAsync(o => o.Subject == subject);
// Email may have changed since the last sign in - ensure we update it.
// TODO Should we emit an event if it has changed?
oneLoginUser.Email = email;

if (oneLoginUser is not null)
{
oneLoginUser.LastOneLoginSignIn = clock.UtcNow;
oneLoginUser.Email = email;
if (oneLoginUser.PersonId is not null)
{
oneLoginUser.LastSignIn = clock.UtcNow;
}
}

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

await journeyInstance.UpdateStateAsync(state => CreateAndAssignPrincipal(state, oneLoginUser.Person.Trn!));
}
await journeyInstance.UpdateStateAsync(state =>
{
state.Reset();
state.OneLoginAuthenticationTicket = ticket;

if (oneLoginUser.VerificationRoute is not null)
{
Debug.Assert(oneLoginUser.VerifiedNames is not null);
Debug.Assert(oneLoginUser.VerifiedDatesOfBirth is not null);
state.SetVerified(oneLoginUser.VerifiedNames!, oneLoginUser.VerifiedDatesOfBirth!);
}

if (oneLoginUser.Person?.Trn is string trn && !ShowDebugPages)
{
Complete(state, trn);
}
});
}
else

async Task OnUserVerified()
{
oneLoginUser = new()
var verifiedNames = ticket.Principal.GetCoreIdentityNames().Select(n => n.NameParts.Select(part => part.Value).ToArray()).ToArray();
var verifiedDatesOfBirth = ticket.Principal.GetCoreIdentityBirthDates().Select(d => d.Value).ToArray();

var oneLoginUser = await dbContext.OneLoginUsers.SingleAsync(u => u.Subject == sub);
oneLoginUser.VerifiedOn = clock.UtcNow;
oneLoginUser.VerificationRoute = OneLoginUserVerificationRoute.OneLogin;
oneLoginUser.VerifiedNames = verifiedNames;
oneLoginUser.VerifiedDatesOfBirth = verifiedDatesOfBirth;
await dbContext.SaveChangesAsync();

await journeyInstance.UpdateStateAsync(state =>
{
Subject = subject,
Email = email,
FirstOneLoginSignIn = clock.UtcNow,
LastOneLoginSignIn = clock.UtcNow
};
dbContext.OneLoginUsers.Add(oneLoginUser);
}
state.OneLoginAuthenticationTicket = ticket;
state.AttemptedIdentityVerification = true;

await dbContext.SaveChangesAsync();
state.SetVerified(verifiedNames, verifiedDatesOfBirth);
});
}
}

if (oneLoginUser.PersonId is null && !journeyInstance.State.IdentityVerified && !journeyInstance.State.AttemptedIdentityVerification)
public async Task<IResult> OnUserVerificationWithOneLoginFailed(JourneyInstance<SignInJourneyState> journeyInstance)
{
await journeyInstance.UpdateStateAsync(state =>
{
return VerifyIdentityWithOneLogin(journeyInstance);
}
state.AttemptedIdentityVerification = true;
});

return Results.Redirect(GetNextPage(journeyInstance));
return GetNextPage(journeyInstance);
}

public string GetNextPage(JourneyInstance<SignInJourneyState> journeyInstance) => journeyInstance.State switch
public IResult GetNextPage(JourneyInstance<SignInJourneyState> journeyInstance) => journeyInstance.State switch
{
// Authentication is complete
{ AuthenticationTicket: not null } => GetSafeRedirectUri(journeyInstance),
{ AuthenticationTicket: not null } => Results.Redirect(GetSafeRedirectUri(journeyInstance)),

// Authenticated with OneLogin, identity verification succeeded, not yet matched to teaching record
{ OneLoginAuthenticationTicket: not null, IdentityVerified: true, AuthenticationTicket: null } =>
LinkGenerator.Connect(journeyInstance.InstanceId),
Results.Redirect(LinkGenerator.Connect(journeyInstance.InstanceId)),

// Authenticated with OneLogin, not yet verified
{ OneLoginAuthenticationTicket: not null, IdentityVerified: false, AttemptedIdentityVerification: false } =>
VerifyIdentityWithOneLogin(journeyInstance),

// Authenticated with OneLogin, identity verification failed
{ OneLoginAuthenticationTicket: not null, IdentityVerified: false } => LinkGenerator.NotVerified(journeyInstance.InstanceId),
{ OneLoginAuthenticationTicket: not null, IdentityVerified: false } => Results.Redirect(LinkGenerator.NotVerified(journeyInstance.InstanceId)),

_ => throw new InvalidOperationException("Cannot determine next page.")
};
Expand Down Expand Up @@ -173,7 +207,7 @@ public async Task<bool> TryMatchToTeachingRecord(JourneyInstance<SignInJourneySt
oneLoginUser.LastSignIn = clock.UtcNow;
await dbContext.SaveChangesAsync();

await journeyInstance.UpdateStateAsync(state => CreateAndAssignPrincipal(state, matchedTrn));
await journeyInstance.UpdateStateAsync(state => Complete(state, matchedTrn));

return true;
}
Expand All @@ -187,7 +221,7 @@ public async Task<bool> TryMatchToTeachingRecord(JourneyInstance<SignInJourneySt
string.IsNullOrEmpty(value) ? null : new(value.Where(char.IsAsciiDigit).ToArray());
}

private static void CreateAndAssignPrincipal(SignInJourneyState state, string trn)
public void Complete(SignInJourneyState state, string trn)
{
if (state.OneLoginAuthenticationTicket is null)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeachingRecordSystem.Core.DataStore.Postgres.Models;

Expand All @@ -11,5 +14,26 @@ public void Configure(EntityTypeBuilder<OneLoginUser> builder)
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);
builder.Property(o => o.VerifiedNames).HasColumnType("jsonb").HasConversion<string>(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
v => JsonSerializer.Deserialize<string[][]>(v, (JsonSerializerOptions?)null),
new ValueComparer<string[][]>(
(a, b) => (a == null && b == null) || (a != null && b != null && a.SequenceEqual(b, new StringArrayEqualityComparer())),
v => HashCode.Combine(v.Select(names => string.Join(" ", names)))));
builder.Property(o => o.VerifiedDatesOfBirth).HasColumnType("jsonb").HasConversion<string>(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
v => JsonSerializer.Deserialize<DateOnly[]>(v, (JsonSerializerOptions?)null),
new ValueComparer<DateOnly[]>(
(a, b) => (a == null && b == null) || (a != null && b != null && a.SequenceEqual(b)),
v => HashCode.Combine(v)));
}
}

file class StringArrayEqualityComparer : IEqualityComparer<string[]>
{
public bool Equals(string[]? x, string[]? y) =>
(x is null && y is null) ||
(x is not null && y is not null && x.SequenceEqual(y));

public int GetHashCode([DisallowNull] string[] obj) => HashCode.Combine(obj);
}
Loading

0 comments on commit fde9b48

Please sign in to comment.