Skip to content

Commit

Permalink
[PM-12995] Create UI elements for New Device Verification in Admin Po…
Browse files Browse the repository at this point in the history
…rtal (#5165)

* feat(NewDeviceVerification) :
- Added constant to constants in Bit.Core because the cache key format needs to be shared between the Identity Server and the MVC project Admin.
- Updated DeviceValidator class to handle checking cache for user information to allow pass through.
- Updated and Added tests to handle new flow.
- Adding exception flow to admin project. Added tests for new methods in UserService.
  • Loading branch information
ike-kottlowski authored Jan 10, 2025
1 parent 1988f14 commit ce2ecf9
Show file tree
Hide file tree
Showing 6 changed files with 251 additions and 52 deletions.
19 changes: 18 additions & 1 deletion src/Admin/Controllers/UsersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ public async Task<IActionResult> Edit(Guid id)
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user);
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var verifiedDomain = await AccountDeprovisioningEnabled(user.Id);
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings, verifiedDomain));
var deviceVerificationRequired = await _userService.ActiveNewDeviceVerificationException(user.Id);
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings, verifiedDomain, deviceVerificationRequired));
}

[HttpPost]
Expand Down Expand Up @@ -162,6 +163,22 @@ public async Task<IActionResult> Delete(Guid id)
return RedirectToAction("Index");
}

[HttpPost]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.User_GeneralDetails_View)]
[RequireFeature(FeatureFlagKeys.NewDeviceVerification)]
public async Task<IActionResult> ToggleNewDeviceVerification(Guid id)
{
var user = await _userRepository.GetByIdAsync(id);
if (user == null)
{
return RedirectToAction("Index");
}

await _userService.ToggleNewDeviceVerificationException(user.Id);
return RedirectToAction("Edit", new { id });
}

// TODO: Feature flag to be removed in PM-14207
private async Task<bool?> AccountDeprovisioningEnabled(Guid userId)
{
Expand Down
7 changes: 6 additions & 1 deletion src/Admin/Models/UserEditModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ public UserEditModel(
BillingInfo billingInfo,
BillingHistoryInfo billingHistoryInfo,
GlobalSettings globalSettings,
bool? claimedAccount)
bool? claimedAccount,
bool? activeNewDeviceVerificationException)
{
User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, claimedAccount);

ActiveNewDeviceVerificationException = activeNewDeviceVerificationException ?? false;

BillingInfo = billingInfo;
BillingHistoryInfo = billingHistoryInfo;
BraintreeMerchantId = globalSettings.Braintree.MerchantId;
Expand All @@ -44,6 +47,8 @@ public UserEditModel(
public string RandomLicenseKey => CoreHelpers.SecureRandomString(20);
public string OneYearExpirationDate => DateTime.Now.AddYears(1).ToString("yyyy-MM-ddTHH:mm");
public string BraintreeMerchantId { get; init; }
public bool ActiveNewDeviceVerificationException { get; init; }


[Display(Name = "Name")]
public string Name { get; init; }
Expand Down
132 changes: 85 additions & 47 deletions src/Admin/Views/Users/Edit.cshtml
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
@using Bit.Admin.Enums;
@inject Bit.Admin.Services.IAccessControlService AccessControlService
@inject Bit.Core.Services.IFeatureService FeatureService
@inject IWebHostEnvironment HostingEnvironment
@model UserEditModel
@{
ViewData["Title"] = "User: " + Model.User.Email;

var canViewUserInformation = AccessControlService.UserHasPermission(Permission.User_UserInformation_View);
var canViewNewDeviceException = AccessControlService.UserHasPermission(Permission.User_UserInformation_View) &&
FeatureService.IsEnabled(Bit.Core.FeatureFlagKeys.NewDeviceVerification);
var canViewBillingInformation = AccessControlService.UserHasPermission(Permission.User_BillingInformation_View);
var canViewGeneral = AccessControlService.UserHasPermission(Permission.User_GeneralDetails_View);
var canViewPremium = AccessControlService.UserHasPermission(Permission.User_Premium_View);
Expand All @@ -32,7 +35,11 @@
// Premium
document.getElementById('@(nameof(Model.MaxStorageGb))').value = '1';
document.getElementById('@(nameof(Model.Premium))').checked = true;
using Stripe.Entitlements;
// Licensing
using Bit.Core;
using Stripe.Entitlements;
using Microsoft.Identity.Client.Extensibility;
document.getElementById('@(nameof(Model.LicenseKey))').value = '@Model.RandomLicenseKey';
document.getElementById('@(nameof(Model.PremiumExpirationDate))').value =
'@Model.OneYearExpirationDate';
Expand All @@ -47,13 +54,13 @@
if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Stripe)') {
const url = '@(HostingEnvironment.IsDevelopment()
? "https://dashboard.stripe.com/test"
: "https://dashboard.stripe.com")';
? "https://dashboard.stripe.com/test"
: "https://dashboard.stripe.com")';
window.open(`${url}/customers/${customerId.value}/`, '_blank');
} else if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Braintree)') {
const url = '@(HostingEnvironment.IsDevelopment()
? $"https://www.sandbox.braintreegateway.com/merchants/{Model.BraintreeMerchantId}"
: $"https://www.braintreegateway.com/merchants/{Model.BraintreeMerchantId}")';
? $"https://www.sandbox.braintreegateway.com/merchants/{Model.BraintreeMerchantId}"
: $"https://www.braintreegateway.com/merchants/{Model.BraintreeMerchantId}")';
window.open(`${url}/${customerId.value}`, '_blank');
}
});
Expand All @@ -67,13 +74,13 @@
if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Stripe)') {
const url = '@(HostingEnvironment.IsDevelopment() || HostingEnvironment.IsEnvironment("QA")
? "https://dashboard.stripe.com/test"
: "https://dashboard.stripe.com")'
? "https://dashboard.stripe.com/test"
: "https://dashboard.stripe.com")'
window.open(`${url}/subscriptions/${subId.value}`, '_blank');
} else if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Braintree)') {
const url = '@(HostingEnvironment.IsDevelopment() || HostingEnvironment.IsEnvironment("QA")
? $"https://www.sandbox.braintreegateway.com/merchants/{Model.BraintreeMerchantId}"
: $"https://www.braintreegateway.com/merchants/{Model.BraintreeMerchantId}")';
? $"https://www.sandbox.braintreegateway.com/merchants/{Model.BraintreeMerchantId}"
: $"https://www.braintreegateway.com/merchants/{Model.BraintreeMerchantId}")';
window.open(`${url}/subscriptions/${subId.value}`, '_blank');
}
});
Expand All @@ -88,11 +95,40 @@
<h2>User Information</h2>
@await Html.PartialAsync("_ViewInformation", Model.User)
}
@if (canViewNewDeviceException)
{
<h2>New Device Verification </h2>
<dl class="row">
<dt class="col d-flex">
<form asp-action="ToggleNewDeviceVerification" asp-route-id="@Model.User.Id" method="post">
@if (Model.ActiveNewDeviceVerificationException)
{
<p>Status: Bypassed</p>
<button type="submit" class="btn btn-success" id="new-device-verification-exception">Require New
Device Verification</button>
}
else
{
<p>Status: Required</p>
<button type="submit" class="btn btn-outline-danger" id="new-device-verification-exception">Bypass New
Device Verification</button>
}
</form>

</dt>
</dl>
}
@if (canViewBillingInformation)
{
<h2>Billing Information</h2>
@await Html.PartialAsync("_BillingInformation",
new BillingInformationModel { BillingInfo = Model.BillingInfo, BillingHistoryInfo = Model.BillingHistoryInfo, UserId = Model.User.Id, Entity = "User" })
new BillingInformationModel
{
BillingInfo = Model.BillingInfo,
BillingHistoryInfo = Model.BillingHistoryInfo,
UserId = Model.User.Id,
Entity = "User"
})
}
@if (canViewGeneral)
{
Expand All @@ -109,7 +145,7 @@
<label class="form-check-label" asp-for="EmailVerified"></label>
</div>
}
<form method="post" id="edit-form">
<form method="post" id="edit-form">
@if (canViewPremium)
{
<h2>Premium</h2>
Expand Down Expand Up @@ -139,54 +175,56 @@
<div class="col-sm">
<div class="mb-3">
<label asp-for="PremiumExpirationDate" class="form-label"></label>
<input type="datetime-local" class="form-control" asp-for="PremiumExpirationDate" readonly='@(!canEditLicensing)'>
<input type="datetime-local" class="form-control" asp-for="PremiumExpirationDate"
readonly='@(!canEditLicensing)'>
</div>
</div>
</div>
}
@if (canViewBilling)
{
<h2>Billing</h2>
<div class="row">
<div class="col-md">
<div class="mb-3">
<label asp-for="Gateway" class="form-label"></label>
<select class="form-select" asp-for="Gateway" disabled='@(canEditBilling ? null : "disabled")'
@if (canViewBilling)
{
<h2>Billing</h2>
<div class="row">
<div class="col-md">
<div class="mb-3">
<label asp-for="Gateway" class="form-label"></label>
<select class="form-select" asp-for="Gateway" disabled='@(canEditBilling ? null : "disabled")'
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
<option value="">--</option>
</select>
<option value="">--</option>
</select>
</div>
</div>
</div>
<div class="col-md">
<div class="mb-3">
<label asp-for="GatewayCustomerId" class="form-label"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId" readonly='@(!canEditBilling)'>
@if (canLaunchGateway)
{
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
<i class="fa fa-external-link"></i>
</button>
}
<div class="col-md">
<div class="mb-3">
<label asp-for="GatewayCustomerId" class="form-label"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId" readonly='@(!canEditBilling)'>
@if (canLaunchGateway)
{
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
<i class="fa fa-external-link"></i>
</button>
}
</div>
</div>
</div>
</div>
<div class="col-md">
<div class="mb-3">
<label asp-for="GatewaySubscriptionId" class="form-label"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId" readonly='@(!canEditBilling)'>
@if (canLaunchGateway)
{
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
<i class="fa fa-external-link"></i>
</button>
}
<div class="col-md">
<div class="mb-3">
<label asp-for="GatewaySubscriptionId" class="form-label"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId"
readonly='@(!canEditBilling)'>
@if (canLaunchGateway)
{
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
<i class="fa fa-external-link"></i>
</button>
}
</div>
</div>
</div>
</div>
</div>
}
}
</form>
<div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
Expand Down
11 changes: 11 additions & 0 deletions src/Core/Services/IUserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ Task<IdentityResult> UpdatePasswordHash(User user, string newPassword,
Task<bool> VerifyOTPAsync(User user, string token);
Task<bool> VerifySecretAsync(User user, string secret, bool isSettingMFA = false);
Task ResendNewDeviceVerificationEmail(string email, string secret);
/// <summary>
/// We use this method to check if the user has an active new device verification bypass
/// </summary>
/// <param name="userId">self</param>
/// <returns>returns true if the value is found in the cache</returns>
Task<bool> ActiveNewDeviceVerificationException(Guid userId);
/// <summary>
/// We use this method to toggle the new device verification bypass
/// </summary>
/// <param name="userId">Id of user bypassing new device verification</param>
Task ToggleNewDeviceVerificationException(Guid userId);

void SetTwoFactorProvider(User user, TwoFactorProviderType type, bool setEnabled = true);

Expand Down
30 changes: 29 additions & 1 deletion src/Core/Services/Implementations/UserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
using Fido2NetLib.Objects;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using File = System.IO.File;
Expand Down Expand Up @@ -72,6 +73,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
private readonly IPremiumUserBillingService _premiumUserBillingService;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand;
private readonly IDistributedCache _distributedCache;

public UserService(
IUserRepository userRepository,
Expand Down Expand Up @@ -107,7 +109,8 @@ public UserService(
IFeatureService featureService,
IPremiumUserBillingService premiumUserBillingService,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand)
IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand,
IDistributedCache distributedCache)
: base(
store,
optionsAccessor,
Expand Down Expand Up @@ -149,6 +152,7 @@ public UserService(
_premiumUserBillingService = premiumUserBillingService;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand;
_distributedCache = distributedCache;
}

public Guid? GetProperUserId(ClaimsPrincipal principal)
Expand Down Expand Up @@ -1471,6 +1475,30 @@ public async Task ResendNewDeviceVerificationEmail(string email, string secret)
}
}

public async Task<bool> ActiveNewDeviceVerificationException(Guid userId)
{
var cacheKey = string.Format(AuthConstants.NewDeviceVerificationExceptionCacheKeyFormat, userId.ToString());
var cacheValue = await _distributedCache.GetAsync(cacheKey);
return cacheValue != null;
}

public async Task ToggleNewDeviceVerificationException(Guid userId)
{
var cacheKey = string.Format(AuthConstants.NewDeviceVerificationExceptionCacheKeyFormat, userId.ToString());
var cacheValue = await _distributedCache.GetAsync(cacheKey);
if (cacheValue != null)
{
await _distributedCache.RemoveAsync(cacheKey);
}
else
{
await _distributedCache.SetAsync(cacheKey, new byte[1], new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24)
});
}
}

private async Task SendAppropriateWelcomeEmailAsync(User user, string initiationPath)
{
var isFromMarketingWebsite = initiationPath.Contains("Secrets Manager trial");
Expand Down
Loading

0 comments on commit ce2ecf9

Please sign in to comment.