Skip to content

Commit cb9b661

Browse files
committed
Merge branch 'main' into feat/pdf-service-task
2 parents 9b652c2 + bdd828d commit cb9b661

File tree

17 files changed

+1824
-27
lines changed

17 files changed

+1824
-27
lines changed

src/Altinn.App.Api/Controllers/ProcessController.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,19 @@ private ObjectResult ExceptionResponse(Exception exception, string message)
559559
{
560560
_logger.LogError(exception, message);
561561

562+
if (exception is PlatformHttpResponseSnapshotException phse)
563+
{
564+
return StatusCode(
565+
phse.StatusCode,
566+
new ProblemDetails()
567+
{
568+
Detail = phse.Message,
569+
Status = phse.StatusCode,
570+
Title = message,
571+
}
572+
);
573+
}
574+
562575
if (exception is PlatformHttpException phe)
563576
{
564577
return StatusCode(

src/Altinn.App.Core/Extensions/HttpClientExtension.cs

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ public static class HttpClientExtension
1515
/// <param name="requestUri">The request Uri</param>
1616
/// <param name="content">The http content</param>
1717
/// <param name="platformAccessToken">The platformAccess tokens</param>
18+
/// <param name="cancellationToken">The cancellation token</param>
1819
/// <returns>A HttpResponseMessage</returns>
1920
public static async Task<HttpResponseMessage> PostAsync(
2021
this HttpClient httpClient,
2122
string authorizationToken,
2223
string requestUri,
2324
HttpContent? content,
24-
string? platformAccessToken = null
25+
string? platformAccessToken = null,
26+
CancellationToken cancellationToken = default
2527
)
2628
{
2729
using HttpRequestMessage request = new(HttpMethod.Post, requestUri);
@@ -37,7 +39,7 @@ public static async Task<HttpResponseMessage> PostAsync(
3739
request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken);
3840
}
3941

40-
return await httpClient.SendAsync(request, CancellationToken.None);
42+
return await httpClient.SendAsync(request, cancellationToken);
4143
}
4244

4345
/// <summary>
@@ -48,13 +50,15 @@ public static async Task<HttpResponseMessage> PostAsync(
4850
/// <param name="requestUri">The request Uri</param>
4951
/// <param name="content">The http content</param>
5052
/// <param name="platformAccessToken">The platformAccess tokens</param>
53+
/// <param name="cancellationToken">The cancellation token</param>
5154
/// <returns>A HttpResponseMessage</returns>
5255
public static async Task<HttpResponseMessage> PutAsync(
5356
this HttpClient httpClient,
5457
string authorizationToken,
5558
string requestUri,
5659
HttpContent? content,
57-
string? platformAccessToken = null
60+
string? platformAccessToken = null,
61+
CancellationToken cancellationToken = default
5862
)
5963
{
6064
using HttpRequestMessage request = new(HttpMethod.Put, requestUri);
@@ -70,7 +74,7 @@ public static async Task<HttpResponseMessage> PutAsync(
7074
request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken);
7175
}
7276

73-
return await httpClient.SendAsync(request, CancellationToken.None);
77+
return await httpClient.SendAsync(request, cancellationToken);
7478
}
7579

7680
/// <summary>
@@ -80,12 +84,14 @@ public static async Task<HttpResponseMessage> PutAsync(
8084
/// <param name="authorizationToken">the authorization token (jwt)</param>
8185
/// <param name="requestUri">The request Uri</param>
8286
/// <param name="platformAccessToken">The platformAccess tokens</param>
87+
/// <param name="cancellationToken">The cancellation token</param>
8388
/// <returns>A HttpResponseMessage</returns>
8489
public static async Task<HttpResponseMessage> GetAsync(
8590
this HttpClient httpClient,
8691
string authorizationToken,
8792
string requestUri,
88-
string? platformAccessToken = null
93+
string? platformAccessToken = null,
94+
CancellationToken cancellationToken = default
8995
)
9096
{
9197
using HttpRequestMessage request = new(HttpMethod.Get, requestUri);
@@ -100,7 +106,42 @@ public static async Task<HttpResponseMessage> GetAsync(
100106
request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken);
101107
}
102108

103-
return await httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, CancellationToken.None);
109+
return await httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken);
110+
}
111+
112+
/// <summary>
113+
/// Extension that adds authorization header to request and returns response configured for streaming.
114+
/// Response returns immediately after headers are received, allowing content to be streamed.
115+
/// </summary>
116+
/// <param name="httpClient">The HttpClient</param>
117+
/// <param name="authorizationToken">the authorization token (jwt)</param>
118+
/// <param name="requestUri">The request Uri</param>
119+
/// <param name="platformAccessToken">The platformAccess tokens</param>
120+
/// <param name="cancellationToken">The cancellation token</param>
121+
/// <returns>A HttpResponseMessage</returns>
122+
/// <remarks>When using GetStreamingAsync() for large file downloads, ensure your HttpClient
123+
/// instance has an appropriate timeout configured. The default timeout may be too short for large files.</remarks>
124+
public static async Task<HttpResponseMessage> GetStreamingAsync(
125+
this HttpClient httpClient,
126+
string authorizationToken,
127+
string requestUri,
128+
string? platformAccessToken = null,
129+
CancellationToken cancellationToken = default
130+
)
131+
{
132+
using HttpRequestMessage request = new(HttpMethod.Get, requestUri);
133+
134+
request.Headers.Authorization = new AuthenticationHeaderValue(
135+
Constants.AuthorizationSchemes.Bearer,
136+
authorizationToken
137+
);
138+
139+
if (!string.IsNullOrEmpty(platformAccessToken))
140+
{
141+
request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken);
142+
}
143+
144+
return await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
104145
}
105146

106147
/// <summary>
@@ -110,12 +151,14 @@ public static async Task<HttpResponseMessage> GetAsync(
110151
/// <param name="authorizationToken">the authorization token (jwt)</param>
111152
/// <param name="requestUri">The request Uri</param>
112153
/// <param name="platformAccessToken">The platformAccess tokens</param>
154+
/// <param name="cancellationToken">The cancellation token</param>
113155
/// <returns>A HttpResponseMessage</returns>
114156
public static async Task<HttpResponseMessage> DeleteAsync(
115157
this HttpClient httpClient,
116158
string authorizationToken,
117159
string requestUri,
118-
string? platformAccessToken = null
160+
string? platformAccessToken = null,
161+
CancellationToken cancellationToken = default
119162
)
120163
{
121164
using HttpRequestMessage request = new(HttpMethod.Delete, requestUri);
@@ -130,6 +173,6 @@ public static async Task<HttpResponseMessage> DeleteAsync(
130173
request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken);
131174
}
132175

133-
return await httpClient.SendAsync(request, CancellationToken.None);
176+
return await httpClient.SendAsync(request, cancellationToken);
134177
}
135178
}

src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ private static void AddValidationServices(IServiceCollection services, IConfigur
231231
services.AddTransient<IDataElementValidator, DefaultDataElementValidator>();
232232
services.AddTransient<ITaskValidator, DefaultTaskValidator>();
233233
services.AddTransient<IValidator, SigningTaskValidator>();
234+
services.AddTransient<IValidator, SignatureHashValidator>();
234235

235236
var appSettings = configuration.GetSection("AppSettings").Get<AppSettings>();
236237
if (appSettings?.RequiredValidation is true)
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
using System.Diagnostics;
2+
using System.Security.Cryptography;
3+
using Altinn.App.Core.Features.Signing.Models;
4+
using Altinn.App.Core.Features.Signing.Services;
5+
using Altinn.App.Core.Internal.App;
6+
using Altinn.App.Core.Internal.Data;
7+
using Altinn.App.Core.Internal.Process;
8+
using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties;
9+
using Altinn.App.Core.Models;
10+
using Altinn.App.Core.Models.Validation;
11+
using Altinn.Platform.Storage.Interface.Models;
12+
using Microsoft.AspNetCore.Http;
13+
using Microsoft.Extensions.Logging;
14+
15+
namespace Altinn.App.Core.Features.Validation.Default;
16+
17+
/// <summary>
18+
/// Validates that signature hashes are still valid.
19+
/// </summary>
20+
internal sealed class SignatureHashValidator(
21+
ISigningService signingService,
22+
IProcessReader processReader,
23+
IDataClient dataClient,
24+
IAppMetadata appMetadata,
25+
IHttpContextAccessor httpContextAccessor,
26+
ILogger<SignatureHashValidator> logger
27+
) : IValidator
28+
{
29+
private const string SigningTaskType = "signing";
30+
31+
/// <summary>
32+
/// We implement <see cref="ShouldRunForTask"/> instead.
33+
/// </summary>
34+
public string TaskId => "*";
35+
36+
/// <summary>
37+
/// Only runs for tasks that are of type "signing".
38+
/// </summary>
39+
public bool ShouldRunForTask(string taskId)
40+
{
41+
AltinnTaskExtension? taskConfig = processReader.GetAltinnTaskExtension(taskId);
42+
return taskConfig?.TaskType is SigningTaskType;
43+
}
44+
45+
public bool NoIncrementalValidation => true;
46+
47+
/// <inheritdoc />
48+
public Task<bool> HasRelevantChanges(IInstanceDataAccessor dataAccessor, string taskId, DataElementChanges changes)
49+
{
50+
throw new UnreachableException(
51+
"HasRelevantChanges should not be called because NoIncrementalValidation is true."
52+
);
53+
}
54+
55+
public async Task<List<ValidationIssue>> Validate(
56+
IInstanceDataAccessor dataAccessor,
57+
string taskId,
58+
string? language
59+
)
60+
{
61+
CancellationToken cancellationToken = httpContextAccessor.HttpContext?.RequestAborted ?? CancellationToken.None;
62+
63+
Instance instance = dataAccessor.Instance;
64+
65+
AltinnSignatureConfiguration signingConfiguration =
66+
processReader.GetAltinnTaskExtension(taskId)?.SignatureConfiguration
67+
?? throw new ApplicationConfigException("Signing configuration not found in AltinnTaskExtension");
68+
69+
ApplicationMetadata applicationMetadata = await appMetadata.GetApplicationMetadata();
70+
71+
List<SigneeContext> signeeContextsResults = await signingService.GetSigneeContexts(
72+
dataAccessor,
73+
signingConfiguration,
74+
cancellationToken
75+
);
76+
77+
foreach (SigneeContext signeeContext in signeeContextsResults)
78+
{
79+
List<SignDocument.DataElementSignature> dataElementSignatures =
80+
signeeContext.SignDocument?.DataElementSignatures ?? [];
81+
82+
foreach (SignDocument.DataElementSignature dataElementSignature in dataElementSignatures)
83+
{
84+
ValidationIssue? validationIssue = await ValidateDataElementSignature(
85+
dataElementSignature,
86+
instance,
87+
applicationMetadata,
88+
cancellationToken
89+
);
90+
91+
if (validationIssue != null)
92+
{
93+
return [validationIssue];
94+
}
95+
}
96+
}
97+
98+
logger.LogInformation("All signature hashes are valid for instance {InstanceId}", instance.Id);
99+
100+
return [];
101+
}
102+
103+
private async Task<ValidationIssue?> ValidateDataElementSignature(
104+
SignDocument.DataElementSignature dataElementSignature,
105+
Instance instance,
106+
ApplicationMetadata applicationMetadata,
107+
CancellationToken cancellationToken
108+
)
109+
{
110+
var instanceIdentifier = new InstanceIdentifier(instance);
111+
112+
await using Stream dataStream = await dataClient.GetBinaryDataStream(
113+
instanceIdentifier.InstanceOwnerPartyId,
114+
instanceIdentifier.InstanceGuid,
115+
Guid.Parse(dataElementSignature.DataElementId),
116+
HasRestrictedRead(applicationMetadata, instance, dataElementSignature.DataElementId)
117+
? StorageAuthenticationMethod.ServiceOwner()
118+
: null,
119+
cancellationToken
120+
);
121+
122+
string sha256Hash = await GenerateSha256Hash(dataStream);
123+
124+
if (sha256Hash != dataElementSignature.Sha256Hash)
125+
{
126+
logger.LogError(
127+
"Found an invalid signature for data element {DataElementId} on instance {InstanceId}. Expected hash {ExpectedHash}, calculated hash {CalculatedHash}",
128+
dataElementSignature.DataElementId,
129+
instance.Id,
130+
dataElementSignature.Sha256Hash,
131+
sha256Hash
132+
);
133+
134+
return new ValidationIssue
135+
{
136+
Code = ValidationIssueCodes.DataElementCodes.InvalidSignatureHash,
137+
Severity = ValidationIssueSeverity.Error,
138+
CustomTextKey = "backend.validation_errors.invalid_signature_hash",
139+
};
140+
}
141+
142+
return null;
143+
}
144+
145+
private static bool HasRestrictedRead(
146+
ApplicationMetadata applicationMetadata,
147+
Instance instance,
148+
string dataElementId
149+
)
150+
{
151+
DataElement? dataElement = instance.Data.FirstOrDefault(de => de.Id == dataElementId);
152+
string? dataTypeId = dataElement?.DataType;
153+
DataType? dataType = applicationMetadata.DataTypes.FirstOrDefault(dt => dt.Id == dataTypeId);
154+
155+
if (dataType == null)
156+
{
157+
throw new ApplicationConfigException(
158+
$"Unable to find data type {dataTypeId} for data element {dataElementId} in applicationmetadata.json."
159+
);
160+
}
161+
162+
return !string.IsNullOrEmpty(dataType.ActionRequiredToRead);
163+
}
164+
165+
private static async Task<string> GenerateSha256Hash(Stream stream)
166+
{
167+
using var sha256 = SHA256.Create();
168+
byte[] digest = await sha256.ComputeHashAsync(stream);
169+
return FormatShaDigest(digest);
170+
}
171+
172+
/// <summary>
173+
/// Formats a SHA digest with common best practice:<br/>
174+
/// Lowercase hexadecimal representation without delimiters
175+
/// </summary>
176+
/// <param name="digest">The hash code (digest) to format</param>
177+
/// <returns>String representation of the digest</returns>
178+
/// <remarks>This mirrors how the altinn-storage formats the Sha digest when creating the signature document, and it must stay in sync.</remarks>
179+
private static string FormatShaDigest(byte[] digest)
180+
{
181+
return Convert.ToHexString(digest).ToLowerInvariant();
182+
}
183+
}

0 commit comments

Comments
 (0)