1
0
mirror of https://github.com/bitwarden/server synced 2025-12-26 13:13:24 +00:00

[PM-8220] New Device Verification (#5084)

* feat(BaseRequestValidator): 
Add global setting for new device verification.
Refactor BaseRequestValidator enabling better self-documenting code and better single responsibility principle for validators.
Updated DeviceValidator to handle new device verification, behind a feature flag.
Moved IDeviceValidator interface to separate file.
Updated CustomRequestValidator to act as the conduit by which *Validators communicate authentication context between themselves and the RequestValidators.
Adding new test for DeviceValidator class.
Updated tests for BaseRequestValidator as some functionality was moved to the DeviceValidator class.
This commit is contained in:
Ike
2024-12-12 09:08:11 -08:00
committed by GitHub
parent a76a9cb800
commit 867fa848dd
15 changed files with 1112 additions and 473 deletions

View File

@@ -1,95 +1,162 @@
using System.ComponentModel.DataAnnotations;
using System.Reflection;
using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Identity.IdentityServer.Enums;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators;
public interface IDeviceValidator
{
/// <summary>
/// Save a device to the database. If the device is already known, it will be returned.
/// </summary>
/// <param name="user">The user is assumed NOT null, still going to check though</param>
/// <param name="request">Duende Validated Request that contains the data to create the device object</param>
/// <returns>Returns null if user or device is malformed; The existing device if already in DB; a new device login</returns>
Task<Device> SaveDeviceAsync(User user, ValidatedTokenRequest request);
/// <summary>
/// Check if a device is known to the user.
/// </summary>
/// <param name="user">current user trying to authenticate</param>
/// <param name="request">contains raw information that is parsed about the device</param>
/// <returns>true if the device is known, false if it is not</returns>
Task<bool> KnownDeviceAsync(User user, ValidatedTokenRequest request);
}
public class DeviceValidator(
IDeviceService deviceService,
IDeviceRepository deviceRepository,
GlobalSettings globalSettings,
IMailService mailService,
ICurrentContext currentContext) : IDeviceValidator
ICurrentContext currentContext,
IUserService userService,
IFeatureService featureService) : IDeviceValidator
{
private readonly IDeviceService _deviceService = deviceService;
private readonly IDeviceRepository _deviceRepository = deviceRepository;
private readonly GlobalSettings _globalSettings = globalSettings;
private readonly IMailService _mailService = mailService;
private readonly ICurrentContext _currentContext = currentContext;
private readonly IUserService _userService = userService;
private readonly IFeatureService _featureService = featureService;
/// <summary>
/// Save a device to the database. If the device is already known, it will be returned.
/// </summary>
/// <param name="user">The user is assumed NOT null, still going to check though</param>
/// <param name="request">Duende Validated Request that contains the data to create the device object</param>
/// <returns>Returns null if user or device is malformed; The existing device if already in DB; a new device login</returns>
public async Task<Device> SaveDeviceAsync(User user, ValidatedTokenRequest request)
public async Task<bool> ValidateRequestDeviceAsync(ValidatedTokenRequest request, CustomValidatorRequestContext context)
{
var device = GetDeviceFromRequest(request);
if (device != null && user != null)
// Parse device from request and return early if no device information is provided
var requestDevice = context.Device ?? GetDeviceFromRequest(request);
// If context.Device and request device information are null then return error
// backwards compatibility -- check if user is null
// PM-13340: Null user check happens in the HandleNewDeviceVerificationAsync method and can be removed from here
if (requestDevice == null || context.User == null)
{
var existingDevice = await GetKnownDeviceAsync(user, device);
if (existingDevice == null)
{
device.UserId = user.Id;
await _deviceService.SaveAsync(device);
// This makes sure the user isn't sent a "new device" email on their first login
var now = DateTime.UtcNow;
if (now - user.CreationDate > TimeSpan.FromMinutes(10))
{
var deviceType = device.Type.GetType().GetMember(device.Type.ToString())
.FirstOrDefault()?.GetCustomAttribute<DisplayAttribute>()?.GetName();
if (!_globalSettings.DisableEmailNewDevice)
{
await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now,
_currentContext.IpAddress);
}
}
return device;
}
return existingDevice;
(context.ValidationErrorResult, context.CustomResponse) =
BuildDeviceErrorResult(DeviceValidationResultType.NoDeviceInformationProvided);
return false;
}
return null;
// if not a new device request then check if the device is known
if (!NewDeviceOtpRequest(request))
{
var knownDevice = await GetKnownDeviceAsync(context.User, requestDevice);
// if the device is know then we return the device fetched from the database
// returning the database device is important for TDE
if (knownDevice != null)
{
context.KnownDevice = true;
context.Device = knownDevice;
return true;
}
}
// We have established that the device is unknown at this point; begin new device verification
// PM-13340: remove feature flag
if (_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification) &&
request.GrantType == "password" &&
request.Raw["AuthRequest"] == null &&
!context.TwoFactorRequired &&
!context.SsoRequired &&
_globalSettings.EnableNewDeviceVerification)
{
// We only want to return early if the device is invalid or there is an error
var validationResult = await HandleNewDeviceVerificationAsync(context.User, request);
if (validationResult != DeviceValidationResultType.Success)
{
(context.ValidationErrorResult, context.CustomResponse) =
BuildDeviceErrorResult(validationResult);
if (validationResult == DeviceValidationResultType.NewDeviceVerificationRequired)
{
await _userService.SendOTPAsync(context.User);
}
return false;
}
}
// At this point we have established either new device verification is not required or the NewDeviceOtp is valid
requestDevice.UserId = context.User.Id;
await _deviceService.SaveAsync(requestDevice);
context.Device = requestDevice;
// backwards compatibility -- If NewDeviceVerification not enabled send the new login emails
// PM-13340: removal Task; remove entire if block emails should no longer be sent
if (!_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification))
{
// This ensures the user doesn't receive a "new device" email on the first login
var now = DateTime.UtcNow;
if (now - context.User.CreationDate > TimeSpan.FromMinutes(10))
{
var deviceType = requestDevice.Type.GetType().GetMember(requestDevice.Type.ToString())
.FirstOrDefault()?.GetCustomAttribute<DisplayAttribute>()?.GetName();
if (!_globalSettings.DisableEmailNewDevice)
{
await _mailService.SendNewDeviceLoggedInEmail(context.User.Email, deviceType, now,
_currentContext.IpAddress);
}
}
}
return true;
}
public async Task<bool> KnownDeviceAsync(User user, ValidatedTokenRequest request) =>
(await GetKnownDeviceAsync(user, GetDeviceFromRequest(request))) != default;
/// <summary>
/// Checks the if the requesting deice requires new device verification otherwise saves the device to the database
/// </summary>
/// <param name="user">user attempting to authenticate</param>
/// <param name="ValidatedRequest">The Request is used to check for the NewDeviceOtp and for the raw device data</param>
/// <returns>returns deviceValtaionResultType</returns>
private async Task<DeviceValidationResultType> HandleNewDeviceVerificationAsync(User user, ValidatedRequest request)
{
// currently unreachable due to backward compatibility
// PM-13340: will address this
if (user == null)
{
return DeviceValidationResultType.InvalidUser;
}
private async Task<Device> GetKnownDeviceAsync(User user, Device device)
// parse request for NewDeviceOtp to validate
var newDeviceOtp = request.Raw["NewDeviceOtp"]?.ToString();
// we only check null here since an empty OTP will be considered an incorrect OTP
if (newDeviceOtp != null)
{
// verify the NewDeviceOtp
var otpValid = await _userService.VerifyOTPAsync(user, newDeviceOtp);
if (otpValid)
{
return DeviceValidationResultType.Success;
}
return DeviceValidationResultType.InvalidNewDeviceOtp;
}
// if a user has no devices they are assumed to be newly registered user which does not require new device verification
var devices = await _deviceRepository.GetManyByUserIdAsync(user.Id);
if (devices.Count == 0)
{
return DeviceValidationResultType.Success;
}
// if we get to here then we need to send a new device verification email
return DeviceValidationResultType.NewDeviceVerificationRequired;
}
public async Task<Device> GetKnownDeviceAsync(User user, Device device)
{
if (user == null || device == null)
{
return default;
return null;
}
return await _deviceRepository.GetByIdentifierAsync(device.Identifier, user.Id);
}
private static Device GetDeviceFromRequest(ValidatedRequest request)
public static Device GetDeviceFromRequest(ValidatedRequest request)
{
var deviceIdentifier = request.Raw["DeviceIdentifier"]?.ToString();
var requestDeviceType = request.Raw["DeviceType"]?.ToString();
@@ -112,4 +179,49 @@ public class DeviceValidator(
PushToken = string.IsNullOrWhiteSpace(devicePushToken) ? null : devicePushToken
};
}
/// <summary>
/// Checks request for the NewDeviceOtp field to determine if a new device verification is required.
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
public static bool NewDeviceOtpRequest(ValidatedRequest request)
{
return !string.IsNullOrEmpty(request.Raw["NewDeviceOtp"]?.ToString());
}
/// <summary>
/// This builds builds the error result for the various grant and token validators. The Success type is not used here.
/// </summary>
/// <param name="errorType">DeviceValidationResultType that is an error, success type is not used.</param>
/// <returns>validation result used by grant and token validators, and the custom response for either Grant or Token response objects.</returns>
private static (Duende.IdentityServer.Validation.ValidationResult, Dictionary<string, object>) BuildDeviceErrorResult(DeviceValidationResultType errorType)
{
var result = new Duende.IdentityServer.Validation.ValidationResult
{
IsError = true,
Error = "device_error",
};
var customResponse = new Dictionary<string, object>();
switch (errorType)
{
case DeviceValidationResultType.InvalidUser:
result.ErrorDescription = "Invalid user";
customResponse.Add("ErrorModel", new ErrorResponseModel("invalid user"));
break;
case DeviceValidationResultType.InvalidNewDeviceOtp:
result.ErrorDescription = "Invalid New Device OTP";
customResponse.Add("ErrorModel", new ErrorResponseModel("invalid new device otp"));
break;
case DeviceValidationResultType.NewDeviceVerificationRequired:
result.ErrorDescription = "New device verification required";
customResponse.Add("ErrorModel", new ErrorResponseModel("new device verification required"));
break;
case DeviceValidationResultType.NoDeviceInformationProvided:
result.ErrorDescription = "No device information provided";
customResponse.Add("ErrorModel", new ErrorResponseModel("no device information provided"));
break;
}
return (result, customResponse);
}
}