diff --git a/README.md b/README.md index 54ed071..d2d2939 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,116 @@ When using standard [ASP.NET cookie authentication](https://docs.microsoft.com/e } ``` +- Similarly, the `MobileAuthInitController` generates a challenge nonce and returns the mobile deep-link for starting the Web eID Mobile authentication flow, and the `MobileAuthLoginController` handles the mobile login request by validating the returned authentication token and creating the authentication cookie. + ```cs + using System; + using System.Text; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Extensions.Options; + using System.Text.Json; + using System.Text.Json.Serialization; + using Options; + using Security.Challenge; + + [ApiController] + [Route("auth/mobile")] + public class MobileAuthInitController( + IChallengeNonceGenerator nonceGenerator, + IOptions mobileOptions + ) : ControllerBase + { + private const string WebEidMobileAuthPath = "auth"; + private const string MobileLoginPath = "/auth/mobile/login"; + + [HttpPost("init")] + public IActionResult Init() + { + var challenge = nonceGenerator.GenerateAndStoreNonce(TimeSpan.FromMinutes(5)); + var challengeBase64 = challenge.Base64EncodedNonce; + + var loginUri = $"{Request.Scheme}://{Request.Host}{MobileLoginPath}"; + + var payload = new AuthPayload + { + Challenge = challengeBase64, + LoginUri = loginUri, + GetSigningCertificate = mobileOptions.Value.RequestSigningCert ? true : null + }; + + var json = JsonSerializer.Serialize(payload); + var encodedPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + + var authUri = BuildAuthUri(encodedPayload); + + return Ok(new AuthUri + { + AuthUriValue = authUri + }); + } + ``` + + ```cs + using Microsoft.AspNetCore.Mvc; + using System.Text.Json; + using Dto; + using Security.Challenge; + using Security.Validator; + using System.Security.Claims; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Authentication; + using Microsoft.AspNetCore.Authentication.Cookies; + using Security.Util; + + [ApiController] + [Route("auth/mobile")] + public class MobileAuthLoginController( + IAuthTokenValidator authTokenValidator, + IChallengeNonceStore challengeNonceStore + ) : ControllerBase + { + [HttpPost("login")] + public async Task MobileLogin([FromBody] AuthenticateRequestDto dto) + { + if (dto?.AuthToken == null) + { + return BadRequest(new { error = "Missing auth_token" }); + } + + var parsedToken = dto.AuthToken; + var certificate = await authTokenValidator.Validate( + parsedToken, + challengeNonceStore.GetAndRemove().Base64EncodedNonce); + + var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme); + + identity.AddClaim(new Claim(ClaimTypes.GivenName, certificate.GetSubjectGivenName())); + identity.AddClaim(new Claim(ClaimTypes.Surname, certificate.GetSubjectSurname())); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, certificate.GetSubjectIdCode())); + identity.AddClaim(new Claim(ClaimTypes.Name, certificate.GetSubjectCn())); + + if (!string.IsNullOrEmpty(parsedToken.UnverifiedSigningCertificate)) + { + identity.AddClaim(new Claim("signingCertificate", parsedToken.UnverifiedSigningCertificate)); + } + + if (parsedToken.SupportedSignatureAlgorithms != null) + { + identity.AddClaim(new Claim( + "supportedSignatureAlgorithms", + JsonSerializer.Serialize(parsedToken.SupportedSignatureAlgorithms))); + } + + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(identity), + new AuthenticationProperties { IsPersistent = false }); + + return Ok(new { redirect = "/welcome" }); + } + } + ``` + + # Table of contents * [Introduction](#introduction) diff --git a/example/README.md b/example/README.md index ad41bd2..366f29a 100644 --- a/example/README.md +++ b/example/README.md @@ -163,7 +163,8 @@ The `src\WebEid.AspNetCore.Example` directory contains the ASP.NET application s - digital signing, - `DigiDoc`: contains the C# binding files of the `libdigidocpp` library; these files must be copied from the `libdigidocpp` installation directory `\include\digidocpp_csharp`, - `Pages`: Razor pages, -- `Signing`: Web eID signing service implementation that uses `libdigidocpp`. +- `Services`: Web eID signing service implementation that uses `libdigidocpp`. +- `Options`: strongly-typed configuration classes for mobile Web eID settings such as `BaseRequestUri` and `RequestSigningCert` (when set to false, initiates a separate signing-certificate flow to demo requesting the certificate without prior authentication, as the signing certificate normally comes from the authentication flow). ## More information diff --git a/example/src/WebEid.AspNetCore.Example/Controllers/Api/AuthController.cs b/example/src/WebEid.AspNetCore.Example/Controllers/Api/AuthController.cs index b8873fd..df735e6 100644 --- a/example/src/WebEid.AspNetCore.Example/Controllers/Api/AuthController.cs +++ b/example/src/WebEid.AspNetCore.Example/Controllers/Api/AuthController.cs @@ -22,13 +22,16 @@ namespace WebEid.AspNetCore.Example.Controllers.Api using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Mvc; + using Microsoft.AspNetCore.Http; using Security.Util; using Security.Validator; using System.Collections.Generic; using System.Security.Claims; + using System.Text.Json; using System.Threading.Tasks; using Security.Challenge; using WebEid.AspNetCore.Example.Dto; + using WebEid.Security.AuthToken; using System; [Route("[controller]")] @@ -44,44 +47,68 @@ public AuthController(IAuthTokenValidator authTokenValidator, IChallengeNonceSto this.challengeNonceStore = challengeNonceStore; } - [HttpPost] - [Route("login")] - public async Task Login([FromBody] AuthenticateRequestDto authToken) + [HttpPost("login")] + public async Task Login([FromBody] AuthenticateRequestDto dto) { - var certificate = await this.authTokenValidator.Validate(authToken.AuthToken, this.challengeNonceStore.GetAndRemove().Base64EncodedNonce); + try + { + await SignInUser(dto?.AuthToken); + return Ok(); + } + catch (ArgumentNullException) + { + return BadRequest(new { error = "Missing auth_token" }); + } + } - List claims = new(); + [HttpPost("logout")] + public async Task Logout() + { + RemoveUserContainerFile(); + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + } + + private async Task SignInUser(WebEidAuthToken authToken) + { + if (authToken == null) + { + throw new ArgumentNullException(nameof(authToken), "authToken must not be null"); + } + + var certificate = await authTokenValidator.Validate(authToken, challengeNonceStore.GetAndRemove().Base64EncodedNonce); + var claims = new List(); AddNewClaimIfCertificateHasData(claims, ClaimTypes.GivenName, certificate.GetSubjectGivenName); AddNewClaimIfCertificateHasData(claims, ClaimTypes.Surname, certificate.GetSubjectSurname); AddNewClaimIfCertificateHasData(claims, ClaimTypes.NameIdentifier, certificate.GetSubjectIdCode); AddNewClaimIfCertificateHasData(claims, ClaimTypes.Name, certificate.GetSubjectCn); - var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + if (!string.IsNullOrEmpty(authToken.UnverifiedSigningCertificate)) + { + claims.Add(new Claim( + "signingCertificate", + authToken.UnverifiedSigningCertificate)); + } - var authProperties = new AuthenticationProperties + if (authToken.SupportedSignatureAlgorithms != null) { - AllowRefresh = true - }; + claims.Add(new Claim( + "supportedSignatureAlgorithms", + JsonSerializer.Serialize(authToken.SupportedSignatureAlgorithms))); + } + + var identity = new ClaimsIdentity(claims,CookieAuthenticationDefaults.AuthenticationScheme); await HttpContext.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, - new ClaimsPrincipal(claimsIdentity), - authProperties); + new ClaimsPrincipal(identity), + new AuthenticationProperties { AllowRefresh = true }); // Assign a unique ID within the session to enable the use of a unique temporary container name across successive requests. // A unique temporary container name is required to facilitate simultaneous signing from multiple browsers. SetUniqueIdInSession(); } - [HttpGet] - [Route("logout")] - public async Task Logout() - { - RemoveUserContainerFile(); - await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); - } - private static void AddNewClaimIfCertificateHasData(List claims, string claimType, Func dataGetter) { var claimData = dataGetter(); diff --git a/example/src/WebEid.AspNetCore.Example/Controllers/Api/MobileAuthInitController.cs b/example/src/WebEid.AspNetCore.Example/Controllers/Api/MobileAuthInitController.cs new file mode 100644 index 0000000..6e89f6b --- /dev/null +++ b/example/src/WebEid.AspNetCore.Example/Controllers/Api/MobileAuthInitController.cs @@ -0,0 +1,98 @@ +// Copyright (c) 2025-2025 Estonian Information System Authority +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +namespace WebEid.AspNetCore.Example.Controllers.Api +{ + using System; + using System.Text; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Extensions.Options; + using System.Text.Json; + using System.Text.Json.Serialization; + using Options; + using Security.Challenge; + + [ApiController] + [Route("auth/mobile")] + public class MobileAuthInitController( + IChallengeNonceGenerator nonceGenerator, + IOptions mobileOptions + ) : ControllerBase + { + private const string WebEidMobileAuthPath = "auth"; + private const string MobileLoginPath = "/auth/mobile/login"; + + [HttpPost("init")] + public IActionResult Init() + { + var challenge = nonceGenerator.GenerateAndStoreNonce(TimeSpan.FromMinutes(5)); + var challengeBase64 = challenge.Base64EncodedNonce; + + var loginUri = $"{Request.Scheme}://{Request.Host}{MobileLoginPath}"; + + var payload = new AuthPayload + { + Challenge = challengeBase64, + LoginUri = loginUri, + GetSigningCertificate = mobileOptions.Value.RequestSigningCert ? true : null + }; + + var json = JsonSerializer.Serialize(payload); + var encodedPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + + var authUri = BuildAuthUri(encodedPayload); + + return Ok(new AuthUri + { + AuthUriValue = authUri + }); + } + + private string BuildAuthUri(string encodedPayload) + { + var baseUri = mobileOptions.Value.BaseRequestUri; + + return baseUri.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? $"{baseUri.TrimEnd('/')}/{WebEidMobileAuthPath}#{encodedPayload}" + : $"{baseUri}{WebEidMobileAuthPath}#{encodedPayload}"; + } + + private sealed record AuthPayload + { + [JsonInclude] + [JsonPropertyName("challenge")] + public required string Challenge { get; init; } + + [JsonInclude] + [JsonPropertyName("login_uri")] + public required string LoginUri { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("get_signing_certificate")] + public bool? GetSigningCertificate { get; init; } + } + + private sealed record AuthUri + { + [JsonInclude] + [JsonPropertyName("auth_uri")] + public required string AuthUriValue { get; init; } + } + } +} diff --git a/example/src/WebEid.AspNetCore.Example/Controllers/Api/SignController.cs b/example/src/WebEid.AspNetCore.Example/Controllers/Api/SignController.cs index d4f6806..1c61fd6 100644 --- a/example/src/WebEid.AspNetCore.Example/Controllers/Api/SignController.cs +++ b/example/src/WebEid.AspNetCore.Example/Controllers/Api/SignController.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2021-2024 Estonian Information System Authority +// Copyright (c) 2021-2025 Estonian Information System Authority // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in @@ -17,47 +17,76 @@ // IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -namespace WebEid.AspNetCore.Example.Controllers.Api +namespace WebEid.AspNetCore.Example.Controllers.Api { using System; using System.Security.Claims; using System.Threading.Tasks; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; + using Dto; using Services; - using WebEid.AspNetCore.Example.Dto; + using Signing; [Route("[controller]")] [ApiController] + [Authorize(Policy = "LoggedInOnly")] public class SignController : BaseController { private const string SignedFile = "example-for-signing.asice"; private readonly SigningService signingService; + private readonly MobileSigningService mobileSigningService; private readonly ILogger logger; - public SignController(SigningService signingService, ILogger logger) + public SignController(SigningService signingService, MobileSigningService mobileSigningService, ILogger logger) { this.signingService = signingService; + this.mobileSigningService = mobileSigningService; this.logger = logger; } - [Route("prepare")] - [HttpPost] + [HttpPost("prepare")] public DigestDto Prepare([FromBody] CertificateDto data) { return signingService.PrepareContainer(data, (ClaimsIdentity)HttpContext.User.Identity, GetUserContainerName()); } - [Route("sign")] - [HttpPost] + [HttpPost("sign")] public FileDto Sign([FromBody] SignatureDto data) { signingService.SignContainer(data, GetUserContainerName()); return new FileDto(SignedFile); } - [Route("download")] - [HttpGet] + [HttpPost("mobile/init")] + public MobileSigningService.MobileInitRequest MobileInit() + { + var identity = (ClaimsIdentity)HttpContext.User.Identity; + var container = GetUserContainerName(); + return mobileSigningService.InitCertificateOrSigningRequest(identity, container); + } + + [HttpPost("mobile/certificate")] + public MobileSigningService.MobileInitRequest CertificatePost([FromBody] CertificateDto certificateDto) + { + var identity = (ClaimsIdentity)HttpContext.User.Identity; + var containerName = GetUserContainerName(); + + return mobileSigningService.InitSigningRequest( + identity, + certificateDto, + containerName); + } + + [HttpPost("mobile/signature")] + public FileDto SignaturePost([FromBody] SignatureDto signatureDto) + { + signingService.SignContainer(signatureDto, GetUserContainerName()); + return new FileDto(SignedFile); + } + + [HttpGet("download")] public async Task Download() { try diff --git a/example/src/WebEid.AspNetCore.Example/Dto/AuthenticateRequestDto.cs b/example/src/WebEid.AspNetCore.Example/Dto/AuthenticateRequestDto.cs index c36726f..a5a015b 100644 --- a/example/src/WebEid.AspNetCore.Example/Dto/AuthenticateRequestDto.cs +++ b/example/src/WebEid.AspNetCore.Example/Dto/AuthenticateRequestDto.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2021-2024 Estonian Information System Authority +// Copyright (c) 2021-2025 Estonian Information System Authority // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in @@ -24,7 +24,7 @@ public class AuthenticateRequestDto { - [JsonPropertyName("auth-token")] + [JsonPropertyName("auth_token")] public WebEidAuthToken AuthToken { get; set; } } } diff --git a/example/src/WebEid.AspNetCore.Example/Options/WebEidMobileOptions.cs b/example/src/WebEid.AspNetCore.Example/Options/WebEidMobileOptions.cs new file mode 100644 index 0000000..a7f2310 --- /dev/null +++ b/example/src/WebEid.AspNetCore.Example/Options/WebEidMobileOptions.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2025-2025 Estonian Information System Authority +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +namespace WebEid.AspNetCore.Example.Options +{ + using System.ComponentModel.DataAnnotations; + + public class WebEidMobileOptions + { + [Required] + [RegularExpression("^.*(?:[^/]|://)$", ErrorMessage = "Base URI must not have a trailing slash")] + public string BaseRequestUri { get; set; } = null!; + + public bool RequestSigningCert { get; set; } + } +} \ No newline at end of file diff --git a/example/src/WebEid.AspNetCore.Example/Pages/Index.cshtml b/example/src/WebEid.AspNetCore.Example/Pages/Index.cshtml index d25e828..a11aa76 100644 --- a/example/src/WebEid.AspNetCore.Example/Pages/Index.cshtml +++ b/example/src/WebEid.AspNetCore.Example/Pages/Index.cshtml @@ -3,115 +3,517 @@ - - + + + + @{ + var tokens = Xsrf.GetAndStoreTokens(HttpContext); + } + + Web eID: electronic ID smart cards on the Web - - + + + + + + + + -
-
-
-

Web eID: electronic ID smart cards on the Web

-

- The Web eID project enables usage of European Union electronic identity (eID) smart cards for - secure authentication and digital signing of documents on the web using public-key cryptography. -

-

- Estonian, Finnish, Latvian, Lithuanian and Croatian eID cards are supported in the first phase, but only - Estonian eID card support is currently enabled in the test application below. -

-

- Please get in touch by email at help@ria.ee in case you need support with adding Web eID to your project - or want to add support for a new eID card to Web eID. -

- -
- -

- More information about the Web eID project, including installation and usage instructions - is available on the project [website](https://web-eid.eu/). -

-

Click Authenticate below to test authentication and digital signing.

- -