1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-03 00:52:49 -05:00

[PM-2032] Server endpoints to support authentication with a passkey (#3361)

* [PM-2032] feat: add assertion options tokenable

* [PM-2032] feat: add request and response models

* [PM-2032] feat: implement `assertion-options` identity endpoint

* [PM-2032] feat: implement authentication with passkey

* [PM-2032] chore: rename to `WebAuthnGrantValidator`

* [PM-2032] fix: add missing subsitute

* [PM-2032] feat: start adding builder

* [PM-2032] feat: add support for KeyConnector

* [PM-2032] feat: add first version of TDE

* [PM-2032] chore: refactor WithSso

* [PM-2023] feat: add support for TDE feature flag

* [PM-2023] feat: add support for approving devices

* [PM-2023] feat: add support for hasManageResetPasswordPermission

* [PM-2032] feat: add support for hasAdminApproval

* [PM-2032] chore: don't supply device if not necessary

* [PM-2032] chore: clean up imports

* [PM-2023] feat: extract interface

* [PM-2023] chore: add clarifying comment

* [PM-2023] feat: use new builder in production code

* [PM-2032] feat: add support for PRF

* [PM-2032] chore: clean-up todos

* [PM-2023] chore: remove token which is no longer used

* [PM-2032] chore: remove todo

* [PM-2032] feat: improve assertion error handling

* [PM-2032] fix: linting issues

* [PM-2032] fix: revert changes to `launchSettings.json`

* [PM-2023] chore: clean up assertion endpoint

* [PM-2032] feat: bypass 2FA

* [PM-2032] fix: rename prf option to singular

* [PM-2032] fix: lint

* [PM-2032] fix: typo

* [PM-2032] chore: improve builder tests

Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com>

* [PM-2032] chore: clarify why we don't require 2FA

* [PM-2023] feat: move `identityProvider` constant to common class

* [PM-2032] fix: lint

* [PM-2023] fix: move `IdentityProvider` to core.Constants

* [PM-2032] fix: missing import

* [PM-2032] chore: refactor token timespan to use `TimeSpan`

* [PM-2032] chore: make `StartWebAuthnLoginAssertion` sync

* [PM-2032] chore: use `FromMinutes`

* [PM-2032] fix: change to 17 minutes to cover webauthn assertion

* [PM-2032] chore: do not use `async void`

* [PM-2032] fix: comment saying wrong amount of minutes

* [PM-2032] feat: put validator behind feature flag

* [PM-2032] fix: lint

---------

Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com>
This commit is contained in:
Andreas Coroiu
2023-11-20 15:55:31 +01:00
committed by GitHub
parent 07c202ecaf
commit 80740aa4ba
24 changed files with 855 additions and 223 deletions

View File

@ -1,6 +1,8 @@
using Bit.Core;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Models.Api.Response.Accounts;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Services;
using Bit.Core.Auth.Utilities;
using Bit.Core.Enums;
@ -8,9 +10,9 @@ using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;
using Fido2NetLib;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Identity.Controllers;
@ -23,17 +25,21 @@ public class AccountsController : Controller
private readonly IUserRepository _userRepository;
private readonly IUserService _userService;
private readonly ICaptchaValidationService _captchaValidationService;
private readonly IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector;
public AccountsController(
ILogger<AccountsController> logger,
IUserRepository userRepository,
IUserService userService,
ICaptchaValidationService captchaValidationService)
ICaptchaValidationService captchaValidationService,
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector)
{
_logger = logger;
_userRepository = userRepository;
_userService = userService;
_captchaValidationService = captchaValidationService;
_assertionOptionsDataProtector = assertionOptionsDataProtector;
}
// Moved from API, If you modify this endpoint, please update API as well. Self hosted installs still use the API endpoints.
@ -75,36 +81,19 @@ public class AccountsController : Controller
return new PreloginResponseModel(kdfInformation);
}
[HttpPost("webauthn-assertion-options")]
[ApiExplorerSettings(IgnoreApi = true)] // Disable Swagger due to CredentialCreateOptions not converting properly
[HttpPost("webauthn/assertion-options")]
[RequireFeature(FeatureFlagKeys.PasswordlessLogin)]
// TODO: Create proper models for this call
public async Task<AssertionOptions> PostWebAuthnAssertionOptions([FromBody] PreloginRequestModel model)
public WebAuthnLoginAssertionOptionsResponseModel PostWebAuthnLoginAssertionOptions()
{
var user = await _userRepository.GetByEmailAsync(model.Email);
if (user == null)
var options = _userService.StartWebAuthnLoginAssertion();
var tokenable = new WebAuthnLoginAssertionOptionsTokenable(WebAuthnLoginAssertionOptionsScope.Authentication, options);
var token = _assertionOptionsDataProtector.Protect(tokenable);
return new WebAuthnLoginAssertionOptionsResponseModel
{
// TODO: return something? possible enumeration attacks with this response
return new AssertionOptions();
}
var options = await _userService.StartWebAuthnLoginAssertionAsync(user);
return options;
}
[HttpPost("webauthn-assertion")]
[RequireFeature(FeatureFlagKeys.PasswordlessLogin)]
// TODO: Create proper models for this call
public async Task<string> PostWebAuthnAssertion([FromBody] PreloginRequestModel model)
{
var user = await _userRepository.GetByEmailAsync(model.Email);
if (user == null)
{
// TODO: proper response here?
throw new BadRequestException();
}
var token = await _userService.CompleteWebAuthLoginAssertionAsync(null, user);
return token;
Options = options,
Token = token
};
}
}

View File

@ -13,7 +13,7 @@ public class ApiClient : Client
string[] scopes = null)
{
ClientId = id;
AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode };
AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode, WebAuthnGrantValidator.GrantType };
RefreshTokenExpiration = TokenExpiration.Sliding;
RefreshTokenUsage = TokenUsage.ReUse;
SlidingRefreshTokenLifetime = 86400 * refreshTokenSlidingDays;

View File

@ -10,7 +10,6 @@ using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Utilities;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@ -23,7 +22,6 @@ using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Bit.Identity.Utilities;
using IdentityServer4.Validation;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Caching.Distributed;
@ -35,7 +33,6 @@ public abstract class BaseRequestValidator<T> where T : class
private UserManager<User> _userManager;
private readonly IDeviceRepository _deviceRepository;
private readonly IDeviceService _deviceService;
private readonly IUserService _userService;
private readonly IEventService _eventService;
private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider;
private readonly IOrganizationRepository _organizationRepository;
@ -53,6 +50,8 @@ public abstract class BaseRequestValidator<T> where T : class
protected IPolicyService PolicyService { get; }
protected IFeatureService FeatureService { get; }
protected ISsoConfigRepository SsoConfigRepository { get; }
protected IUserService _userService { get; }
protected IUserDecryptionOptionsBuilder UserDecryptionOptionsBuilder { get; }
public BaseRequestValidator(
UserManager<User> userManager,
@ -73,7 +72,8 @@ public abstract class BaseRequestValidator<T> where T : class
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory,
IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository,
IDistributedCache distributedCache)
IDistributedCache distributedCache,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
{
_userManager = userManager;
_deviceRepository = deviceRepository;
@ -96,11 +96,12 @@ public abstract class BaseRequestValidator<T> where T : class
_distributedCache = distributedCache;
_cacheEntryOptions = new DistributedCacheEntryOptions
{
// This sets the time an item is cached to 15 minutes. This value is hard coded
// to 15 because to it covers all time-out windows for both Authenticators and
// This sets the time an item is cached to 17 minutes. This value is hard coded
// to 17 because to it covers all time-out windows for both Authenticators and
// Email TOTP.
AbsoluteExpirationRelativeToNow = new TimeSpan(0, 15, 0)
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(17)
};
UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder;
}
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
@ -333,7 +334,7 @@ public abstract class BaseRequestValidator<T> where T : class
protected abstract void SetErrorResult(T context, Dictionary<string, object> customResponse);
protected abstract ClaimsPrincipal GetSubject(T context);
private async Task<Tuple<bool, Organization>> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request)
protected virtual async Task<Tuple<bool, Organization>> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request)
{
if (request.GrantType == "client_credentials")
{
@ -612,67 +613,12 @@ public abstract class BaseRequestValidator<T> where T : class
/// </summary>
private async Task<UserDecryptionOptions> CreateUserDecryptionOptionsAsync(User user, Device device, ClaimsPrincipal subject)
{
var ssoConfiguration = await GetSsoConfigurationDataAsync(subject);
var userDecryptionOption = new UserDecryptionOptions
{
HasMasterPassword = !string.IsNullOrEmpty(user.MasterPassword)
};
var ssoConfigurationData = ssoConfiguration?.GetData();
if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl))
{
// KeyConnector makes it mutually exclusive
userDecryptionOption.KeyConnectorOption = new KeyConnectorUserDecryptionOption(ssoConfigurationData.KeyConnectorUrl);
return userDecryptionOption;
}
// Only add the trusted device specific option when the flag is turned on
if (FeatureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, CurrentContext) && ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption })
{
string? encryptedPrivateKey = null;
string? encryptedUserKey = null;
if (device.IsTrusted())
{
encryptedPrivateKey = device.EncryptedPrivateKey;
encryptedUserKey = device.EncryptedUserKey;
}
var allDevices = await _deviceRepository.GetManyByUserIdAsync(user.Id);
// Checks if the current user has any devices that are capable of approving login with device requests except for
// their current device.
// NOTE: this doesn't check for if the users have configured the devices to be capable of approving requests as that is a client side setting.
var hasLoginApprovingDevice = allDevices
.Where(d => d.Identifier != device.Identifier && LoginApprovingDeviceTypes.Types.Contains(d.Type))
.Any();
// Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP
var hasManageResetPasswordPermission = false;
// when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here
if (CurrentContext.Organizations.Any(o => o.Id == ssoConfiguration!.OrganizationId))
{
// TDE requires single org so grabbing first org & id is fine.
hasManageResetPasswordPermission = await CurrentContext.ManageResetPassword(ssoConfiguration!.OrganizationId);
}
// If sso configuration data is not null then I know for sure that ssoConfiguration isn't null
var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(ssoConfiguration!.OrganizationId, user.Id);
// They are only able to be approved by an admin if they have enrolled is reset password
var hasAdminApproval = !string.IsNullOrEmpty(organizationUser.ResetPasswordKey);
// TrustedDeviceEncryption only exists for SSO, but if that ever changes this value won't always be true
userDecryptionOption.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption(
hasAdminApproval,
hasLoginApprovingDevice,
hasManageResetPasswordPermission,
encryptedPrivateKey,
encryptedUserKey);
}
return userDecryptionOption;
var ssoConfig = await GetSsoConfigurationDataAsync(subject);
return await UserDecryptionOptionsBuilder
.ForUser(user)
.WithDevice(device)
.WithSso(ssoConfig)
.BuildAsync();
}
private async Task<SsoConfig?> GetSsoConfigurationDataAsync(ClaimsPrincipal subject)

View File

@ -44,12 +44,13 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
IPolicyService policyService,
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory,
IFeatureService featureService,
IDistributedCache distributedCache)
IDistributedCache distributedCache,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
: base(userManager, deviceRepository, deviceService, userService, eventService,
organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository,
applicationCacheService, mailService, logger, currentContext, globalSettings,
userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository,
distributedCache)
distributedCache, userDecryptionOptionsBuilder)
{
_userManager = userManager;
}

View File

@ -0,0 +1,13 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Entities;
namespace Bit.Identity.IdentityServer;
public interface IUserDecryptionOptionsBuilder
{
IUserDecryptionOptionsBuilder ForUser(User user);
IUserDecryptionOptionsBuilder WithDevice(Device device);
IUserDecryptionOptionsBuilder WithSso(SsoConfig ssoConfig);
IUserDecryptionOptionsBuilder WithWebAuthnLoginCredential(WebAuthnCredential credential);
Task<UserDecryptionOptions> BuildAsync();
}

View File

@ -1,4 +1,5 @@
using System.Security.Claims;
using Bit.Core;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
@ -46,11 +47,12 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory,
IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository,
IDistributedCache distributedCache)
IDistributedCache distributedCache,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
: base(userManager, deviceRepository, deviceService, userService, eventService,
organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository,
applicationCacheService, mailService, logger, currentContext, globalSettings, userRepository, policyService,
tokenDataFactory, featureService, ssoConfigRepository, distributedCache)
tokenDataFactory, featureService, ssoConfigRepository, distributedCache, userDecryptionOptionsBuilder)
{
_userManager = userManager;
_userService = userService;
@ -144,7 +146,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
List<Claim> claims, Dictionary<string, object> customResponse)
{
context.Result = new GrantValidationResult(user.Id.ToString(), "Application",
identityProvider: "bitwarden",
identityProvider: Constants.IdentityProvider,
claims: claims.Count > 0 ? claims : null,
customResponse: customResponse);
return Task.CompletedTask;

View File

@ -0,0 +1,155 @@
using Bit.Core;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.Utilities;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Identity.Utilities;
namespace Bit.Identity.IdentityServer;
#nullable enable
/// <summary>
/// Used to create a list of all possible ways the newly authenticated user can decrypt their vault contents
///
/// Note: Do not use this as an injected service if you intend to build multiple independent UserDecryptionOptions
/// </summary>
public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
{
private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
private readonly IDeviceRepository _deviceRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private UserDecryptionOptions _options = new UserDecryptionOptions();
private User? _user;
private Core.Auth.Entities.SsoConfig? _ssoConfig;
private Device? _device;
public UserDecryptionOptionsBuilder(
ICurrentContext currentContext,
IFeatureService featureService,
IDeviceRepository deviceRepository,
IOrganizationUserRepository organizationUserRepository
)
{
_currentContext = currentContext;
_featureService = featureService;
_deviceRepository = deviceRepository;
_organizationUserRepository = organizationUserRepository;
}
public IUserDecryptionOptionsBuilder ForUser(User user)
{
_options.HasMasterPassword = user.HasMasterPassword();
_user = user;
return this;
}
public IUserDecryptionOptionsBuilder WithSso(Core.Auth.Entities.SsoConfig ssoConfig)
{
_ssoConfig = ssoConfig;
return this;
}
public IUserDecryptionOptionsBuilder WithDevice(Device device)
{
_device = device;
return this;
}
public IUserDecryptionOptionsBuilder WithWebAuthnLoginCredential(WebAuthnCredential credential)
{
if (credential.GetPrfStatus() == WebAuthnPrfStatus.Enabled)
{
_options.WebAuthnPrfOption = new WebAuthnPrfDecryptionOption(credential.EncryptedPrivateKey, credential.EncryptedUserKey);
}
return this;
}
public async Task<UserDecryptionOptions> BuildAsync()
{
BuildKeyConnectorOptions();
await BuildTrustedDeviceOptions();
return _options;
}
private void BuildKeyConnectorOptions()
{
if (_ssoConfig == null)
{
return;
}
var ssoConfigurationData = _ssoConfig.GetData();
if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl))
{
_options.KeyConnectorOption = new KeyConnectorUserDecryptionOption(ssoConfigurationData.KeyConnectorUrl);
}
}
private async Task BuildTrustedDeviceOptions()
{
// TrustedDeviceEncryption only exists for SSO, if that changes then these guards should change
if (_ssoConfig == null || !_featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext))
{
return;
}
var ssoConfigurationData = _ssoConfig.GetData();
if (ssoConfigurationData is not { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption })
{
return;
}
string? encryptedPrivateKey = null;
string? encryptedUserKey = null;
if (_device != null && _device.IsTrusted())
{
encryptedPrivateKey = _device.EncryptedPrivateKey;
encryptedUserKey = _device.EncryptedUserKey;
}
var hasLoginApprovingDevice = false;
if (_device != null && _user != null)
{
var allDevices = await _deviceRepository.GetManyByUserIdAsync(_user.Id);
// Checks if the current user has any devices that are capable of approving login with device requests except for
// their current device.
// NOTE: this doesn't check for if the users have configured the devices to be capable of approving requests as that is a client side setting.
hasLoginApprovingDevice = allDevices
.Where(d => d.Identifier != _device.Identifier && LoginApprovingDeviceTypes.Types.Contains(d.Type))
.Any();
}
// Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP
var hasManageResetPasswordPermission = false;
// when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here
if (_currentContext.Organizations != null && _currentContext.Organizations.Any(o => o.Id == _ssoConfig.OrganizationId))
{
// TDE requires single org so grabbing first org & id is fine.
hasManageResetPasswordPermission = await _currentContext.ManageResetPassword(_ssoConfig!.OrganizationId);
}
var hasAdminApproval = false;
if (_user != null)
{
// If sso configuration data is not null then I know for sure that ssoConfiguration isn't null
var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id);
// They are only able to be approved by an admin if they have enrolled is reset password
hasAdminApproval = organizationUser != null && !string.IsNullOrEmpty(organizationUser.ResetPasswordKey);
}
_options.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption(
hasAdminApproval,
hasLoginApprovingDevice,
hasManageResetPasswordPermission,
encryptedPrivateKey,
encryptedUserKey);
}
}

View File

@ -0,0 +1,150 @@
using System.Security.Claims;
using System.Text.Json;
using Bit.Core;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Fido2NetLib;
using IdentityServer4.Models;
using IdentityServer4.Validation;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Caching.Distributed;
namespace Bit.Identity.IdentityServer;
public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidationContext>, IExtensionGrantValidator
{
public const string GrantType = "webauthn";
private readonly IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector;
public WebAuthnGrantValidator(
UserManager<User> userManager,
IDeviceRepository deviceRepository,
IDeviceService deviceService,
IUserService userService,
IEventService eventService,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IApplicationCacheService applicationCacheService,
IMailService mailService,
ILogger<CustomTokenRequestValidator> logger,
ICurrentContext currentContext,
GlobalSettings globalSettings,
ISsoConfigRepository ssoConfigRepository,
IUserRepository userRepository,
IPolicyService policyService,
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory,
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector,
IFeatureService featureService,
IDistributedCache distributedCache,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder
)
: base(userManager, deviceRepository, deviceService, userService, eventService,
organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository,
applicationCacheService, mailService, logger, currentContext, globalSettings,
userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, distributedCache, userDecryptionOptionsBuilder)
{
_assertionOptionsDataProtector = assertionOptionsDataProtector;
}
string IExtensionGrantValidator.GrantType => "webauthn";
public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
if (!FeatureService.IsEnabled(FeatureFlagKeys.PasswordlessLogin, CurrentContext))
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
return;
}
var rawToken = context.Request.Raw.Get("token");
var rawDeviceResponse = context.Request.Raw.Get("deviceResponse");
if (string.IsNullOrWhiteSpace(rawToken) || string.IsNullOrWhiteSpace(rawDeviceResponse))
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
return;
}
var verified = _assertionOptionsDataProtector.TryUnprotect(rawToken, out var token) &&
token.TokenIsValid(WebAuthnLoginAssertionOptionsScope.Authentication);
var deviceResponse = JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(rawDeviceResponse);
if (!verified)
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest);
return;
}
var (user, credential) = await _userService.CompleteWebAuthLoginAssertionAsync(token.Options, deviceResponse);
var validatorContext = new CustomValidatorRequestContext
{
User = user,
KnownDevice = await KnownDeviceAsync(user, context.Request)
};
UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential);
await ValidateAsync(context, context.Request, validatorContext);
}
protected override Task<bool> ValidateContextAsync(ExtensionGrantValidationContext context,
CustomValidatorRequestContext validatorContext)
{
if (validatorContext.User == null)
{
return Task.FromResult(false);
}
return Task.FromResult(true);
}
protected override Task SetSuccessResult(ExtensionGrantValidationContext context, User user,
List<Claim> claims, Dictionary<string, object> customResponse)
{
context.Result = new GrantValidationResult(user.Id.ToString(), "Application",
identityProvider: Constants.IdentityProvider,
claims: claims.Count > 0 ? claims : null,
customResponse: customResponse);
return Task.CompletedTask;
}
protected override ClaimsPrincipal GetSubject(ExtensionGrantValidationContext context)
{
return context.Result.Subject;
}
protected override Task<Tuple<bool, Organization>> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request)
{
// We consider Fido2 userVerification a second factor, so we don't require a second factor here.
return Task.FromResult(new Tuple<bool, Organization>(false, null));
}
protected override void SetTwoFactorResult(ExtensionGrantValidationContext context,
Dictionary<string, object> customResponse)
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Two factor required.",
customResponse);
}
protected override void SetSsoResult(ExtensionGrantValidationContext context,
Dictionary<string, object> customResponse)
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Sso authentication required.",
customResponse);
}
protected override void SetErrorResult(ExtensionGrantValidationContext context,
Dictionary<string, object> customResponse)
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: customResponse);
}
}

View File

@ -17,6 +17,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<StaticClientStore>();
services.AddTransient<IAuthorizationCodeStore, AuthorizationCodeStore>();
services.AddTransient<IUserDecryptionOptionsBuilder, UserDecryptionOptionsBuilder>();
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
var identityServerBuilder = services
@ -44,7 +45,8 @@ public static class ServiceCollectionExtensions
.AddResourceOwnerValidator<ResourceOwnerPasswordValidator>()
.AddPersistedGrantStore<PersistedGrantStore>()
.AddClientStore<ClientStore>()
.AddIdentityServerCertificate(env, globalSettings);
.AddIdentityServerCertificate(env, globalSettings)
.AddExtensionGrantValidator<WebAuthnGrantValidator>();
services.AddTransient<ICorsPolicyService, CustomCorsPolicyService>();
return identityServerBuilder;