Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
15 changes: 13 additions & 2 deletions src/Altinn.App.Api/Controllers/InstancesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,25 @@ CancellationToken cancellationToken

try
{
Instance instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid);
Instance instance = await _instanceClient.GetInstance(
app,
org,
instanceOwnerPartyId,
instanceGuid,
ct: cancellationToken
);
SelfLinkHelper.SetInstanceAppSelfLinks(instance, Request);

string? userOrgClaim = User.GetOrg();

if (userOrgClaim == null || !org.Equals(userOrgClaim, StringComparison.OrdinalIgnoreCase))
{
await _instanceClient.UpdateReadStatus(instanceOwnerPartyId, instanceGuid, "read");
await _instanceClient.UpdateReadStatus(
instanceOwnerPartyId,
instanceGuid,
"read",
ct: cancellationToken
);
}

var instanceOwnerParty = await _registerClient.GetPartyUnchecked(instanceOwnerPartyId, cancellationToken);
Expand Down
113 changes: 106 additions & 7 deletions src/Altinn.App.Api/Controllers/PaymentController.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
using System.Security.Cryptography;
using System.Text;
using Altinn.App.Api.Infrastructure.Filters;
using Altinn.App.Core.Features;
using Altinn.App.Core.Features.Payment;
using Altinn.App.Core.Features.Payment.Exceptions;
using Altinn.App.Core.Features.Payment.Models;
using Altinn.App.Core.Features.Payment.Processors.Nets;
using Altinn.App.Core.Features.Payment.Processors.Nets.Models;
using Altinn.App.Core.Features.Payment.Services;
using Altinn.App.Core.Internal.Instances;
using Altinn.App.Core.Internal.Process;
using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties;
using Altinn.Platform.Storage.Interface.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;

namespace Altinn.App.Api.Controllers;

Expand All @@ -24,18 +29,18 @@ public class PaymentController : ControllerBase
private readonly IProcessReader _processReader;
private readonly IPaymentService _paymentService;
private readonly AppImplementationFactory _appImplementationFactory;
private readonly ILogger<PaymentController> _logger;
private readonly IOptions<NetsPaymentSettings> _netsPaymentSettings;

/// <summary>
/// Initializes a new instance of the <see cref="PaymentController"/> class.
/// </summary>
public PaymentController(
IServiceProvider serviceProvider,
IInstanceClient instanceClient,
IProcessReader processReader
)
public PaymentController(IServiceProvider serviceProvider)
{
_instanceClient = instanceClient;
_processReader = processReader;
_instanceClient = serviceProvider.GetRequiredService<IInstanceClient>();
_processReader = serviceProvider.GetRequiredService<IProcessReader>();
_logger = serviceProvider.GetRequiredService<ILogger<PaymentController>>();
_netsPaymentSettings = serviceProvider.GetRequiredService<IOptions<NetsPaymentSettings>>();
_paymentService = serviceProvider.GetRequiredService<IPaymentService>();
_appImplementationFactory = serviceProvider.GetRequiredService<AppImplementationFactory>();
}
Expand All @@ -61,6 +66,10 @@ public async Task<IActionResult> GetPaymentInformation(
)
{
Instance instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid);
if (instance.Process?.CurrentTask?.ElementId == null)
{
return BadRequest("Instance has no current task");
}
AltinnPaymentConfiguration? paymentConfiguration = _processReader
.GetAltinnTaskExtension(instance.Process.CurrentTask.ElementId)
?.PaymentConfiguration;
Expand Down Expand Up @@ -114,4 +123,94 @@ public async Task<IActionResult> GetOrderDetails(

return Ok(orderDetails);
}

/// <summary>
/// Endpoint to receive payment webhooks from the payment processor.
/// </summary>
/// <param name="org">unique identifier of the organisation responsible for the app</param>
/// <param name="app">application identifier which is unique within an organisation</param>
/// <param name="instanceOwnerPartyId">unique id of the party that this the owner of the instance</param>
/// <param name="instanceGuid">unique id to identify the instance</param>
/// <param name="webhookPayload">The webhook payload from nets</param>
/// <param name="authorizationHeader"></param>
/// <returns>Acknowledgement of the webhook</returns>
[HttpPost("nets-webhook-listener")]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> PaymentWebhookListener(
[FromRoute] string org,
[FromRoute] string app,
[FromRoute] int instanceOwnerPartyId,
[FromRoute] Guid instanceGuid,
[FromBody] NetsCompleteWebhookPayload webhookPayload,
[FromHeader(Name = "Authorization")] string authorizationHeader
)
{
if (_netsPaymentSettings.Value.WebhookCallbackKey == null)
{
_logger.LogWarning(
"Received Nets webhook callback, but no WebhookCallbackKey is configured. Ignoring the callback."
);
return NotFound(
"Received Nets webhook callback, but no WebhookCallbackKey is configured. Ignoring the callback"
);
}

var expectedHeader = $"Bearer {_netsPaymentSettings.Value.WebhookCallbackKey}";
var headersEqual = CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(authorizationHeader ?? string.Empty),
Encoding.UTF8.GetBytes(expectedHeader)
);
if (!headersEqual)
{
_logger.LogWarning(
"Received Nets webhook callback with invalid authorization header. Ignoring the callback."
);
return Unauthorized("Invalid authorization header");
}

_logger.LogInformation(
"Received valid Nets webhook callback for instance {InstanceGuid} for {Payment}",
instanceGuid,
webhookPayload.Data.PaymentId
);

var instance = await _instanceClient.GetInstance(
app,
org,
instanceOwnerPartyId,
instanceGuid,
StorageAuthenticationMethod.ServiceOwner()
);

if (instance.Process?.CurrentTask?.ElementId == null)
{
_logger.LogWarning(
"Instance has no current task. Cannot process Nets webhook callback for instance {InstanceGuid}",
instanceGuid
);
return BadRequest("Instance has no current task");
}

AltinnPaymentConfiguration? paymentConfiguration = _processReader
.GetAltinnTaskExtension(instance.Process.CurrentTask.ElementId)
?.PaymentConfiguration;

if (paymentConfiguration == null)
{
_logger.LogWarning(
"Payment configuration not found in AltinnTaskExtension, or instance not part of task. Cannot process Nets webhook callback."
);
throw new PaymentException("Payment configuration not found in AltinnTaskExtension");
}

var validPaymentConfiguration = paymentConfiguration.Validate();

// Update payment status using ServiceOwner authentication
await _paymentService.HandlePaymentCompletedWebhook(
instance,
validPaymentConfiguration,
StorageAuthenticationMethod.ServiceOwner()
);
return Ok();
}
}
3 changes: 2 additions & 1 deletion src/Altinn.App.Clients.Fiks/FiksArkiv/FiksArkivHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Altinn.App.Clients.Fiks.FiksArkiv.Models;
using Altinn.App.Clients.Fiks.FiksIO;
using Altinn.App.Clients.Fiks.FiksIO.Models;
using Altinn.App.Core.Constants;
using Altinn.App.Core.Features;
using Altinn.App.Core.Internal.AppModel;
using Altinn.App.Core.Internal.Process.Elements;
Expand Down Expand Up @@ -267,7 +268,7 @@ internal async Task IncomingMessageListener(FiksIOReceivedMessage message)
/// </summary>
private static bool CurrentTaskIsFiksArkiv(Instance? instance) =>
instance?.Process?.CurrentTask?.AltinnTaskType?.Equals(
FiksArkivServiceTask.Identifier,
AltinnTaskTypes.FiksArkiv,
StringComparison.OrdinalIgnoreCase
)
is true;
Expand Down
4 changes: 2 additions & 2 deletions src/Altinn.App.Clients.Fiks/FiksArkiv/FiksArkivServiceTask.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Altinn.App.Clients.Fiks.Constants;
using Altinn.App.Clients.Fiks.FiksArkiv.Models;
using Altinn.App.Core.Constants;
using Altinn.App.Core.Internal.Process.ProcessTasks.ServiceTasks;
using Altinn.Platform.Storage.Interface.Models;
using Microsoft.Extensions.Logging;
Expand All @@ -13,8 +14,7 @@ internal sealed class FiksArkivServiceTask : IServiceTask
private readonly IFiksArkivHost _fiksArkivHost;
private readonly FiksArkivSettings _fiksArkivSettings;

internal const string Identifier = "fiksArkiv";
public string Type => Identifier;
public string Type => AltinnTaskTypes.FiksArkiv;

public FiksArkivServiceTask(
IFiksArkivHost fiksArkivHost,
Expand Down
47 changes: 47 additions & 0 deletions src/Altinn.App.Core/Constants/AltinnTaskTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace Altinn.App.Core.Constants;

/// <summary>
/// Constants for Altinn task types.
/// </summary>
internal static class AltinnTaskTypes
{
/// <summary>
/// The payment task type.
/// </summary>
public const string Payment = "payment";

/// <summary>
/// The signing task type.
/// </summary>
public const string Signing = "signing";

/// <summary>
/// The data task type for collecting data from the user in a form.
/// </summary>
public const string Data = "data";

/// <summary>
/// The feedback task type for waiting for a service owner integration to push update the instance.
/// </summary>
public const string Feedback = "feedback";

/// <summary>
/// Service task type for generating a pdf document.
/// </summary>
public const string Pdf = "pdf";

/// <summary>
/// The eFormidling task type when waiting for confirmation that the instance has been sent to eFormidling.
/// </summary>
public const string EFormidling = "eFormidling";

/// <summary>
/// The FiksArkiv task type.
/// </summary>
public const string FiksArkiv = "fiksArkiv";

/// <summary>
/// The confirmation task type. (Simple version of Sign without creating a signature document)
/// </summary>
public const string Confirmation = "confirmation";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System.Text.Json.Serialization;

namespace Altinn.App.Core.Features.Payment.Processors.Nets.Models;

/// <summary>
/// Payload received from Nets when a payment is completed.
/// </summary>
public sealed class NetsCompleteWebhookPayload
{
/// <summary>
/// The unique identifier of the payment.
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; set; }

/// <summary>
/// The MerchantId of the payment.
/// </summary>
[JsonPropertyName("merchantId")]
public required int MerchantId { get; set; }

/// <summary>
/// Timestamp of when the payment was created.
/// </summary>
[JsonPropertyName("timestamp")]
public required DateTimeOffset Timestamp { get; set; }

/// <summary>
/// The event name.
/// </summary>
[JsonPropertyName("event")]
public required string EventName { get; set; }

/// <summary>
/// The data of the payment.
/// </summary>
[JsonPropertyName("data")]
public required NetsCompleteWebhookPayloadData Data { get; set; }
}

/// <summary>
/// Represents the detailed data contained within the payload of a completed payment notification from Nets.
/// </summary>
public sealed class NetsCompleteWebhookPayloadData
{
/// <summary>
/// The unique identifier of the payment.
/// </summary>
[JsonPropertyName("paymentId")]
public required string PaymentId { get; set; }
//TODO: Add other properties if needed in the future
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ public async Task<PaymentDetails> StartPayment(Instance instance, OrderDetails o
"Payer is missing in orderDetails. MerchantHandlesConsumerData is set to true. Payer must be provided."
);
}
if (string.IsNullOrEmpty(_settings.WebhookCallbackKey))
{
throw new PaymentException(
"WebhookCallbackKey is not configured in NetsPaymentSettings. It must be set to a secure random value."
);
}

var payment = new NetsCreatePayment()
{
Expand Down Expand Up @@ -89,6 +95,18 @@ public async Task<PaymentDetails> StartPayment(Instance instance, OrderDetails o
Enabled = x.Enabled,
})
.ToList(),
Notifications = new NetsNotifications()
{
WebHooks =
[
new NetsWebHook()
{
Authorization = _settings.WebhookCallbackKey,
Url = $"{baseUrl}instances/{instance.Id}/payment/nets-webhook-listener",
EventName = "payment.checkout.completed",
},
],
},
Checkout = new NetsCheckout
{
IntegrationType = "HostedPaymentPage",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations;

namespace Altinn.App.Core.Features.Payment.Processors.Nets;

/// <summary>
Expand All @@ -10,6 +12,20 @@ public class NetsPaymentSettings
/// </summary>
public required string SecretApiKey { get; set; }

/// <summary>
/// Key used to validate webhook callbacks from Nets Easy. Should be set in the secret manager being used. Never check in to source control.
/// </summary>
/// <remarks>
/// The credentials that will be sent in the HTTP Authorization request header of the callback. Must be between 8 and 64 characters long and contain alphanumeric characters.
/// Length: 8-64
/// Pattern: @^[a-zA-Z0-9\-= ]*$
/// </remarks>
// TODO: Make this required and not null in V9
[RegularExpression("^[a-zA-Z0-9\\-= ]*$")]
[MinLength(8)]
[MaxLength(64)]
public string? WebhookCallbackKey { get; set; }

/// <summary>
/// Base API url for Nets Easy.
/// </summary>
Expand Down
10 changes: 10 additions & 0 deletions src/Altinn.App.Core/Features/Payment/Services/IPaymentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ Task<PaymentInformation> CheckAndStorePaymentStatus(
string? language
);

/// <summary>
/// Handle webhook callback from payment provider indicating that the payment is completed.
/// Calls the provider for status, not trusting the webhook alone.
/// </summary>
Task HandlePaymentCompletedWebhook(
Instance instance,
ValidAltinnPaymentConfiguration paymentConfiguration,
StorageAuthenticationMethod storageAuthenticationMethod
);

/// <summary>
/// Get our internal payment status. Will only check the local status and will not get updated status from the payment provider.
/// </summary>
Expand Down
Loading
Loading