Implement forgot your password using swiyu, ASP.NET Core Identity and Aspire

This post show to implement a “Forgot your password” flow using the Swiss Digital identity and trust infrastructure, (swiyu) in an ASP.NET Core web application using ASP.NET Core Identity and Duende IdentityServer. The generic containers from swiyu are used to integrate the Swiss E-ID and the OpenID for Verifiable Presentations standards. .NET Aspire is used to implement the applications and run the containers.

Code: https://github.com/swiss-ssi-group/swiyu-idp-mfa-aspire-aspnetcore

Blogs in this series

Setup

The solution is configured to use Duende IdentityServer as the OpenID Connect implementation within a web application that leverages ASP.NET Core Identity for identity and access management (IAM). Users authenticate with a password as the primary factor and swiyu as the second factor. The swiyu 2FA mechanism is already registered and linked to the user’s account, ensuring a smooth login experience. If a user needs to reset their password, the swiyu identity check can be used to verify their identity, enabling a secure self-service password reset process. The swiyu verification logic is implemented using the swiyu generic containers, which provide the necessary APIs and structure for identity validation and request handling.

Forgot your password

A Razor page is used to implement the forgot your password logic. This page verifies the user identity using the swiyu generic containers and the OpenID for Verifiable Presentations standard. If the verification succeeds and the GeneratePasswordResetTokenAsync method is used to create a reset password flow for the associated account of the verified identity. The code is returned and the application opens the reset password logic where the user must verify an email and can reset the password.

using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores;
using Idp.Swiyu.IdentityProvider.Data;
using Idp.Swiyu.IdentityProvider.Models;
using Idp.Swiyu.IdentityProvider.SwiyuServices;
using ImageMagick;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using Net.Codecrete.QrCodeGenerator;
using System.Security.Claims;
using System.Text;
using System.Text.Json;

namespace Idp.Swiyu.IdentityProvider.Pages.ForgotPassword;

[AllowAnonymous]
public class ForgotPasswordSwiyuModel : PageModel
{
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly SignInManager<ApplicationUser> _signInManager;
    private readonly IIdentityServerInteractionService _interaction;
    private readonly IEventService _events;
    private readonly IAuthenticationSchemeProvider _schemeProvider;
    private readonly IIdentityProviderStore _identityProviderStore;
    private readonly IHttpClientFactory _clientFactory;
    private readonly ApplicationDbContext _applicationDbContext;

    [BindProperty]
    public string? ReturnUrl { get; set; }

    private readonly VerificationService _verificationService;
    private readonly string? _swiyuOid4vpUrl;

    [BindProperty]
    public string? VerificationId { get; set; }

    [BindProperty]
    public string? QrCodeUrl { get; set; } = string.Empty;

    [BindProperty]
    public byte[]? QrCodePng { get; set; } = [];

    public ForgotPasswordSwiyuModel(
        IIdentityServerInteractionService interaction,
        IAuthenticationSchemeProvider schemeProvider,
        IIdentityProviderStore identityProviderStore,
        IEventService events,
        UserManager<ApplicationUser> userManager,
        SignInManager<ApplicationUser> signInManager,
        VerificationService verificationService,
        IHttpClientFactory clientFactory,
        IConfiguration configuration,
        ApplicationDbContext applicationDbContext)
    {
        _userManager = userManager;
        _signInManager = signInManager;
        _interaction = interaction;
        _schemeProvider = schemeProvider;
        _identityProviderStore = identityProviderStore;
        _events = events;

        _clientFactory = clientFactory;
        _applicationDbContext = applicationDbContext;

        _verificationService = verificationService;
        _swiyuOid4vpUrl = configuration["SwiyuOid4vpUrl"];
        QrCodeUrl = QrCodeUrl.Replace("{OID4VP_URL}", _swiyuOid4vpUrl);
    }

    public async Task<IActionResult> OnGet(string? returnUrl)
    {
        if (returnUrl != null)
        {
            // check if we are in the context of an authorization request
            var context = await _interaction.GetAuthorizationContextAsync(returnUrl);

            ReturnUrl = returnUrl;
        }

        var presentation = await _verificationService
            .CreateBetaIdVerificationPresentationAsync();

        var verificationResponse = JsonSerializer.Deserialize<CreateVerificationPresentationModel>(presentation);
        // verification_url
        QrCodeUrl = verificationResponse!.verification_url;

        var qrCode = QrCode.EncodeText(verificationResponse!.verification_url, QrCode.Ecc.Quartile);
        QrCodePng = qrCode.ToPng(20, 4, MagickColors.Black, MagickColors.White);

        VerificationId = verificationResponse.id;

        return Page();
    }

    public async Task<IActionResult> OnPost()
    {
        VerificationClaims verificationClaims = null!;
        try
        {
            if (VerificationId == null)
            {
                return BadRequest(new { error = "400", error_description = "Missing argument 'VerificationId'" });
            }

            var verificationModel = await RequestSwiyuClaimsAsync(1, VerificationId);

            verificationClaims = _verificationService.GetVerifiedClaims(verificationModel);

            // check if we are in the context of an authorization request
            var context = await _interaction.GetAuthorizationContextAsync(ReturnUrl);

            if (ModelState.IsValid)
            {
                var claims = new List<Claim>
                {
                    new Claim("name", verificationClaims.GivenName),
                    new Claim("family_name", verificationClaims.FamilyName),
                    new Claim("birth_place", verificationClaims.BirthPlace),
                    new Claim("birth_date", verificationClaims.BirthDate)
                };

                var exists = _applicationDbContext.SwiyuIdentity.FirstOrDefault(c =>
                    c.BirthDate == verificationClaims.BirthDate &&
                    c.BirthPlace == verificationClaims.BirthPlace &&
                    c.GivenName == verificationClaims.GivenName &&
                    c.FamilyName == verificationClaims.FamilyName);

                if (exists != null)
                {
                    var user = await _userManager.FindByIdAsync(exists.UserId);

                    if (user == null)
                    {
                        // This should return a user message with no info what went wrong.
                        throw new ArgumentNullException("error in authentication");
                    }

                    var code = await _userManager.GeneratePasswordResetTokenAsync(user);
                    code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));

                    var callbackUrl = Url.Page(
                        "/Account/ResetPassword",
                        pageHandler: null,
                        values: new { area = "Identity", code },
                        protocol: Request.Scheme);

                    return Redirect(callbackUrl!);
                }
            }
        }
        catch (Exception ex)
        {
            return BadRequest(new { error = "400", error_description = ex.Message });
        }

        return Page();
    }

    internal async Task<VerificationManagementModel> RequestSwiyuClaimsAsync(int interval, string verificationId)
    {
        var client = _clientFactory.CreateClient();

        while (true)
        {

            var verificationModel = await _verificationService.GetVerificationStatus(verificationId);

            if (verificationModel != null && verificationModel.state == "SUCCESS")
            {
                return verificationModel;
            }
            else
            {
                await Task.Delay(interval * 1000);
            }
        }
    }
}

Verification using the swiyu generic containers

The VerificationService is designed to implement the core logic behind the swiyu generic containers. These containers act as a foundational component, providing a structured and consistent way to manage verification workflows. They expose the swiyu defined APIs that allow seamless interaction with other services and applications. Through these APIs, developers can easily create a presentation request, which serves as the initial step in the identity verification process. In addition to generating requests, the containers also handle the critical task of verifying the identity, ensuring that the provided credentials meet the required OpenID standards.

using System.Text;
using System.Text.Json;
using System.Web;

namespace Idp.Swiyu.IdentityProvider.SwiyuServices;

public class VerificationService
{
    private readonly ILogger<VerificationService> _logger;
    private readonly string? _swiyuVerifierMgmtUrl;
    private readonly string? _issuerId;
    private readonly HttpClient _httpClient;

    public VerificationService(IHttpClientFactory httpClientFactory,
        ILoggerFactory loggerFactory, IConfiguration configuration)
    {
        _swiyuVerifierMgmtUrl = configuration["SwiyuVerifierMgmtUrl"];
        _issuerId = configuration["ISSUER_ID"];
        _httpClient = httpClientFactory.CreateClient();
        _logger = loggerFactory.CreateLogger<VerificationService>();
    }

    /// <summary>
    /// curl - X POST http://localhost:8082/api/v1/verifications \
    ///       -H "accept: application/json" \
    ///       -H "Content-Type: application/json" \
    ///       -d '
    /// </summary>
    public async Task<string> CreateBetaIdVerificationPresentationAsync()
    {
        _logger.LogInformation("Creating verification presentation");

        // from "betaid-sdjwt"
        var acceptedIssuerDid = "did:tdw:QmPEZPhDFR4nEYSFK5bMnvECqdpf1tPTPJuWs9QrMjCumw:identifier-reg.trust-infra.swiyu-int.admin.ch:api:v1:did:9a5559f0-b81c-4368-a170-e7b4ae424527";

        var inputDescriptorsId = Guid.NewGuid().ToString();
        var presentationDefinitionId = "00000000-0000-0000-0000-000000000000"; // Guid.NewGuid().ToString();

        var json = GetBetaIdVerificationPresentationBody(inputDescriptorsId,
            presentationDefinitionId, acceptedIssuerDid, "betaid-sdjwt");

        return await SendCreateVerificationPostRequest(json);
    }

    public async Task<VerificationManagementModel?> GetVerificationStatus(string verificationId)
    {
        var idEncoded = HttpUtility.UrlEncode(verificationId);
        using HttpResponseMessage response = await _httpClient.GetAsync(
            $"{_swiyuVerifierMgmtUrl}/api/v1/verifications/{idEncoded}");

        if (response.IsSuccessStatusCode)
        {
            var jsonResponse = await response.Content.ReadAsStringAsync();

            if (jsonResponse == null)
            {
                _logger.LogError("GetVerificationStatus no data returned from Swiyu");
                return null;
            }

            //  state: PENDING, SUCCESS, FAILED
            return JsonSerializer.Deserialize<VerificationManagementModel>(jsonResponse);
        }

        var error = await response.Content.ReadAsStringAsync();
        _logger.LogError("Could not create verification presentation {vp}", error);

        throw new ArgumentException(error);
    }

    /// <summary>
    /// In a business app we can use the data from the verificationModel
    /// Verification data:
    /// Use: wallet_response/credential_subject_data
    ///
    /// birth_date, given_name, family_name, birth_place
    /// 
    /// </summary>
    /// <param name="verificationManagementModel"></param>
    /// <returns></returns>
    public VerificationClaims GetVerifiedClaims(VerificationManagementModel verificationManagementModel)
    {
        var json = verificationManagementModel.wallet_response!.credential_subject_data!.ToString();

        var jsonElement = JsonDocument.Parse(json!).RootElement;

        var claims = new VerificationClaims
        {
            BirthDate = jsonElement.GetProperty("birth_date").ToString(),
            BirthPlace = jsonElement.GetProperty("birth_place").ToString(),
            FamilyName = jsonElement.GetProperty("family_name").ToString(),
            GivenName = jsonElement.GetProperty("given_name").ToString()
        };

        return claims;
    }

    private async Task<string> SendCreateVerificationPostRequest(string json)
    {
        var jsonContent = new StringContent(json, Encoding.UTF8, "application/json");
        var response = await _httpClient.PostAsync(
                    $"{_swiyuVerifierMgmtUrl}/api/v1/verifications", jsonContent);
        if (response.IsSuccessStatusCode)
        {
            var jsonResponse = await response.Content.ReadAsStringAsync();

            return jsonResponse;
        }

        var error = await response.Content.ReadAsStringAsync();
        _logger.LogError("Could not create verification presentation {vp}", error);

        throw new ArgumentException(error);
    }

    /// <summary>
    /// There will be private companies having a need to do identification routines (e.g. KYC or before issuing another credential), 
    /// asking for given_name, family_name, birth_date and birth_place.
    /// 
    /// { "path": [ "$.birth_date" ] },
    /// { "path": ["$.given_name"] },
    /// { "path": ["$.family_name"] },
    /// { "path": ["$.birth_place"] },
    /// </summary>
    private static string GetBetaIdVerificationPresentationBody(string inputDescriptorsId, string presentationDefinitionId, string acceptedIssuerDid, string vcType)
    {
        var json = $$"""
             {
                 "accepted_issuer_dids": [ "{{acceptedIssuerDid}}" ],
                 "jwt_secured_authorization_request": true,
                 "presentation_definition": {
                     "id": "{{presentationDefinitionId}}",
                     "name": "Verification",
                     "purpose": "Verify using Beta ID",
                     "input_descriptors": [
                         {
                             "id": "{{inputDescriptorsId}}",
                             "format": {
                                 "vc+sd-jwt": {
                                     "sd-jwt_alg_values": [
                                         "ES256"
                                     ],
                                     "kb-jwt_alg_values": [
                                         "ES256"
                                     ]
                                 }
                             },
                             "constraints": {
             	                "fields": [
             		                {
             			                "path": [
             				                "$.vct"
             			                ],
             			                "filter": {
             				                "type": "string",
             				                "const": "{{vcType}}"
             			                }
             		                },
             		                { "path": [ "$.birth_date" ] },
             		                { "path": [ "$.given_name" ] },
                                    { "path": [ "$.family_name" ] },
                                    { "path": [ "$.birth_place" ] }
             	                ]
                             }
                         }
                     ]
                 }
             }
             """;

        return json;
    }
}

Notes

Using the Swiss E-ID as an identity check for resetting a password is an effective and secure approach. This method ensures that the identity being verified is authentic, as the Swiss E-ID is a trusted and government-backed credential. By leveraging this verified identity, the system can confidently match the user to their account, reducing the risk of unauthorized access. This process not only strengthens security but also simplifies the user experience by providing a reliable and standardized way to confirm identity. Incorporating Swiss E-ID into password reset workflows helps maintain compliance with strict identity assurance requirements while offering a seamless and trustworthy solution for both users and service providers.

Links

https://swiyu-admin-ch.github.io/

https://www.eid.admin.ch/en/public-beta-e

https://duendesoftware.com/products/identityserver

https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview

https://www.npmjs.com/package/ngrok

https://swiyu-admin-ch.github.io/specifications/interoperability-profile/

https://andrewlock.net/converting-a-docker-compose-file-to-aspire/

https://swiyu-admin-ch.github.io/cookbooks/onboarding-generic-verifier/

https://github.com/orgs/swiyu-admin-ch/projects/2/views/2

Standards

https://identity.foundation/trustdidweb/

https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html

https://openid.net/specs/openid-4-verifiable-presentations-1_0.html

https://datatracker.ietf.org/doc/draft-ietf-oauth-selective-disclosure-jwt/

https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/

https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/

https://www.w3.org/TR/vc-data-model-2.0/

3 comments

  1. Unknown's avatar

    […] Implement forgot your password using swiyu, ASP.NET Core Identity and Aspire […]

  2. […] Implement forgot your password using swiyu, ASP.NET Core Identity and Aspire (Damien Bowden) […]

Leave a reply to Use swiyu, the Swiss E-ID to authenticate users with Duende and .NET Aspire | Software Engineering Cancel reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.