Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-8220] New Device Verification #5084

Merged
merged 12 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ private void ValidateOpenRegistrationAllowed()
{
// We validate open registration on send of initial email and here b/c a user could technically start the
// account creation process while open registration is enabled and then finish it after it has been
// disabled by the self hosted admin.ร
// disabled by the self hosted admin.
if (_globalSettings.DisableUserRegistration)
{
throw new BadRequestException(_disabledUserRegistrationExceptionMsg);
Expand Down
13 changes: 7 additions & 6 deletions src/Core/Settings/GlobalSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
public virtual string HibpApiKey { get; set; }
public virtual bool DisableUserRegistration { get; set; }
public virtual bool DisableEmailNewDevice { get; set; }
public virtual bool EnableNewDeviceVerification { get; set; }
public virtual bool EnableCloudCommunication { get; set; } = false;
public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days
public virtual string EventGridKey { get; set; }
Expand Down Expand Up @@ -106,7 +107,7 @@
{
return null;
}
return string.Format("http://{0}:5000", name);

Check warning on line 110 in src/Core/Settings/GlobalSettings.cs

View workflow job for this annotation

GitHub Actions / Quality scan

Using http protocol is insecure. Use https instead. (https://rules.sonarsource.com/csharp/RSPEC-5332)
}

public string BuildDirectory(string explicitValue, string appendedPath)
Expand Down Expand Up @@ -433,18 +434,18 @@
public bool EnableSendTracing { get; set; } = false;
/// <summary>
/// The date and time at which registration will be enabled.
///
///
/// **This value should not be updated once set, as it is used to determine installation location of devices.**
///
///
/// If null, registration is disabled.
///
///
/// </summary>
public DateTime? RegistrationStartDate { get; set; }
/// <summary>
/// The date and time at which registration will be disabled.
///
///
/// **This value should not be updated once set, as it is used to determine installation location of devices.**
///
///
/// If null, hub registration has no yet known expiry.
/// </summary>
public DateTime? RegistrationEndDate { get; set; }
Expand All @@ -454,7 +455,7 @@
{
/// <summary>
/// List of Notification Hub settings to use for sending push notifications.
///
///
/// Note that hubs on the same namespace share active device limits, so multiple namespaces should be used to increase capacity.
/// </summary>
public List<NotificationHubSettings> NotificationHubs { get; set; } = new();
Expand Down
1 change: 1 addition & 0 deletions src/Core/Settings/IGlobalSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public interface IGlobalSettings
string LicenseCertificatePassword { get; set; }
int OrganizationInviteExpirationHours { get; set; }
bool DisableUserRegistration { get; set; }
bool EnableNewDeviceVerification { get; set; }
IInstallationSettings Installation { get; set; }
IFileStorageSettings Attachment { get; set; }
IConnectionStringSettings Storage { get; set; }
Expand Down
32 changes: 32 additions & 0 deletions src/Identity/IdentityServer/CustomValidatorRequestContext.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,43 @@
๏ปฟusing Bit.Core.Auth.Models.Business;
using Bit.Core.Entities;
using Duende.IdentityServer.Validation;

namespace Bit.Identity.IdentityServer;

public class CustomValidatorRequestContext
{
public User User { get; set; }
/// <summary>
/// This is the device that the user is using to authenticate. It can be either known or unknown.
/// We set it here since the ResourceOwnerPasswordValidator needs the device to know if CAPTCHA is required.
/// The option to set it here saves a trip to the database.
/// </summary>
public Device Device { get; set; }
/// <summary>
/// Communicates whether or not the device in the request is known to the user.
/// KnownDevice is set in the child classes of the BaseRequestValidator using the DeviceValidator.KnownDeviceAsync method.
/// Except in the CustomTokenRequestValidator, where it is hardcoded to true.
/// </summary>
public bool KnownDevice { get; set; }
/// <summary>
/// This communicates whether or not two factor is required for the user to authenticate.
/// </summary>
public bool TwoFactorRequired { get; set; } = false;
/// <summary>
/// This communicates whether or not SSO is required for the user to authenticate.
/// </summary>
public bool SsoRequired { get; set; } = false;
/// <summary>
/// We use the parent class for both GrantValidationResult and TokenRequestValidationResult here for
/// flexibility when building an error response.
/// This will be null if the authentication request is successful.
/// </summary>
public ValidationResult ValidationErrorResult { get; set; }
/// <summary>
/// This dictionary should contain relevant information for the clients to act on.
/// This will contain the information used to guide a user to successful authentication, such as TwoFactorProviders.
/// This will be null if the authentication request is successful.
/// </summary>
public Dictionary<string, object> CustomResponse { get; set; }
public CaptchaResponse CaptchaResponse { get; set; }
}
10 changes: 10 additions & 0 deletions src/Identity/IdentityServer/Enums/DeviceValidationResultType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
๏ปฟnamespace Bit.Identity.IdentityServer.Enums;

public enum DeviceValidationResultType : byte
{
Success = 0,
InvalidUser = 1,
InvalidNewDeviceOtp = 2,
NewDeviceVerificationRequired = 3,
NoDeviceInformationProvided = 4
}
187 changes: 124 additions & 63 deletions src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,37 +77,51 @@
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
CustomValidatorRequestContext validatorContext)
{
// 1. we need to check if the user is a bot and if their master password hash is correct
var isBot = validatorContext.CaptchaResponse?.IsBot ?? false;
if (isBot)
{
_logger.LogInformation(Constants.BypassFiltersEventId,
"Login attempt for {0} detected as a captcha bot with score {1}.",
request.UserName, validatorContext.CaptchaResponse.Score);
}

var valid = await ValidateContextAsync(context, validatorContext);
var user = validatorContext.User;
if (!valid)
{
await UpdateFailedAuthDetailsAsync(user, false, !validatorContext.KnownDevice);
}

if (!valid || isBot)
{
if (isBot)
{
_logger.LogInformation(Constants.BypassFiltersEventId,
"Login attempt for {UserName} detected as a captcha bot with score {CaptchaScore}.",
request.UserName, validatorContext.CaptchaResponse.Score);
}

if (!valid)
{
await UpdateFailedAuthDetailsAsync(user, false, !validatorContext.KnownDevice);
}

await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user);
return;
}

var (isTwoFactorRequired, twoFactorOrganization) = await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(user, request);
var twoFactorToken = request.Raw["TwoFactorToken"]?.ToString();
var twoFactorProvider = request.Raw["TwoFactorProvider"]?.ToString();
var twoFactorRemember = request.Raw["TwoFactorRemember"]?.ToString() == "1";
var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) &&
!string.IsNullOrWhiteSpace(twoFactorProvider);
// 2. Does this user belong to an organization that requires SSO
validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType);
if (validatorContext.SsoRequired)
{
SetSsoResult(context,
new Dictionary<string, object>
{
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
});
return;
}

if (isTwoFactorRequired)
// 3. Check if 2FA is required
(validatorContext.TwoFactorRequired, var twoFactorOrganization) = await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(user, request);
// This flag is used to determine if the user wants a rememberMe token sent when authentication is successful
var returnRememberMeToken = false;
if (validatorContext.TwoFactorRequired)
{
// 2FA required and not provided response
var twoFactorToken = request.Raw["TwoFactorToken"]?.ToString();
var twoFactorProvider = request.Raw["TwoFactorProvider"]?.ToString();
var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) &&
!string.IsNullOrWhiteSpace(twoFactorProvider);
// response for 2FA required and not provided state
if (!validTwoFactorRequest ||
!Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType))
{
Expand All @@ -125,18 +139,14 @@
return;
}

var verified = await _twoFactorAuthenticationValidator
var twoFactorTokenValid = await _twoFactorAuthenticationValidator
.VerifyTwoFactor(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken);

// 2FA required but request not valid or remember token expired response
if (!verified || isBot)
// response for 2FA required but request is not valid or remember token expired state
if (!twoFactorTokenValid)
{
if (twoFactorProviderType != TwoFactorProviderType.Remember)
{
await UpdateFailedAuthDetailsAsync(user, true, !validatorContext.KnownDevice);
await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, user);
}
else if (twoFactorProviderType == TwoFactorProviderType.Remember)
// The remember me token has expired
if (twoFactorProviderType == TwoFactorProviderType.Remember)
{
var resultDict = await _twoFactorAuthenticationValidator
.BuildTwoFactorResultAsync(user, twoFactorOrganization);
Expand All @@ -145,16 +155,34 @@
resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicy(user));
SetTwoFactorResult(context, resultDict);
}
else
{
await UpdateFailedAuthDetailsAsync(user, true, !validatorContext.KnownDevice);
await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, user);
}
return;
}

// When the two factor authentication is successful, we can check if the user wants a rememberMe token
var twoFactorRemember = request.Raw["TwoFactorRemember"]?.ToString() == "1";
if (twoFactorRemember // Check if the user wants a rememberMe token
&& twoFactorTokenValid // Make sure two factor authentication was successful
&& twoFactorProviderType != TwoFactorProviderType.Remember) // if the two factor auth was rememberMe do not send another token
{
returnRememberMeToken = true;
}
}
else

// 4. Check if the user is logging in from a new device
var deviceValid = await _deviceValidator.ValidateRequestDeviceAsync(request, validatorContext);
if (!deviceValid)
{
validTwoFactorRequest = false;
twoFactorRemember = false;
SetValidationErrorResult(context, validatorContext);
await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn);
return;
}

// Force legacy users to the web for migration
// 5. Force legacy users to the web for migration
if (FeatureService.IsEnabled(FeatureFlagKeys.BlockLegacyUsers))
{
if (UserService.IsLegacyUser(user) && request.ClientId != "web")
Expand All @@ -164,24 +192,7 @@
}
}

if (await IsValidAuthTypeAsync(user, request.GrantType))
{
var device = await _deviceValidator.SaveDeviceAsync(user, request);
if (device == null)
{
await BuildErrorResultAsync("No device information provided.", false, context, user);
return;
}
await BuildSuccessResultAsync(user, context, device, validTwoFactorRequest && twoFactorRemember);
}
else
{
SetSsoResult(context,
new Dictionary<string, object>
{
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
});
}
await BuildSuccessResultAsync(user, context, validatorContext.Device, returnRememberMeToken);
}

protected async Task FailAuthForLegacyUserAsync(User user, T context)
Expand Down Expand Up @@ -235,6 +246,17 @@
await SetSuccessResult(context, user, claims, customResponse);
}

/// <summary>
/// This does two things, it sets the error result for the current ValidatorContext _and_ it logs error.
/// These two things should be seperated to maintain single concerns.
/// </summary>
/// <param name="message">Error message for the error result</param>
/// <param name="twoFactorRequest">bool that controls how the error is logged</param>
/// <param name="context">used to set the error result in the current validator</param>
/// <param name="user">used to associate the failed login with a user</param>
/// <returns>void</returns>
[Obsolete("Consider using SetValidationErrorResult to set the validation result, and LogFailedLoginEvent " +
"to log the failure.")]
protected async Task BuildErrorResultAsync(string message, bool twoFactorRequest, T context, User user)
{
if (user != null)
Expand All @@ -255,41 +277,80 @@
new Dictionary<string, object> { { "ErrorModel", new ErrorResponseModel(message) } });
}

protected abstract void SetTwoFactorResult(T context, Dictionary<string, object> customResponse);
protected async Task LogFailedLoginEvent(User user, EventType eventType)
{
if (user != null)
{
await _eventService.LogUserEventAsync(user.Id, eventType);
}

if (_globalSettings.SelfHosted)
{

Check warning on line 288 in src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs

View check run for this annotation

Codecov / codecov/patch

src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs#L288

Added line #L288 was not covered by tests
string formattedMessage;
switch (eventType)
{
case EventType.User_FailedLogIn:
formattedMessage = string.Format("Failed login attempt. {0}", $" {CurrentContext.IpAddress}");
break;

Check warning on line 294 in src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs

View check run for this annotation

Codecov / codecov/patch

src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs#L293-L294

Added lines #L293 - L294 were not covered by tests
case EventType.User_FailedLogIn2fa:
formattedMessage = string.Format("Failed login attempt, 2FA invalid.{0}", $" {CurrentContext.IpAddress}");
break;

Check warning on line 297 in src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs

View check run for this annotation

Codecov / codecov/patch

src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs#L296-L297

Added lines #L296 - L297 were not covered by tests
default:
formattedMessage = "Failed login attempt.";
break;

Check warning on line 300 in src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs

View check run for this annotation

Codecov / codecov/patch

src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs#L299-L300

Added lines #L299 - L300 were not covered by tests
}
_logger.LogWarning(Constants.BypassFiltersEventId, formattedMessage);
}

Check warning on line 303 in src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs

View check run for this annotation

Codecov / codecov/patch

src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs#L302-L303

Added lines #L302 - L303 were not covered by tests
await Task.Delay(2000); // Delay for brute force.
}

[Obsolete("Consider using SetValidationErrorResult instead.")]
protected abstract void SetTwoFactorResult(T context, Dictionary<string, object> customResponse);
[Obsolete("Consider using SetValidationErrorResult instead.")]
protected abstract void SetSsoResult(T context, Dictionary<string, object> customResponse);
[Obsolete("Consider using SetValidationErrorResult instead.")]
protected abstract void SetErrorResult(T context, Dictionary<string, object> customResponse);

/// <summary>
/// This consumes the ValidationErrorResult property in the CustomValidatorRequestContext and sets
/// it appropriately in the response object for the token and grant validators.
/// </summary>
/// <param name="context">The current grant or token context</param>
/// <param name="requestContext">The modified request context containing material used to build the response object</param>
protected abstract void SetValidationErrorResult(T context, CustomValidatorRequestContext requestContext);
protected abstract Task SetSuccessResult(T context, User user, List<Claim> claims,
Dictionary<string, object> customResponse);

protected abstract void SetErrorResult(T context, Dictionary<string, object> customResponse);
protected abstract ClaimsPrincipal GetSubject(T context);

/// <summary>
/// Check if the user is required to authenticate via SSO. If the user requires SSO, but they are
/// logging in using an API Key (client_credentials) then they are allowed to bypass the SSO requirement.
/// If the GrantType is authorization_code or client_credentials we know the user is trying to login
/// using the SSO flow so they are allowed to continue.
/// </summary>
/// <param name="user">user trying to login</param>
/// <param name="grantType">magic string identifying the grant type requested</param>
/// <returns></returns>
private async Task<bool> IsValidAuthTypeAsync(User user, string grantType)
/// <returns>true if sso required; false if not required or already in process</returns>
private async Task<bool> RequireSsoLoginAsync(User user, string grantType)
{
if (grantType == "authorization_code" || grantType == "client_credentials")
{
// Already using SSO to authorize, finish successfully
// Or login via api key, skip SSO requirement
return true;
// Already using SSO to authenticate, or logging-in via api key to skip SSO requirement
// allow to authenticate successfully
return false;
}

// Check if user belongs to any organization with an active SSO policy
var anySsoPoliciesApplicableToUser = await PolicyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
var anySsoPoliciesApplicableToUser = await PolicyService.AnyPoliciesApplicableToUserAsync(
user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
if (anySsoPoliciesApplicableToUser)
{
return false;
return true;
}

// Default - continue validation process
return true;
// Default - SSO is not required
return false;
}

private async Task ResetFailedAuthDetailsAsync(User user)
Expand Down Expand Up @@ -350,7 +411,7 @@
var orgs = (await CurrentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id))
.ToList();

if (!orgs.Any())
if (orgs.Count == 0)
{
return null;
}
Expand Down
Loading
Loading