diff --git a/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/ClaimsPrincipalExtensions.cs b/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/ClaimsPrincipalExtensions.cs index 2ecf6c5b6..cbdf35188 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/ClaimsPrincipalExtensions.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/ClaimsPrincipalExtensions.cs @@ -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; @@ -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) @@ -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; @@ -114,6 +146,26 @@ public static IEnumerable GetIdentifyingClaims(this List 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); @@ -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); } diff --git a/src/Digdir.Domain.Dialogporten.Application/Common/IUserNameRegistry.cs b/src/Digdir.Domain.Dialogporten.Application/Common/IUserNameRegistry.cs index 9e8b06c98..ab9fa9b3c 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Common/IUserNameRegistry.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Common/IUserNameRegistry.cs @@ -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 GetUserInformation(CancellationToken cancellationToken); + string GetCurrentUserExternalId(); + Task GetCurrentUserInformation(CancellationToken cancellationToken); } public record UserInformation(string UserPid, string? UserName); @@ -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 GetUserInformation(CancellationToken cancellationToken) + public async Task 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); } } @@ -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 GetUserInformation(CancellationToken cancellationToken) - => _userNameRegistry.TryGetCurrentUserPid(out var userPid) - ? Task.FromResult(new UserInformation(userPid!, LocalDevelopmentUserPid)) - : throw new UnreachableException(); + public Task GetCurrentUserInformation(CancellationToken cancellationToken) + => Task.FromResult(new UserInformation(GetCurrentUserExternalId(), LocalDevelopmentUserPid)); } diff --git a/src/Digdir.Domain.Dialogporten.Application/Externals/Authentication/UserType.cs b/src/Digdir.Domain.Dialogporten.Application/Externals/Authentication/UserType.cs new file mode 100644 index 000000000..955e358a6 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Application/Externals/Authentication/UserType.cs @@ -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? +} diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogSeenLogs/Queries/Get/GetDialogSeenLogQuery.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogSeenLogs/Queries/Get/GetDialogSeenLogQuery.cs index 8ecbe1f18..fdc95ed32 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogSeenLogs/Queries/Get/GetDialogSeenLogQuery.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogSeenLogs/Queries/Get/GetDialogSeenLogQuery.cs @@ -44,11 +44,7 @@ public GetDialogSeenLogQueryHandler( public async Task 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)) @@ -83,7 +79,7 @@ public async Task Handle(GetDialogSeenLogQuery request, } var dto = _mapper.Map(seenLog); - dto.IsCurrentEndUser = userPid == seenLog.EndUserId; + dto.IsCurrentEndUser = userId == seenLog.EndUserId; dto.EndUserIdHash = _stringHasher.Hash(seenLog.EndUserId); return dto; diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogSeenLogs/Queries/Search/SearchDialogSeenLogQuery.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogSeenLogs/Queries/Search/SearchDialogSeenLogQuery.cs index ebeba3c4f..b1bfe92d3 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogSeenLogs/Queries/Search/SearchDialogSeenLogQuery.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogSeenLogs/Queries/Search/SearchDialogSeenLogQuery.cs @@ -1,4 +1,5 @@ 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; @@ -6,7 +7,6 @@ using MediatR; using OneOf; using Microsoft.EntityFrameworkCore; -using Digdir.Domain.Dialogporten.Application.Common; namespace Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.DialogSeenLogs.Queries.Search; @@ -42,11 +42,7 @@ public SearchDialogSeenLogQueryHandler( public async Task 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) @@ -79,7 +75,7 @@ public async Task Handle(SearchDialogSeenLogQuery req .Select(x => { var dto = _mapper.Map(x); - dto.IsCurrentEndUser = x.EndUserId == userPid; + dto.IsCurrentEndUser = x.EndUserId == userId; dto.EndUserIdHash = _stringHasher.Hash(x.EndUserId); return dto; }) diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Get/GetDialogQuery.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Get/GetDialogQuery.cs index 1ac23bad1..78f3d00db 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Get/GetDialogQuery.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Get/GetDialogQuery.cs @@ -53,14 +53,7 @@ public GetDialogQueryHandler( public async Task 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. @@ -107,7 +100,7 @@ public async Task 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() @@ -124,7 +117,7 @@ public async Task Handle(GetDialogQuery request, CancellationTo .Select(log => { var logDto = _mapper.Map(log); - logDto.IsCurrentEndUser = log.EndUserId == userPid; + logDto.IsCurrentEndUser = log.EndUserId == userId; logDto.EndUserIdHash = _stringHasher.Hash(log.EndUserId); return logDto; }) diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Search/SearchDialogQuery.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Search/SearchDialogQuery.cs index f30f26dc8..860f10261 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Search/SearchDialogQuery.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Search/SearchDialogQuery.cs @@ -136,11 +136,7 @@ public SearchDialogQueryHandler( public async Task 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 ?? [], @@ -180,7 +176,7 @@ public async Task 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); diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Common/Authentication/UserTypeValidationMiddleware.cs b/src/Digdir.Domain.Dialogporten.WebApi/Common/Authentication/UserTypeValidationMiddleware.cs new file mode 100644 index 000000000..2e65c031a --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.WebApi/Common/Authentication/UserTypeValidationMiddleware.cs @@ -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() { 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(); +} diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Common/Constants.cs b/src/Digdir.Domain.Dialogporten.WebApi/Common/Constants.cs index e7aa61cda..55671c8ca 100644 --- a/src/Digdir.Domain.Dialogporten.WebApi/Common/Constants.cs +++ b/src/Digdir.Domain.Dialogporten.WebApi/Common/Constants.cs @@ -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."; } } - diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Program.cs b/src/Digdir.Domain.Dialogporten.WebApi/Program.cs index 3af8fb03f..7d45c2fb4 100644 --- a/src/Digdir.Domain.Dialogporten.WebApi/Program.cs +++ b/src/Digdir.Domain.Dialogporten.WebApi/Program.cs @@ -147,6 +147,7 @@ static void BuildAndRun(string[] args) .UseJwtSchemeSelector() .UseAuthentication() .UseAuthorization() + .UseUserTypeValidation() .UseAzureConfiguration() .UseFastEndpoints(x => {