/*
* Copyright (c) 2024 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
* File: FidoEndpoint.cs
*
* FidoEndpoint.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
* VNLib collection of libraries and utilities.
*
* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
using System.Net;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using FluentValidation;
using VNLib.Utils;
using VNLib.Utils.Memory;
using VNLib.Hashing;
using VNLib.Utils.Logging;
using VNLib.Plugins.Essentials.Users;
using VNLib.Plugins.Essentials.Endpoints;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
using VNLib.Plugins.Extensions.Loading.Routing;
using VNLib.Plugins.Extensions.Validation;
using VNLib.Plugins.Essentials.Extensions;
using VNLib.Plugins.Essentials.Accounts.MFA;
using VNLib.Plugins.Essentials.Accounts.MFA.Fido;
namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
///
///
/// This enpdoint requires Fido to be enabled in the MFA configuration.
///
///
[EndpointPath("{{path}}")]
[EndpointLogName("FIDO")]
[ConfigurationName("fido_endpoint")]
internal sealed class FidoEndpoint(PluginBase plugin, IConfigScope config) : ProtectedWebEndpoint
{
private static readonly FidoResponseValidator ResponseValidator = new();
private static readonly FidoClientDataJsonValidtor ClientDataValidator = new();
private readonly IUserManager _users = plugin.GetOrCreateSingleton();
private readonly FidoConfig _fidoConfig = plugin.GetConfigElement().FIDOConfig
?? throw new ConfigurationValidationException("Fido configuration was not set, but Fido endpoint was enabled");
private static readonly FidoPubkeyAlgorithm[] _supportedAlgs =
[
new FidoPubkeyAlgorithm(algId: -7), //ES256
new FidoPubkeyAlgorithm(algId: -35), //ES384
new FidoPubkeyAlgorithm(algId: -36), //ES512
];
protected override VfReturnType Get(HttpEntity entity)
{
return VirtualOk(entity);
}
protected override async ValueTask PutAsync(HttpEntity entity)
{
ValErrWebMessage webm = new();
using IUser? user = await _users.GetUserFromIDAsync(entity.Session.UserID, entity.EventCancellation);
if (webm.Assert(user != null, "User not found"))
{
return VirtualClose(entity, webm, HttpStatusCode.NotFound);
}
if(webm.Assert(user.FidoCanAddKey(), "You cannot add another key to this account. You must delete an existing one first"))
{
return VirtualOk(entity, webm);
}
//TODO: Store challenge in user session
string challenge = RandomHash.GetRandomBase64(16);
webm.Result = new FidoRegistrationMessage
{
AttestationType = _fidoConfig.AttestationType,
AuthSelection = _fidoConfig.FIDOAuthSelection,
RelyingParty = new FidoRelyingParty
{
Id = entity.Server.RequestUri.DnsSafeHost,
Name = _fidoConfig.SiteName
},
User = new FidoUserData
{
UserId = user.UserID,
UserName = user.EmailAddress,
DisplayName = user.EmailAddress,
},
Timeout = _fidoConfig.Timeout,
PubKeyCredParams = _supportedAlgs,
Base64Challenge = challenge,
};
webm.Success = true;
return VirtualOk(entity, webm);
}
protected override async ValueTask PostAsync(HttpEntity entity)
{
ValErrWebMessage webm = new();
using JsonDocument? doc = await entity.GetJsonFromFileAsync();
if(webm.Assert(doc != null, "Missing entity message"))
{
return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
}
/*
* Handle a registration response from the client that is used to
* register a new credential to the user's account
*/
if (doc.RootElement.TryGetProperty("registration", out JsonElement deviceResponse))
{
//complete registation of new device
FidoAuthenticatorResponse? res = deviceResponse.Deserialize();
if(webm.Assert(res != null, "Mising registation response object"))
{
return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
}
if(!ResponseValidator.Validate(res, webm))
{
return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
}
return await RegisterDeviceAsync(entity, res);
}
return VfReturnType.NotFound;
}
private async ValueTask RegisterDeviceAsync(
HttpEntity entity,
FidoAuthenticatorResponse response
)
{
ValErrWebMessage webm = new();
bool isAlgSupported = _supportedAlgs.Any(p => p.AlgId == response.CoseAlgorithmNumber);
if(webm.Assert(isAlgSupported, "Authenticator does not support the same algorithms as the server"))
{
return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
}
FidoClientDataJson? clientData = FidoBase64Util.DeserialzeJson(response.Base64ClientData!);
if(webm.Assert(clientData != null, "Client data json is not valid"))
{
return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
}
if(!ClientDataValidator.Validate(clientData, webm))
{
return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
}
FidoDeviceCredential? cred = FidoDecoder.FromResponse(response);
if (webm.Assert(cred != null, "Your device did not send valid public key data"))
{
return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
}
Log.Information("Adding new credential\n {cred}", cred);
using IUser? user = await _users.GetUserFromIDAsync(entity.Session.UserID, entity.EventCancellation);
if(webm.Assert(user != null, "User not found"))
{
return VirtualClose(entity, webm, HttpStatusCode.NotFound);
}
if (webm.Assert(user.FidoCanAddKey(), "You cannot add another key to your account, you must delete an existing one"))
{
return VirtualOk(entity, webm);
}
//user.FidoAddCredential(cred);
webm.Result = "Your fido device was successfully added to your account";
webm.Success = true;
return VirtualOk(entity, webm);
}
}
internal sealed class FidoBase64Util
{
///
/// Takes a base64url encoded JSON string and deserializes it into a
/// given object.
///
///
/// The base64url encoded JSON string to decode
/// The instance of the object if it could be decoded
///
public static T? DeserialzeJson(string base64Url)
{
/*
* We just need to transform the base64 encoded chars back to
* utf8 bytes and then deserialize the object
*
* The length is assumed to be validated before deserialization
*/
using UnsafeMemoryHandle buffer = MemoryUtil.UnsafeAllocNearestPage(base64Url.Length);
ERRNO count = VnEncoding.Base64UrlDecode(base64Url, buffer.Span, System.Text.Encoding.UTF8);
if (count < 1)
{
throw new JsonException("Failed to decode base64url");
}
return JsonSerializer.Deserialize(buffer.AsSpan(0, count));
}
}
internal sealed class FidoResponseValidator : AbstractValidator
{
public FidoResponseValidator()
{
RuleFor(c => c.DeviceId)
.NotEmpty()
.WithMessage("Fido 'device_id' must be provided")
.MaximumLength(256);
RuleFor(c => c.DeviceName)
.NotEmpty()
.Matches(@"^[a-zA-Z0-9\s]+$")
.WithMessage("Your device name contains invalid characters")
.MaximumLength(64);
RuleFor(c => c.Base64PublicKey)
.NotEmpty()
.WithMessage("Fido 'public_key' must be provided");
RuleFor(c => c.CoseAlgorithmNumber)
.NotNull()
.WithMessage("Fido 'public_key_algorithm' number must be provided in a valid COSE algorithm number");
RuleFor(c => c.Base64ClientData)
.NotEmpty()
.WithMessage("Fido 'client_data' must be provided")
.MaximumLength(4096);
RuleFor(c => c.Base64AuthenticatorData)
.NotEmpty()
.WithMessage("Fido 'authenticator_data' must be provided")
.MaximumLength(4096);
RuleFor(c => c.Base64Attestation)
.NotEmpty()
.WithMessage("Fido 'attestation' must be provided")
.MaximumLength(4096);
}
}
internal sealed class FidoClientDataJsonValidtor : AbstractValidator
{
public FidoClientDataJsonValidtor()
{
RuleFor(c => c.Base64Challenge)
.NotEmpty()
.WithMessage("Fido 'challenge' is required")
.MaximumLength(4096);
RuleFor(c => c.Origin)
.NotEmpty()
.WithMessage("Fido 'origin' is required")
.MaximumLength(1024);
RuleFor(c => c.Type)
.NotEmpty()
.WithMessage("Fido 'type' must be provided")
.Matches("webauthn.create");
}
}
}