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

[WIP] feat: Add support for legacy enterprise users #634

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Expand Up @@ -2,6 +2,7 @@
using System.Security.Claims;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Digdir.Domain.Dialogporten.Application.Externals.Authentication;
using Digdir.Domain.Dialogporten.Domain.Parties;

namespace Digdir.Domain.Dialogporten.Application.Common.Extensions;
Expand All @@ -19,6 +20,10 @@ public static class ClaimsPrincipalExtensions
private const string OrgClaim = "urn:altinn:org";
private const string IdportenAuthLevelClaim = "acr";
private const string AltinnAuthLevelClaim = "urn:altinn:authlevel";
private const string AltinnAuthenticationMethodClaim = "urn:altinn:authenticatemethod";
private const string AltinnAuthenticationEnterpriseUserMethod = "virksomhetsbruker";
private const string AltinnUserIdClaim = "urn:altinn:userid";
private const string AltinnUserNameClaim = "urn:altinn:username";
private const string PidClaim = "pid";

public static bool TryGetClaimValue(this ClaimsPrincipal claimsPrincipal, string claimType, [NotNullWhen(true)] out string? value)
Expand Down Expand Up @@ -52,6 +57,33 @@ public static bool TryGetPid(this Claim? pidClaim, [NotNullWhen(true)] out strin
return pid is not null;
}

// This is used for legacy systems using Altinn 2 enterprise users with Maskinporten authentication + token exchange
// as described in https://altinn.github.io/docs/api/rest/kom-i-gang/virksomhet/#autentisering-med-virksomhetsbruker-og-maskinporten
public static bool TryGetLegacySystemUserId(this ClaimsPrincipal claimsPrincipal, [NotNullWhen(true)] out string? systemUserId)
{
systemUserId = null;
if (claimsPrincipal.TryGetClaimValue(AltinnAuthenticationMethodClaim, out var authMethod) &&
authMethod == AltinnAuthenticationEnterpriseUserMethod &&
claimsPrincipal.TryGetClaimValue(AltinnUserIdClaim, out var userId))
{
systemUserId = userId;
}

return systemUserId is not null;
}

public static bool TryGetLegacySystemUserName(this ClaimsPrincipal claimsPrincipal, [NotNullWhen(true)] out string? systemUserName)
{
systemUserName = null;
if (claimsPrincipal.TryGetLegacySystemUserId(out _) &&
claimsPrincipal.TryGetClaimValue(AltinnUserNameClaim, out var claimValue))
{
systemUserName = claimValue;
}

return systemUserName is not null;
}

public static bool TryGetOrgNumber(this Claim? consumerClaim, [NotNullWhen(true)] out string? orgNumber)
{
orgNumber = null;
Expand Down Expand Up @@ -114,6 +146,26 @@ public static IEnumerable<Claim> GetIdentifyingClaims(this List<Claim> claims) =
c.Type.StartsWith(AltinnClaimPrefix, StringComparison.Ordinal)
).OrderBy(c => c.Type);

public static UserType GetUserType(this ClaimsPrincipal claimsPrincipal)
{
if (claimsPrincipal.TryGetPid(out _))
{
return UserType.Person;
}

if (claimsPrincipal.TryGetLegacySystemUserId(out _))
{
return UserType.LegacySystemUser;
}

if (claimsPrincipal.TryGetOrgNumber(out _))
{
return UserType.Enterprise;
}

return UserType.Unknown;
}

private static bool TryGetOrgShortName(this ClaimsPrincipal claimsPrincipal, [NotNullWhen(true)] out string? orgShortName)
=> claimsPrincipal.FindFirst(OrgClaim).TryGetOrgShortName(out orgShortName);

Expand All @@ -132,4 +184,10 @@ internal static bool TryGetOrgShortName(this IUser user, [NotNullWhen(true)] out

internal static bool TryGetPid(this IUser user, [NotNullWhen(true)] out string? pid) =>
user.GetPrincipal().TryGetPid(out pid);

internal static bool TryGetLegacySystemUserId(this IUser user, [NotNullWhen(true)] out string? systemUserId) =>
user.GetPrincipal().TryGetLegacySystemUserId(out systemUserId);

internal static bool TryGetLegacySystemUserName(this IUser user, [NotNullWhen(true)] out string? systemUserName) =>
user.GetPrincipal().TryGetLegacySystemUserName(out systemUserName);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
using System.Diagnostics.CodeAnalysis;
using Digdir.Domain.Dialogporten.Application.Common.Extensions;
using Digdir.Domain.Dialogporten.Application.Externals;
using Digdir.Domain.Dialogporten.Application.Externals.Authentication;
using Digdir.Domain.Dialogporten.Application.Externals.Presentation;

namespace Digdir.Domain.Dialogporten.Application.Common;

public interface IUserNameRegistry
{
bool TryGetCurrentUserPid([NotNullWhen(true)] out string? userPid);
Task<UserInformation?> GetUserInformation(CancellationToken cancellationToken);
string GetCurrentUserExternalId();
Task<UserInformation> GetCurrentUserInformation(CancellationToken cancellationToken);
}

public record UserInformation(string UserPid, string? UserName);
Expand All @@ -18,24 +19,47 @@ public class UserNameRegistry : IUserNameRegistry
{
private readonly IUser _user;
private readonly INameRegistry _nameRegistry;
private readonly IOrganizationRegistry _organizationRegistry;

public UserNameRegistry(IUser user, INameRegistry nameRegistry)
public UserNameRegistry(IUser user, INameRegistry nameRegistry, IOrganizationRegistry organizationRegistry)
{
_user = user ?? throw new ArgumentNullException(nameof(user));
_nameRegistry = nameRegistry ?? throw new ArgumentNullException(nameof(nameRegistry));
_organizationRegistry = organizationRegistry ?? throw new ArgumentNullException(nameof(organizationRegistry));
}
public string GetCurrentUserExternalId()
{
if (_user.TryGetPid(out var userId)) return userId;
if (_user.TryGetLegacySystemUserId(out userId)) return userId;
if (_user.TryGetOrgNumber(out userId)) return userId;

public bool TryGetCurrentUserPid([NotNullWhen(true)] out string? userPid) => _user.TryGetPid(out userPid);
throw new InvalidOperationException("User external id not found");
}

public async Task<UserInformation?> GetUserInformation(CancellationToken cancellationToken)
public async Task<UserInformation> GetCurrentUserInformation(CancellationToken cancellationToken)
{
if (!TryGetCurrentUserPid(out var userPid))
var userExernalId = GetCurrentUserExternalId();
string? userName;
switch (_user.GetPrincipal().GetUserType())
{
return null;
case UserType.Person:
userName = await _nameRegistry.GetName(userExernalId, cancellationToken);
break;
case UserType.LegacySystemUser:
_user.TryGetLegacySystemUserName(out userName);
break;
case UserType.Enterprise:
userName = await _organizationRegistry.GetOrgShortName(userExernalId, cancellationToken);
break;
case UserType.SystemUser:
// TODO: Implement when we know how system users will be handled
case UserType.Unknown:
default:
// This should never happen as GetCurrentExternalId should throw if the user type is unknown
throw new UnreachableException();
}

var userName = await _nameRegistry.GetName(userPid, cancellationToken);
return new(userPid, userName);
return new(userExernalId, userName);
}
}

Expand All @@ -49,12 +73,8 @@ public LocalDevelopmentUserNameRegistryDecorator(IUserNameRegistry userNameRegis
{
_userNameRegistry = userNameRegistry ?? throw new ArgumentNullException(nameof(userNameRegistry));
}
public string GetCurrentUserExternalId() => _userNameRegistry.GetCurrentUserExternalId();

public bool TryGetCurrentUserPid([NotNullWhen(true)] out string? userPid) =>
_userNameRegistry.TryGetCurrentUserPid(out userPid);

public Task<UserInformation?> GetUserInformation(CancellationToken cancellationToken)
=> _userNameRegistry.TryGetCurrentUserPid(out var userPid)
? Task.FromResult<UserInformation?>(new UserInformation(userPid!, LocalDevelopmentUserPid))
: throw new UnreachableException();
public Task<UserInformation> GetCurrentUserInformation(CancellationToken cancellationToken)
=> Task.FromResult(new UserInformation(GetCurrentUserExternalId(), LocalDevelopmentUserPid));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Digdir.Domain.Dialogporten.Application.Externals.Authentication;

public enum UserType
{
Unknown = 0,
Person = 1,
LegacySystemUser = 2,
SystemUser = 3,
Enterprise = 4
// TODO! Should we add a new type for service owners? ServiceownerOnBehalfOfPerson?
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,7 @@ public GetDialogSeenLogQueryHandler(
public async Task<GetDialogSeenLogResult> Handle(GetDialogSeenLogQuery request,
CancellationToken cancellationToken)
{
if (!_userNameRegistry.TryGetCurrentUserPid(out var userPid))
{
return new Forbidden("No valid user pid found.");
}

var userId = _userNameRegistry.GetCurrentUserExternalId();
var dialog = await _dbContext.Dialogs
.AsNoTracking()
.Include(x => x.SeenLog.Where(x => x.Id == request.SeenLogId))
Expand Down Expand Up @@ -83,7 +79,7 @@ public async Task<GetDialogSeenLogResult> Handle(GetDialogSeenLogQuery request,
}

var dto = _mapper.Map<GetDialogSeenLogDto>(seenLog);
dto.IsCurrentEndUser = userPid == seenLog.EndUserId;
dto.IsCurrentEndUser = userId == seenLog.EndUserId;
dto.EndUserIdHash = _stringHasher.Hash(seenLog.EndUserId);

return dto;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
using AutoMapper;
using Digdir.Domain.Dialogporten.Application.Common;
using Digdir.Domain.Dialogporten.Application.Common.ReturnTypes;
using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization;
using Digdir.Domain.Dialogporten.Application.Externals;
using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities;
using MediatR;
using OneOf;
using Microsoft.EntityFrameworkCore;
using Digdir.Domain.Dialogporten.Application.Common;

namespace Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.DialogSeenLogs.Queries.Search;

Expand Down Expand Up @@ -42,11 +42,7 @@ public SearchDialogSeenLogQueryHandler(

public async Task<SearchDialogSeenLogResult> Handle(SearchDialogSeenLogQuery request, CancellationToken cancellationToken)
{
if (!_userNameRegistry.TryGetCurrentUserPid(out var userPid))
{
return new Forbidden("No valid user pid found.");
}

var userId = _userNameRegistry.GetCurrentUserExternalId();
var dialog = await _db.Dialogs
.AsNoTracking()
.Include(x => x.SeenLog)
Expand Down Expand Up @@ -79,7 +75,7 @@ public async Task<SearchDialogSeenLogResult> Handle(SearchDialogSeenLogQuery req
.Select(x =>
{
var dto = _mapper.Map<SearchDialogSeenLogDto>(x);
dto.IsCurrentEndUser = x.EndUserId == userPid;
dto.IsCurrentEndUser = x.EndUserId == userId;
dto.EndUserIdHash = _stringHasher.Hash(x.EndUserId);
return dto;
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,7 @@ public GetDialogQueryHandler(

public async Task<GetDialogResult> Handle(GetDialogQuery request, CancellationToken cancellationToken)
{
var userInformation = await _userNameRegistry.GetUserInformation(cancellationToken);

if (userInformation is null)
{
return new Forbidden("No valid user pid found.");
}

var (userPid, userName) = userInformation;
var (userId, userName) = await _userNameRegistry.GetCurrentUserInformation(cancellationToken);

// This query could be written without all the includes as ProjectTo will do the job for us.
// However, we need to guarantee an order for sub resources of the dialog aggregate.
Expand Down Expand Up @@ -107,7 +100,7 @@ public async Task<GetDialogResult> Handle(GetDialogQuery request, CancellationTo

// TODO: What if name lookup fails
// https://github.com/digdir/dialogporten/issues/387
dialog.UpdateSeenAt(userPid, userName);
dialog.UpdateSeenAt(userId, userName);

var saveResult = await _unitOfWork
.WithoutAuditableSideEffects()
Expand All @@ -124,7 +117,7 @@ public async Task<GetDialogResult> Handle(GetDialogQuery request, CancellationTo
.Select(log =>
{
var logDto = _mapper.Map<GetDialogDialogSeenLogDto>(log);
logDto.IsCurrentEndUser = log.EndUserId == userPid;
logDto.IsCurrentEndUser = log.EndUserId == userId;
logDto.EndUserIdHash = _stringHasher.Hash(log.EndUserId);
return logDto;
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,7 @@ public SearchDialogQueryHandler(

public async Task<SearchDialogResult> Handle(SearchDialogQuery request, CancellationToken cancellationToken)
{
if (!_userNameRegistry.TryGetCurrentUserPid(out var userPid))
{
return new Forbidden("No valid user pid found.");
}

var userId = _userNameRegistry.GetCurrentUserExternalId();
var searchExpression = Expressions.LocalizedSearchExpression(request.Search, request.SearchCultureCode);
var authorizedResources = await _altinnAuthorization.GetAuthorizedResourcesForSearch(
request.Party ?? [],
Expand Down Expand Up @@ -180,7 +176,7 @@ public async Task<SearchDialogResult> Handle(SearchDialogQuery request, Cancella
foreach (var seenLog in paginatedList.Items.SelectMany(x => x.SeenSinceLastUpdate))
{
// Before we hash the end user id, check if the seen log entry is for the current user
seenLog.IsCurrentEndUser = userPid == seenLog.EndUserIdHash;
seenLog.IsCurrentEndUser = userId == seenLog.EndUserIdHash;
// TODO: Add test to not expose un-hashed end user id to the client
// https://github.com/digdir/dialogporten/issues/596
seenLog.EndUserIdHash = _stringHasher.Hash(seenLog.EndUserIdHash);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System.Net;
using Azure;
using Digdir.Domain.Dialogporten.Application.Common.Extensions;
using Digdir.Domain.Dialogporten.Application.Externals.Authentication;
using Digdir.Domain.Dialogporten.WebApi.Common.Extensions;
using FluentValidation.Results;

namespace Digdir.Domain.Dialogporten.WebApi.Common.Authentication;

public class UserTypeValidationMiddleware
{
private readonly RequestDelegate _next;

public UserTypeValidationMiddleware(RequestDelegate next)
{
_next = next;
}

public async Task InvokeAsync(HttpContext context)
{
if (context.User.Identity is { IsAuthenticated: true })
{
var userType = context.User.GetUserType();
if (userType == UserType.Unknown)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(context.ResponseBuilder(
context.Response.StatusCode,
new List<ValidationFailure>() { new("UserType",
"The request was authenticated, but we were unable to determine valid user type (person, enterprise or system user) in order to authorize the request.") }
));

return;
}
}

await _next(context);
}
}

public static class UserTypeValidationMiddlewareExtensions
{
public static IApplicationBuilder UseUserTypeValidation(this IApplicationBuilder app)
=> app.UseMiddleware<UserTypeValidationMiddleware>();
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,3 @@ internal static class SwaggerSummary
internal const string OptimisticConcurrencyNote = "Optimistic concurrency control is implemented using the If-Match header. Supply the Revision value from the GetDialog endpoint to ensure that the dialog is not modified/deleted by another request in the meantime.";
}
}

1 change: 1 addition & 0 deletions src/Digdir.Domain.Dialogporten.WebApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ static void BuildAndRun(string[] args)
.UseJwtSchemeSelector()
.UseAuthentication()
.UseAuthorization()
.UseUserTypeValidation()
.UseAzureConfiguration()
.UseFastEndpoints(x =>
{
Expand Down
Loading