Skip to content

Commit 699bfa8

Browse files
Merge remote-tracking branch 'origin/master' into auth/pm-3275/tde-add-get-mp-policy-endpoint
# Conflicts: # src/Core/Services/Implementations/UserService.cs # src/SharedWeb/Utilities/ServiceCollectionExtensions.cs # test/Core.Test/Services/UserServiceTests.cs
2 parents 2e6f90f + 1af105a commit 699bfa8

File tree

37 files changed

+1265
-26
lines changed

37 files changed

+1265
-26
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
namespace Bit.Admin.Utilities;
2+
3+
public static class WebHostEnvironmentExtensions
4+
{
5+
public static string GetStripeUrl(this IWebHostEnvironment hostingEnvironment)
6+
{
7+
if (hostingEnvironment.IsDevelopment() || hostingEnvironment.IsEnvironment("QA"))
8+
{
9+
return "https://dashboard.stripe.com/test";
10+
}
11+
12+
return "https://dashboard.stripe.com";
13+
}
14+
15+
public static string GetBraintreeMerchantUrl(this IWebHostEnvironment hostingEnvironment)
16+
{
17+
if (hostingEnvironment.IsDevelopment() || hostingEnvironment.IsEnvironment("QA"))
18+
{
19+
return "https://www.sandbox.braintreegateway.com/merchants";
20+
}
21+
22+
return "https://www.braintreegateway.com/merchants";
23+
}
24+
}

src/Admin/Views/Shared/_OrganizationFormScripts.cshtml

+10-6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@inject IWebHostEnvironment HostingEnvironment
2+
@using Bit.Admin.Utilities
13
@model OrganizationEditModel
24

35
<script>
@@ -15,10 +17,11 @@
1517
return;
1618
}
1719
if (gateway.value === '@((byte)GatewayType.Stripe)') {
18-
window.open('https://dashboard.stripe.com/customers/' + customerId.value, '_blank');
20+
const url = `@(HostingEnvironment.GetStripeUrl())/customers/${customerId.value}/`;
21+
window.open(url, '_blank');
1922
} else if (gateway.value === '@((byte)GatewayType.Braintree)') {
20-
window.open('https://www.braintreegateway.com/merchants/@(Model.BraintreeMerchantId)/'
21-
+ customerId.value, '_blank');
23+
const url = `@(HostingEnvironment.GetBraintreeMerchantUrl())/@Model.BraintreeMerchantId/${customerId.value}`;
24+
window.open(url, '_blank');
2225
}
2326
});
2427
document.getElementById('gateway-subscription-link')?.addEventListener('click', () => {
@@ -28,10 +31,11 @@
2831
return;
2932
}
3033
if (gateway.value === '@((byte)GatewayType.Stripe)') {
31-
window.open('https://dashboard.stripe.com/subscriptions/' + subId.value, '_blank');
34+
const url = `@(HostingEnvironment.GetStripeUrl())/subscriptions/${subId.value}/`;
35+
window.open(url, '_blank');
3236
} else if (gateway.value === '@((byte)GatewayType.Braintree)') {
33-
window.open('https://www.braintreegateway.com/merchants/@(Model.BraintreeMerchantId)/' +
34-
'subscriptions/' + subId.value, '_blank');
37+
const url = `@(HostingEnvironment.GetBraintreeMerchantUrl())/@Model.BraintreeMerchantId/subscriptions/${subId.value}`;
38+
window.open(url, '_blank');
3539
}
3640
});
3741
document.getElementById('@(nameof(Model.UseSecretsManager))').addEventListener('change', (event) => {

src/Admin/Views/Users/Edit.cshtml

+19-8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
@using Bit.Admin.Enums;
22
@inject Bit.Admin.Services.IAccessControlService AccessControlService
3+
@inject IWebHostEnvironment HostingEnvironment
34
@model UserEditModel
45
@{
56
ViewData["Title"] = "User: " + Model.User.Email;
@@ -10,7 +11,7 @@
1011
var canViewPremium = AccessControlService.UserHasPermission(Permission.User_Premium_View);
1112
var canViewLicensing = AccessControlService.UserHasPermission(Permission.User_Licensing_View);
1213
var canViewBilling = AccessControlService.UserHasPermission(Permission.User_Billing_View);
13-
14+
1415
var canEditPremium = AccessControlService.UserHasPermission(Permission.User_Premium_Edit);
1516
var canEditLicensing = AccessControlService.UserHasPermission(Permission.User_Licensing_Edit);
1617
var canEditBilling = AccessControlService.UserHasPermission(Permission.User_Billing_Edit);
@@ -45,10 +46,15 @@
4546
}
4647
4748
if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Stripe)') {
48-
window.open('https://dashboard.stripe.com/customers/' + customerId.value, '_blank');
49+
const url = '@(HostingEnvironment.IsDevelopment()
50+
? "https://dashboard.stripe.com/test"
51+
: "https://dashboard.stripe.com")';
52+
window.open(`${url}/customers/${customerId.value}/`, '_blank');
4953
} else if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Braintree)') {
50-
window.open('https://www.braintreegateway.com/merchants/@(Model.BraintreeMerchantId)/' +
51-
'customers/' + customerId.value, '_blank');
54+
const url = '@(HostingEnvironment.IsDevelopment()
55+
? $"https://www.sandbox.braintreegateway.com/merchants/{Model.BraintreeMerchantId}"
56+
: $"https://www.braintreegateway.com/merchants/{Model.BraintreeMerchantId}")';
57+
window.open(`${url}/${customerId.value}`, '_blank');
5258
}
5359
});
5460
@@ -60,10 +66,15 @@
6066
}
6167
6268
if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Stripe)') {
63-
window.open('https://dashboard.stripe.com/subscriptions/' + subId.value, '_blank');
69+
const url = '@(HostingEnvironment.IsDevelopment() || HostingEnvironment.IsEnvironment("QA")
70+
? "https://dashboard.stripe.com/test"
71+
: "https://dashboard.stripe.com")'
72+
window.open(`${url}/subscriptions/${subId.value}`, '_blank');
6473
} else if (gateway.value === '@((byte)Bit.Core.Enums.GatewayType.Braintree)') {
65-
window.open('https://www.braintreegateway.com/merchants/@(Model.BraintreeMerchantId)/' +
66-
'subscriptions/' + subId.value, '_blank');
74+
const url = '@(HostingEnvironment.IsDevelopment() || HostingEnvironment.IsEnvironment("QA")
75+
? $"https://www.sandbox.braintreegateway.com/merchants/{Model.BraintreeMerchantId}"
76+
: $"https://www.braintreegateway.com/merchants/{Model.BraintreeMerchantId}")';
77+
window.open(`${url}/subscriptions/${subId.value}`, '_blank');
6778
}
6879
});
6980
})();
@@ -80,7 +91,7 @@
8091
@if (canViewBillingInformation)
8192
{
8293
<h2>Billing Information</h2>
83-
@await Html.PartialAsync("_BillingInformation",
94+
@await Html.PartialAsync("_BillingInformation",
8495
new BillingInformationModel { BillingInfo = Model.BillingInfo, UserId = Model.User.Id, Entity = "User" })
8596
}
8697
@if (canViewGeneral)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
using Bit.Api.Auth.Models.Request.Accounts;
2+
using Bit.Api.Auth.Models.Request.Webauthn;
3+
using Bit.Api.Auth.Models.Response.WebAuthn;
4+
using Bit.Api.Models.Response;
5+
using Bit.Core;
6+
using Bit.Core.Auth.Models.Business.Tokenables;
7+
using Bit.Core.Auth.Repositories;
8+
using Bit.Core.Exceptions;
9+
using Bit.Core.Services;
10+
using Bit.Core.Tokens;
11+
using Bit.Core.Utilities;
12+
using Microsoft.AspNetCore.Authorization;
13+
using Microsoft.AspNetCore.Mvc;
14+
15+
namespace Bit.Api.Auth.Controllers;
16+
17+
[Route("webauthn")]
18+
[Authorize("Web")]
19+
[RequireFeature(FeatureFlagKeys.PasswordlessLogin)]
20+
public class WebAuthnController : Controller
21+
{
22+
private readonly IUserService _userService;
23+
private readonly IWebAuthnCredentialRepository _credentialRepository;
24+
private readonly IDataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable> _createOptionsDataProtector;
25+
26+
public WebAuthnController(
27+
IUserService userService,
28+
IWebAuthnCredentialRepository credentialRepository,
29+
IDataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable> createOptionsDataProtector)
30+
{
31+
_userService = userService;
32+
_credentialRepository = credentialRepository;
33+
_createOptionsDataProtector = createOptionsDataProtector;
34+
}
35+
36+
[HttpGet("")]
37+
public async Task<ListResponseModel<WebAuthnCredentialResponseModel>> Get()
38+
{
39+
var user = await GetUserAsync();
40+
var credentials = await _credentialRepository.GetManyByUserIdAsync(user.Id);
41+
42+
return new ListResponseModel<WebAuthnCredentialResponseModel>(credentials.Select(c => new WebAuthnCredentialResponseModel(c)));
43+
}
44+
45+
[HttpPost("options")]
46+
public async Task<WebAuthnCredentialCreateOptionsResponseModel> PostOptions([FromBody] SecretVerificationRequestModel model)
47+
{
48+
var user = await VerifyUserAsync(model);
49+
var options = await _userService.StartWebAuthnLoginRegistrationAsync(user);
50+
51+
var tokenable = new WebAuthnCredentialCreateOptionsTokenable(user, options);
52+
var token = _createOptionsDataProtector.Protect(tokenable);
53+
54+
return new WebAuthnCredentialCreateOptionsResponseModel
55+
{
56+
Options = options,
57+
Token = token
58+
};
59+
}
60+
61+
[HttpPost("")]
62+
public async Task Post([FromBody] WebAuthnCredentialRequestModel model)
63+
{
64+
var user = await GetUserAsync();
65+
var tokenable = _createOptionsDataProtector.Unprotect(model.Token);
66+
if (!tokenable.TokenIsValid(user))
67+
{
68+
throw new BadRequestException("The token associated with your request is expired. A valid token is required to continue.");
69+
}
70+
71+
var success = await _userService.CompleteWebAuthLoginRegistrationAsync(user, model.Name, tokenable.Options, model.DeviceResponse);
72+
if (!success)
73+
{
74+
throw new BadRequestException("Unable to complete WebAuthn registration.");
75+
}
76+
}
77+
78+
[HttpPost("{id}/delete")]
79+
public async Task Delete(Guid id, [FromBody] SecretVerificationRequestModel model)
80+
{
81+
var user = await VerifyUserAsync(model);
82+
var credential = await _credentialRepository.GetByIdAsync(id, user.Id);
83+
if (credential == null)
84+
{
85+
throw new NotFoundException("Credential not found.");
86+
}
87+
88+
await _credentialRepository.DeleteAsync(credential);
89+
}
90+
91+
private async Task<Core.Entities.User> GetUserAsync()
92+
{
93+
var user = await _userService.GetUserByPrincipalAsync(User);
94+
if (user == null)
95+
{
96+
throw new UnauthorizedAccessException();
97+
}
98+
return user;
99+
}
100+
101+
private async Task<Core.Entities.User> VerifyUserAsync(SecretVerificationRequestModel model)
102+
{
103+
var user = await GetUserAsync();
104+
if (!await _userService.VerifySecretAsync(user, model.Secret))
105+
{
106+
await Task.Delay(Constants.FailedSecretVerificationDelay);
107+
throw new BadRequestException(string.Empty, "User verification failed.");
108+
}
109+
110+
return user;
111+
}
112+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using Fido2NetLib;
3+
4+
namespace Bit.Api.Auth.Models.Request.Webauthn;
5+
6+
public class WebAuthnCredentialRequestModel
7+
{
8+
[Required]
9+
public AuthenticatorAttestationRawResponse DeviceResponse { get; set; }
10+
11+
[Required]
12+
public string Name { get; set; }
13+
14+
[Required]
15+
public string Token { get; set; }
16+
}
17+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using Bit.Core.Models.Api;
2+
using Fido2NetLib;
3+
4+
namespace Bit.Api.Auth.Models.Response.WebAuthn;
5+
6+
public class WebAuthnCredentialCreateOptionsResponseModel : ResponseModel
7+
{
8+
private const string ResponseObj = "webauthnCredentialCreateOptions";
9+
10+
public WebAuthnCredentialCreateOptionsResponseModel() : base(ResponseObj)
11+
{
12+
}
13+
14+
public CredentialCreateOptions Options { get; set; }
15+
public string Token { get; set; }
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using Bit.Core.Auth.Entities;
2+
using Bit.Core.Models.Api;
3+
4+
namespace Bit.Api.Auth.Models.Response.WebAuthn;
5+
6+
public class WebAuthnCredentialResponseModel : ResponseModel
7+
{
8+
private const string ResponseObj = "webauthnCredential";
9+
10+
public WebAuthnCredentialResponseModel(WebAuthnCredential credential) : base(ResponseObj)
11+
{
12+
Id = credential.Id.ToString();
13+
Name = credential.Name;
14+
PrfSupport = false;
15+
}
16+
17+
public string Id { get; set; }
18+
public string Name { get; set; }
19+
public bool PrfSupport { get; set; }
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using Bit.Core.Entities;
3+
using Bit.Core.Utilities;
4+
5+
namespace Bit.Core.Auth.Entities;
6+
7+
public class WebAuthnCredential : ITableObject<Guid>
8+
{
9+
public Guid Id { get; set; }
10+
public Guid UserId { get; set; }
11+
[MaxLength(50)]
12+
public string Name { get; set; }
13+
[MaxLength(256)]
14+
public string PublicKey { get; set; }
15+
[MaxLength(256)]
16+
public string CredentialId { get; set; }
17+
public int Counter { get; set; }
18+
[MaxLength(20)]
19+
public string Type { get; set; }
20+
public Guid AaGuid { get; set; }
21+
public string EncryptedUserKey { get; set; }
22+
public string EncryptedPrivateKey { get; set; }
23+
public string EncryptedPublicKey { get; set; }
24+
public bool SupportsPrf { get; set; }
25+
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
26+
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
27+
28+
public void SetNewId()
29+
{
30+
Id = CoreHelpers.GenerateComb();
31+
}
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using System.Text.Json.Serialization;
2+
using Bit.Core.Entities;
3+
using Bit.Core.Tokens;
4+
using Fido2NetLib;
5+
6+
namespace Bit.Core.Auth.Models.Business.Tokenables;
7+
8+
public class WebAuthnCredentialCreateOptionsTokenable : ExpiringTokenable
9+
{
10+
// 7 minutes = max webauthn timeout (6 minutes) + slack for miscellaneous delays
11+
private const double _tokenLifetimeInHours = (double)7 / 60;
12+
public const string ClearTextPrefix = "BWWebAuthnCredentialCreateOptions_";
13+
public const string DataProtectorPurpose = "WebAuthnCredentialCreateDataProtector";
14+
public const string TokenIdentifier = "WebAuthnCredentialCreateOptionsToken";
15+
16+
public string Identifier { get; set; } = TokenIdentifier;
17+
public Guid? UserId { get; set; }
18+
public CredentialCreateOptions Options { get; set; }
19+
20+
[JsonConstructor]
21+
public WebAuthnCredentialCreateOptionsTokenable()
22+
{
23+
ExpirationDate = DateTime.UtcNow.AddHours(_tokenLifetimeInHours);
24+
}
25+
26+
public WebAuthnCredentialCreateOptionsTokenable(User user, CredentialCreateOptions options) : this()
27+
{
28+
UserId = user?.Id;
29+
Options = options;
30+
}
31+
32+
public bool TokenIsValid(User user)
33+
{
34+
if (!Valid || user == null)
35+
{
36+
return false;
37+
}
38+
39+
return UserId == user.Id;
40+
}
41+
42+
protected override bool TokenIsValid() => Identifier == TokenIdentifier && UserId != null && Options != null;
43+
}
44+

0 commit comments

Comments
 (0)