Skip to content

Commit

Permalink
PIDP-912 Confirmation Page for account linking (#533)
Browse files Browse the repository at this point in the history
* Account-linking mock page added

* dialog added to account-linking mock page

* update FE discovery enum

* API portion

* frontend first pass

* remove using

* remove unused

* connect to backend

* delete api added to invalidate the cookie

* add overlay and nicer message

* http DELETE

* update frontend

* fix frontend tests

* remove mock component

* refactor nested template

* update logging

* alt description

* clear cookie

* remove swagger

* simplify

* whoops

---------

Co-authored-by: Kakarla <[email protected]>
Co-authored-by: James Hollinger <[email protected]>
  • Loading branch information
3 people authored Jun 3, 2024
1 parent 2528b3b commit 3a9c389
Show file tree
Hide file tree
Showing 15 changed files with 713 additions and 71 deletions.
53 changes: 11 additions & 42 deletions backend/webapi/Features/Credentials/Create.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ namespace Pidp.Features.Credentials;

public class Create
{
public class Command : ICommand<IDomainResult<Discovery.Model>>
public class Command : ICommand<IDomainResult<int>>
{
[JsonIgnore]
public Guid CredentialLinkToken { get; set; }
[JsonIgnore]
public ClaimsPrincipal User { get; set; } = new();
}

public class CommandHandler : ICommandHandler<Command, IDomainResult<Discovery.Model>>
public class CommandHandler : ICommandHandler<Command, IDomainResult<int>>
{
private readonly IClock clock;
private readonly ILogger<CommandHandler> logger;
Expand All @@ -46,7 +46,7 @@ public CommandHandler(
this.context = context;
}

public async Task<IDomainResult<Discovery.Model>> HandleAsync(Command command)
public async Task<IDomainResult<int>> HandleAsync(Command command)
{
var userId = command.User.GetUserId();
var userIdentityProvider = command.User.GetIdentityProvider();
Expand All @@ -56,7 +56,7 @@ public CommandHandler(
|| string.IsNullOrWhiteSpace(userIdpId))
{
this.logger.LogUserError(userId, userIdentityProvider, userIdpId);
return DomainResult.Failed<Discovery.Model>();
return DomainResult.Failed<int>();
}

var ticket = await this.context.CredentialLinkTickets
Expand All @@ -66,17 +66,17 @@ public CommandHandler(
if (ticket == null)
{
this.logger.LogTicketNotFound(command.CredentialLinkToken);
return DomainResult.NotFound<Discovery.Model>();
return DomainResult.NotFound<int>();
}
if (ticket.LinkToIdentityProvider != userIdentityProvider)
{
this.logger.LogTicketIdpError(ticket.Id, ticket.LinkToIdentityProvider, userIdentityProvider);
return DomainResult.Failed<Discovery.Model>();
return DomainResult.Failed<int>();
}
if (ticket.ExpiresAt < this.clock.GetCurrentInstant())
{
this.logger.LogTicketExpired(ticket.Id);
return DomainResult.Success(new Discovery.Model { Status = Discovery.Model.StatusCode.TicketExpired });
return DomainResult.Failed<int>();
}

#pragma warning disable CA1304 // ToLower() is Locale Dependant
Expand All @@ -89,31 +89,8 @@ public CommandHandler(

if (existingCredential != null)
{
if (existingCredential.PartyId == ticket.PartyId)
{
this.logger.LogCredentialAlreadyLinked(ticket.Id, existingCredential.Id);
return DomainResult.Success(new Discovery.Model
{
PartyId = existingCredential.PartyId,
Status = Discovery.Model.StatusCode.AlreadyLinked
});
}
else
{
this.context.CredentialLinkErrorLogs.Add(new CredentialLinkErrorLog
{
CredentialLinkTicketId = ticket.Id,
ExistingCredentialId = existingCredential.Id
});
await this.context.SaveChangesAsync();

this.logger.LogCredentialAlreadyExists(ticket.Id, existingCredential.Id);
return DomainResult.Success(new Discovery.Model
{
PartyId = existingCredential.PartyId,
Status = Discovery.Model.StatusCode.CredentialExists
});
}
this.logger.LogCredentialAlreadyExists(ticket.Id, existingCredential.Id);
return DomainResult.Failed<int>();
}

var credential = new Credential
Expand All @@ -131,15 +108,10 @@ public CommandHandler(

await this.context.SaveChangesAsync();

return DomainResult.Success(new Discovery.Model
{
PartyId = credential.PartyId,
Status = Discovery.Model.StatusCode.AccountLinkSuccess
});
return DomainResult.Success(ticket.PartyId);
}
}


public class BCProviderUpdateAttributesHandler : INotificationHandler<CredentialLinked>
{
private readonly IBCProviderClient bcProviderClient;
Expand Down Expand Up @@ -279,9 +251,6 @@ public static partial class CredentialCreateLoggingExtensions
[LoggerMessage(4, LogLevel.Error, "Credential Link Ticket {credentialLinkTicketId} expected to link to IDP {expectedIdp}, user had IDP {actualIdp} instead.")]
public static partial void LogTicketIdpError(this ILogger<Create.CommandHandler> logger, int credentialLinkTicketId, string expectedIdp, string actualIdp);

[LoggerMessage(5, LogLevel.Error, "Credential Link Ticket {credentialLinkTicketId} redemption failed, the new Credential already exists on a different party. Credential Id {existingCredentialId}.")]
[LoggerMessage(5, LogLevel.Error, "Credential Link Ticket {credentialLinkTicketId} redemption failed, the Credential with ID {existingCredentialId} already exists.")]
public static partial void LogCredentialAlreadyExists(this ILogger<Create.CommandHandler> logger, int credentialLinkTicketId, int existingCredentialId);

[LoggerMessage(6, LogLevel.Error, "Credential Link Ticket {credentialLinkTicketId} redemption failed, the new Credential is already linked to the Party. Credential ID {existingCredentialId}.")]
public static partial void LogCredentialAlreadyLinked(this ILogger<Create.CommandHandler> logger, int credentialLinkTicketId, int existingCredentialId);
}
33 changes: 21 additions & 12 deletions backend/webapi/Features/Credentials/CredentialsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ namespace Pidp.Features.Credentials;
using Microsoft.AspNetCore.Mvc;

using Pidp.Extensions;
using Pidp.Features.Discovery;
using Pidp.Infrastructure.Auth;
using Pidp.Infrastructure.Services;
using Pidp.Models;
Expand All @@ -26,23 +25,14 @@ public CredentialsController(IPidpAuthorizationService authorizationService) : b
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Discovery.Model>> CreateCredential([FromServices] ICommandHandler<Create.Command, IDomainResult<Discovery.Model>> handler)
public async Task<ActionResult<int>> CreateCredential([FromServices] ICommandHandler<Create.Command, IDomainResult<int>> handler)
{
var credentialLinkTicket = await this.AuthorizationService.VerifyTokenAsync<Cookies.CredentialLinkTicket.Values>(this.Request.Cookies.GetCredentialLinkTicket());
if (credentialLinkTicket == null)
{
return this.BadRequest("A valid Credential Link Ticket is required to link accounts.");
}

var result = await handler.HandleAsync(new Create.Command { CredentialLinkToken = credentialLinkTicket.CredentialLinkToken, User = this.User });

if (result.IsSuccess
&& result.Value.Status == Discovery.Model.StatusCode.TicketExpired)
{
// Keep the Credential Link Ticket cookie to prevent the user from accedentially entering the app and creating a Party.
return result.ToActionResultOfT();
}

this.Response.Cookies.Append(
Cookies.CredentialLinkTicket.Key,
string.Empty,
Expand All @@ -52,7 +42,8 @@ public CredentialsController(IPidpAuthorizationService authorizationService) : b
HttpOnly = true
});

return result.ToActionResultOfT();
return await handler.HandleAsync(new Create.Command { CredentialLinkToken = credentialLinkTicket.CredentialLinkToken, User = this.User })
.ToActionResultOfT();
}

[HttpGet]
Expand Down Expand Up @@ -119,4 +110,22 @@ await this.AuthorizationService.SignTokenAsync(new Cookies.CredentialLinkTicket.

return result.ToActionResult();
}

[HttpDelete("/api/[controller]")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> DeleteCredential()
{
this.Response.Cookies.Append(
Cookies.CredentialLinkTicket.Key,
string.Empty,
new CookieOptions
{
Expires = DateTimeOffset.Now.AddDays(-1),
HttpOnly = true
});

return this.Ok();
}
}
101 changes: 97 additions & 4 deletions backend/webapi/Features/Discovery/Discovery.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
namespace Pidp.Features.Discovery;

using Microsoft.EntityFrameworkCore;
using NodaTime;
using System.Security.Claims;
using System.Text.Json.Serialization;

Expand All @@ -16,6 +17,8 @@ public class Query : IQuery<Model>
{
[JsonIgnore]
public ClaimsPrincipal User { get; set; } = new();
[JsonIgnore]
public Guid? CredentialLinkToken { get; set; }
}

public class Model
Expand All @@ -25,10 +28,11 @@ public enum StatusCode
Success = 1,
NewUser,
NewBCProviderError,
AccountLinkSuccess,
AccountLinkInProgress,
AlreadyLinked,
CredentialExists,
TicketExpired
TicketExpired,
AccountLinkingError
}

public int? PartyId { get; set; }
Expand All @@ -37,11 +41,19 @@ public enum StatusCode

public class QueryHandler : IQueryHandler<Query, Model>
{
private readonly IClock clock;
private readonly ILogger<QueryHandler> logger;
private readonly IPlrClient client;
private readonly PidpDbContext context;

public QueryHandler(IPlrClient client, PidpDbContext context)
public QueryHandler(
IClock clock,
ILogger<QueryHandler> logger,
IPlrClient client,
PidpDbContext context)
{
this.clock = clock;
this.logger = logger;
this.client = client;
this.context = context;
}
Expand All @@ -62,10 +74,15 @@ public async Task<Model> HandleAsync(Query query)
CheckPlr = credential.Party!.Cpn == null
&& credential.Party.Birthdate != null
&& credential.Party.LicenceDeclaration!.LicenceNumber != null
&& credential.Party.LicenceDeclaration!.CollegeCode != null
&& credential.Party.LicenceDeclaration!.CollegeCode != null,
})
.SingleOrDefaultAsync();

if (query.CredentialLinkToken != null)
{
return await this.HandleAcountLinkingDiscovery(query, data?.Credential);
}

if (data == null)
{
return new Model
Expand All @@ -85,6 +102,63 @@ public async Task<Model> HandleAsync(Query query)
};
}

private async Task<Model> HandleAcountLinkingDiscovery(Query query, Credential? credential)
{
var ticket = await this.context.CredentialLinkTickets
.SingleOrDefaultAsync(ticket => ticket.Token == query.CredentialLinkToken
&& !ticket.Claimed);

if (ticket == null)
{
this.logger.LogTicketNotFound(query.User.GetUserId(), query.CredentialLinkToken!.Value);
return new Model { Status = Model.StatusCode.AccountLinkingError };
}
if (ticket.LinkToIdentityProvider != query.User.GetIdentityProvider())
{
this.logger.LogTicketIdpError(query.User.GetUserId(), ticket.Id, ticket.LinkToIdentityProvider, query.User.GetIdentityProvider());
return new Model { Status = Model.StatusCode.AccountLinkingError };
}
if (ticket.ExpiresAt < this.clock.GetCurrentInstant())
{
this.logger.LogTicketExpired(query.User.GetUserId(), ticket.Id);
return new Model { Status = Model.StatusCode.TicketExpired };
}

if (credential == null)
{
return new Model
{
Status = Model.StatusCode.AccountLinkInProgress
};
}

if (credential.PartyId == ticket.PartyId)
{
this.logger.LogCredentialAlreadyLinked(query.User.GetUserId(), ticket.Id, credential.Id);
return new Model
{
PartyId = credential.PartyId,
Status = Model.StatusCode.AlreadyLinked
};
}
else
{
this.context.CredentialLinkErrorLogs.Add(new CredentialLinkErrorLog
{
CredentialLinkTicketId = ticket.Id,
ExistingCredentialId = credential.Id
});
await this.context.SaveChangesAsync();

this.logger.LogCredentialAlreadyExists(query.User.GetUserId(), ticket.Id, credential.Id);
return new Model
{
PartyId = credential.PartyId,
Status = Model.StatusCode.CredentialExists
};
}
}

private async Task HandleUpdatesAsync(Credential credential, bool checkPlr, ClaimsPrincipal user)
{
var saveChanges = false;
Expand Down Expand Up @@ -121,3 +195,22 @@ private async Task HandleUpdatesAsync(Credential credential, bool checkPlr, Clai
}
}
}


public static partial class DiscoveryLoggingExtensions
{
[LoggerMessage(1, LogLevel.Error, "User {userId} Discovery: no unclaimed Credential Link Ticket with token {credentialLinkToken} was found.")]
public static partial void LogTicketNotFound(this ILogger<Discovery.QueryHandler> logger, Guid userId, Guid credentialLinkToken);

[LoggerMessage(2, LogLevel.Error, "User {userId} Discovery: Credential Link Ticket {credentialLinkTicketId} is expired.")]
public static partial void LogTicketExpired(this ILogger<Discovery.QueryHandler> logger, Guid userId, int credentialLinkTicketId);

[LoggerMessage(3, LogLevel.Error, "User {userId} Discovery: Credential Link Ticket {credentialLinkTicketId} expected to link to IDP {expectedIdp}, user had IDP {actualIdp} instead.")]
public static partial void LogTicketIdpError(this ILogger<Discovery.QueryHandler> logger, Guid userId, int credentialLinkTicketId, string expectedIdp, string? actualIdp);

[LoggerMessage(4, LogLevel.Error, "User {userId} Discovery: Credential Link Ticket {credentialLinkTicketId} redemption failed, the new Credential is already linked to the Party. Credential ID {existingCredentialId}.")]
public static partial void LogCredentialAlreadyLinked(this ILogger<Discovery.QueryHandler> logger, Guid userId, int credentialLinkTicketId, int existingCredentialId);

[LoggerMessage(5, LogLevel.Error, "User {userId} Discovery: Credential Link Ticket {credentialLinkTicketId} redemption failed, the new Credential already exists on a different party. Credential Id {existingCredentialId}.")]
public static partial void LogCredentialAlreadyExists(this ILogger<Discovery.QueryHandler> logger, Guid userId, int credentialLinkTicketId, int existingCredentialId);
}
26 changes: 16 additions & 10 deletions backend/webapi/Features/Discovery/DiscoveryController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ namespace Pidp.Features.Discovery;
using Microsoft.AspNetCore.Mvc;

using Pidp.Extensions;
using Pidp.Features.Credentials;
using Pidp.Infrastructure.Auth;
using Pidp.Infrastructure.Services;

Expand All @@ -17,20 +16,27 @@ public DiscoveryController(IPidpAuthorizationService authorizationService) : bas

[HttpPost]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status307TemporaryRedirect)]
public async Task<ActionResult<Discovery.Model>> Discovery([FromServices] IQueryHandler<Discovery.Query, Discovery.Model> handler)
public async Task<ActionResult<Discovery.Model>> PartyDiscovery([FromServices] IQueryHandler<Discovery.Query, Discovery.Model> handler)
{
var credentialLinkTicket = await this.AuthorizationService.VerifyTokenAsync<Cookies.CredentialLinkTicket.Values>(this.Request.Cookies.GetCredentialLinkTicket());
if (credentialLinkTicket != null)

var result = await handler.HandleAsync(new Discovery.Query { CredentialLinkToken = credentialLinkTicket?.CredentialLinkToken, User = this.User });

if (result.Status is Discovery.Model.StatusCode.AlreadyLinked
or Discovery.Model.StatusCode.CredentialExists
or Discovery.Model.StatusCode.AccountLinkingError)
{
return this.RedirectToActionPreserveMethod
(
nameof(CredentialsController.CreateCredential),
nameof(CredentialsController).Replace("Controller", "")
);
this.Response.Cookies.Append(
Cookies.CredentialLinkTicket.Key,
string.Empty,
new CookieOptions
{
Expires = DateTimeOffset.Now.AddDays(-1),
HttpOnly = true
});
}

return await handler.HandleAsync(new Discovery.Query { User = this.User });
return result;
}

[HttpGet("{partyId}/destination")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ export enum DiscoveryStatus {
Success = 1,
NewUser,
NewBCProviderError,
AccountLinkSuccess,
AccountLinkInProgress,
AlreadyLinkedError,
CredentialExistsError,
ExpiredCredentialLinkTicketError,
AccountLinkingError
}

@Injectable({
Expand Down
Loading

0 comments on commit 3a9c389

Please sign in to comment.