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

[PM-1188] Server owner auth migration (#2825)

* [PM-1188] add sso project to auth

* [PM-1188] move sso api models to auth

* [PM-1188] fix sso api model namespace & imports

* [PM-1188] move core files to auth

* [PM-1188] fix core sso namespace & models

* [PM-1188] move sso repository files to auth

* [PM-1188] fix sso repo files namespace & imports

* [PM-1188] move sso sql files to auth folder

* [PM-1188] move sso test files to auth folders

* [PM-1188] fix sso tests namespace & imports

* [PM-1188] move auth api files to auth folder

* [PM-1188] fix auth api files namespace & imports

* [PM-1188] move auth core files to auth folder

* [PM-1188] fix auth core files namespace & imports

* [PM-1188] move auth email templates to auth folder

* [PM-1188] move auth email folder back into shared directory

* [PM-1188] fix auth email names

* [PM-1188] move auth core models to auth folder

* [PM-1188] fix auth model namespace & imports

* [PM-1188] add entire Identity project to auth codeowners

* [PM-1188] fix auth orm files namespace & imports

* [PM-1188] move auth orm files to auth folder

* [PM-1188] move auth sql files to auth folder

* [PM-1188] move auth tests to auth folder

* [PM-1188] fix auth test files namespace & imports

* [PM-1188] move emergency access api files to auth folder

* [PM-1188] fix emergencyaccess api files namespace & imports

* [PM-1188] move emergency access core files to auth folder

* [PM-1188] fix emergency access core files namespace & imports

* [PM-1188] move emergency access orm files to auth folder

* [PM-1188] fix emergency access orm files namespace & imports

* [PM-1188] move emergency access sql files to auth folder

* [PM-1188] move emergencyaccess test files to auth folder

* [PM-1188] fix emergency access test files namespace & imports

* [PM-1188] move captcha files to auth folder

* [PM-1188] fix captcha files namespace & imports

* [PM-1188] move auth admin files into auth folder

* [PM-1188] fix admin auth files namespace & imports
- configure mvc to look in auth folders for views

* [PM-1188] remove extra imports and formatting

* [PM-1188] fix ef auth model imports

* [PM-1188] fix DatabaseContextModelSnapshot paths

* [PM-1188] fix grant import in ef

* [PM-1188] update sqlproj

* [PM-1188] move missed sqlproj files

* [PM-1188] move auth ef models out of auth folder

* [PM-1188] fix auth ef models namespace

* [PM-1188] remove auth ef models unused imports

* [PM-1188] fix imports for auth ef models

* [PM-1188] fix more ef model imports

* [PM-1188] fix file encodings
This commit is contained in:
Jake Fink
2023-04-14 13:25:56 -04:00
committed by GitHub
parent 2529c5b36f
commit 88dd745070
332 changed files with 704 additions and 522 deletions

View File

@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.Auth.Entities;
public class AuthRequest : ITableObject<Guid>
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Enums.AuthRequestType Type { get; set; }
[MaxLength(50)]
public string RequestDeviceIdentifier { get; set; }
public DeviceType RequestDeviceType { get; set; }
[MaxLength(50)]
public string RequestIpAddress { get; set; }
public Guid? ResponseDeviceId { get; set; }
[MaxLength(25)]
public string AccessCode { get; set; }
public string PublicKey { get; set; }
public string Key { get; set; }
public string MasterPasswordHash { get; set; }
public bool? Approved { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime? ResponseDate { get; set; }
public DateTime? AuthenticationDate { get; set; }
public void SetNewId()
{
Id = CoreHelpers.GenerateComb();
}
public bool IsSpent()
{
return ResponseDate.HasValue || AuthenticationDate.HasValue || GetExpirationDate() < DateTime.UtcNow;
}
public DateTime GetExpirationDate()
{
return CreationDate.AddMinutes(15);
}
}

View File

@ -0,0 +1,47 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Auth.Enums;
using Bit.Core.Entities;
using Bit.Core.Utilities;
namespace Bit.Core.Auth.Entities;
public class EmergencyAccess : ITableObject<Guid>
{
public Guid Id { get; set; }
public Guid GrantorId { get; set; }
public Guid? GranteeId { get; set; }
[MaxLength(256)]
public string Email { get; set; }
public string KeyEncrypted { get; set; }
public EmergencyAccessType Type { get; set; }
public EmergencyAccessStatusType Status { get; set; }
public int WaitTimeDays { get; set; }
public DateTime? RecoveryInitiatedDate { get; set; }
public DateTime? LastNotificationDate { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
public void SetNewId()
{
Id = CoreHelpers.GenerateComb();
}
public EmergencyAccess ToEmergencyAccess()
{
return new EmergencyAccess
{
Id = Id,
GrantorId = GrantorId,
GranteeId = GranteeId,
Email = Email,
KeyEncrypted = KeyEncrypted,
Type = Type,
Status = Status,
WaitTimeDays = WaitTimeDays,
RecoveryInitiatedDate = RecoveryInitiatedDate,
LastNotificationDate = LastNotificationDate,
CreationDate = CreationDate,
RevisionDate = RevisionDate,
};
}
}

View File

@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Core.Auth.Entities;
public class Grant
{
[MaxLength(200)]
public string Key { get; set; }
[MaxLength(50)]
public string Type { get; set; }
[MaxLength(200)]
public string SubjectId { get; set; }
[MaxLength(100)]
public string SessionId { get; set; }
[MaxLength(200)]
public string ClientId { get; set; }
[MaxLength(200)]
public string Description { get; set; }
public DateTime CreationDate { get; set; }
public DateTime? ExpirationDate { get; set; }
public DateTime? ConsumedDate { get; set; }
public string Data { get; set; }
}

View File

@ -0,0 +1,30 @@
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
namespace Bit.Core.Auth.Entities;
public class SsoConfig : ITableObject<long>
{
public long Id { get; set; }
public bool Enabled { get; set; } = true;
public Guid OrganizationId { get; set; }
public string Data { get; set; }
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
public void SetNewId()
{
// int will be auto-populated
Id = 0;
}
public SsoConfigurationData GetData()
{
return SsoConfigurationData.Deserialize(Data);
}
public void SetData(SsoConfigurationData data)
{
Data = data.Serialize();
}
}

View File

@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Entities;
namespace Bit.Core.Auth.Entities;
public class SsoUser : ITableObject<long>
{
public long Id { get; set; }
public Guid UserId { get; set; }
public Guid? OrganizationId { get; set; }
[MaxLength(50)]
public string ExternalId { get; set; }
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
public void SetNewId()
{
// int will be auto-populated
Id = 0;
}
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Auth.Enums;
public enum AuthRequestType : byte
{
AuthenticateAndUnlock = 0,
Unlock = 1
}

View File

@ -0,0 +1,10 @@
namespace Bit.Core.Auth.Enums;
public enum EmergencyAccessStatusType : byte
{
Invited = 0,
Accepted = 1,
Confirmed = 2,
RecoveryInitiated = 3,
RecoveryApproved = 4,
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Auth.Enums;
public enum EmergencyAccessType : byte
{
View = 0,
Takeover = 1,
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Auth.Enums;
public enum Saml2BindingType : byte
{
HttpRedirect = 1,
HttpPost = 2,
}

View File

@ -0,0 +1,14 @@
namespace Bit.Core.Auth.Enums;
public enum Saml2NameIdFormat : byte
{
NotConfigured = 0,
Unspecified = 1,
EmailAddress = 2,
X509SubjectName = 3,
WindowsDomainQualifiedName = 4,
KerberosPrincipalName = 5,
EntityIdentifier = 6,
Persistent = 7,
Transient = 8,
}

View File

@ -0,0 +1,8 @@
namespace Bit.Core.Auth.Enums;
public enum Saml2SigningBehavior : byte
{
IfIdpWantAuthnRequestsSigned = 0,
Always = 1,
Never = 3
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Auth.Enums;
public enum SsoType : byte
{
OpenIdConnect = 1,
Saml2 = 2,
}

View File

@ -0,0 +1,13 @@
namespace Bit.Core.Auth.Enums;
public enum TwoFactorProviderType : byte
{
Authenticator = 0,
Email = 1,
Duo = 2,
YubiKey = 3,
U2f = 4, // Deprecated
Remember = 5,
OrganizationDuo = 6,
WebAuthn = 7,
}

View File

@ -0,0 +1,10 @@
namespace Bit.Core.Auth.Exceptions;
public class DuplicateAuthRequestException : Exception
{
public DuplicateAuthRequestException()
: base("An authentication request with the same device already exists.")
{
}
}

View File

@ -0,0 +1,45 @@
using Bit.Core.Auth.Enums;
using Bit.Core.Entities;
using Bit.Core.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using OtpNet;
namespace Bit.Core.Auth.Identity;
public class AuthenticatorTokenProvider : IUserTwoFactorTokenProvider<User>
{
private readonly IServiceProvider _serviceProvider;
public AuthenticatorTokenProvider(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
{
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Authenticator);
if (string.IsNullOrWhiteSpace((string)provider?.MetaData["Key"]))
{
return false;
}
return await _serviceProvider.GetRequiredService<IUserService>()
.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Authenticator, user);
}
public Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
{
return Task.FromResult<string>(null);
}
public Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
{
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Authenticator);
var otp = new Totp(Base32Encoding.ToBytes((string)provider.MetaData["Key"]));
long timeStepMatched;
var valid = otp.VerifyTotp(token, out timeStepMatched, new VerificationWindow(1, 1));
return Task.FromResult(valid);
}
}

View File

@ -0,0 +1,86 @@
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Utilities.Duo;
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Core.Settings;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Auth.Identity;
public class DuoWebTokenProvider : IUserTwoFactorTokenProvider<User>
{
private readonly IServiceProvider _serviceProvider;
private readonly GlobalSettings _globalSettings;
public DuoWebTokenProvider(
IServiceProvider serviceProvider,
GlobalSettings globalSettings)
{
_serviceProvider = serviceProvider;
_globalSettings = globalSettings;
}
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
if (!(await userService.CanAccessPremium(user)))
{
return false;
}
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
if (!HasProperMetaData(provider))
{
return false;
}
return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Duo, user);
}
public async Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
if (!(await userService.CanAccessPremium(user)))
{
return null;
}
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
if (!HasProperMetaData(provider))
{
return null;
}
var signatureRequest = DuoWeb.SignRequest((string)provider.MetaData["IKey"],
(string)provider.MetaData["SKey"], _globalSettings.Duo.AKey, user.Email);
return signatureRequest;
}
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
if (!(await userService.CanAccessPremium(user)))
{
return false;
}
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
if (!HasProperMetaData(provider))
{
return false;
}
var response = DuoWeb.VerifyResponse((string)provider.MetaData["IKey"], (string)provider.MetaData["SKey"],
_globalSettings.Duo.AKey, token);
return response == user.Email;
}
private bool HasProperMetaData(TwoFactorProvider provider)
{
return provider?.MetaData != null && provider.MetaData.ContainsKey("IKey") &&
provider.MetaData.ContainsKey("SKey") && provider.MetaData.ContainsKey("Host");
}
}

View File

@ -0,0 +1,83 @@
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Entities;
using Bit.Core.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Auth.Identity;
public class EmailTokenProvider : IUserTwoFactorTokenProvider<User>
{
private readonly IServiceProvider _serviceProvider;
public EmailTokenProvider(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
{
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email);
if (!HasProperMetaData(provider))
{
return false;
}
return await _serviceProvider.GetRequiredService<IUserService>().
TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Email, user);
}
public Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
{
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email);
if (!HasProperMetaData(provider))
{
return null;
}
return Task.FromResult(RedactEmail((string)provider.MetaData["Email"]));
}
public Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
{
return _serviceProvider.GetRequiredService<IUserService>().VerifyTwoFactorEmailAsync(user, token);
}
private bool HasProperMetaData(TwoFactorProvider provider)
{
return provider?.MetaData != null && provider.MetaData.ContainsKey("Email") &&
!string.IsNullOrWhiteSpace((string)provider.MetaData["Email"]);
}
private static string RedactEmail(string email)
{
var emailParts = email.Split('@');
string shownPart = null;
if (emailParts[0].Length > 2 && emailParts[0].Length <= 4)
{
shownPart = emailParts[0].Substring(0, 1);
}
else if (emailParts[0].Length > 4)
{
shownPart = emailParts[0].Substring(0, 2);
}
else
{
shownPart = string.Empty;
}
string redactedPart = null;
if (emailParts[0].Length > 4)
{
redactedPart = new string('*', emailParts[0].Length - 2);
}
else
{
redactedPart = new string('*', emailParts[0].Length - shownPart.Length);
}
return $"{shownPart}{redactedPart}@{emailParts[1]}";
}
}

View File

@ -0,0 +1,10 @@
using Bit.Core.Entities;
namespace Bit.Core.Auth.Identity;
public interface IOrganizationTwoFactorTokenProvider
{
Task<bool> CanGenerateTwoFactorTokenAsync(Organization organization);
Task<string> GenerateAsync(Organization organization, User user);
Task<bool> ValidateAsync(string token, Organization organization, User user);
}

View File

@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.Auth.Identity;
public class LowerInvariantLookupNormalizer : ILookupNormalizer
{
public string NormalizeEmail(string email)
{
return Normalize(email);
}
public string NormalizeName(string name)
{
return Normalize(name);
}
private string Normalize(string key)
{
return key?.Normalize().ToLowerInvariant();
}
}

View File

@ -0,0 +1,75 @@
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Utilities.Duo;
using Bit.Core.Entities;
using Bit.Core.Settings;
namespace Bit.Core.Auth.Identity;
public interface IOrganizationDuoWebTokenProvider : IOrganizationTwoFactorTokenProvider { }
public class OrganizationDuoWebTokenProvider : IOrganizationDuoWebTokenProvider
{
private readonly GlobalSettings _globalSettings;
public OrganizationDuoWebTokenProvider(GlobalSettings globalSettings)
{
_globalSettings = globalSettings;
}
public Task<bool> CanGenerateTwoFactorTokenAsync(Organization organization)
{
if (organization == null || !organization.Enabled || !organization.Use2fa)
{
return Task.FromResult(false);
}
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
var canGenerate = organization.TwoFactorProviderIsEnabled(TwoFactorProviderType.OrganizationDuo)
&& HasProperMetaData(provider);
return Task.FromResult(canGenerate);
}
public Task<string> GenerateAsync(Organization organization, User user)
{
if (organization == null || !organization.Enabled || !organization.Use2fa)
{
return Task.FromResult<string>(null);
}
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
if (!HasProperMetaData(provider))
{
return Task.FromResult<string>(null);
}
var signatureRequest = DuoWeb.SignRequest(provider.MetaData["IKey"].ToString(),
provider.MetaData["SKey"].ToString(), _globalSettings.Duo.AKey, user.Email);
return Task.FromResult(signatureRequest);
}
public Task<bool> ValidateAsync(string token, Organization organization, User user)
{
if (organization == null || !organization.Enabled || !organization.Use2fa)
{
return Task.FromResult(false);
}
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
if (!HasProperMetaData(provider))
{
return Task.FromResult(false);
}
var response = DuoWeb.VerifyResponse(provider.MetaData["IKey"].ToString(),
provider.MetaData["SKey"].ToString(), _globalSettings.Duo.AKey, token);
return Task.FromResult(response == user.Email);
}
private bool HasProperMetaData(TwoFactorProvider provider)
{
return provider?.MetaData != null && provider.MetaData.ContainsKey("IKey") &&
provider.MetaData.ContainsKey("SKey") && provider.MetaData.ContainsKey("Host");
}
}

View File

@ -0,0 +1,60 @@
using Bit.Core.Entities;
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.Auth.Identity;
public class RoleStore : IRoleStore<Role>
{
public void Dispose() { }
public Task<IdentityResult> CreateAsync(Role role, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<IdentityResult> DeleteAsync(Role role, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<Role> FindByIdAsync(string roleId, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<Role> FindByNameAsync(string normalizedRoleName, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<string> GetNormalizedRoleNameAsync(Role role, CancellationToken cancellationToken)
{
return Task.FromResult(role.Name);
}
public Task<string> GetRoleIdAsync(Role role, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<string> GetRoleNameAsync(Role role, CancellationToken cancellationToken)
{
return Task.FromResult(role.Name);
}
public Task SetNormalizedRoleNameAsync(Role role, string normalizedName, CancellationToken cancellationToken)
{
return Task.FromResult(0);
}
public Task SetRoleNameAsync(Role role, string roleName, CancellationToken cancellationToken)
{
role.Name = roleName;
return Task.FromResult(0);
}
public Task<IdentityResult> UpdateAsync(Role role, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
}

View File

@ -0,0 +1,20 @@
using Bit.Core.Entities;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Bit.Core.Auth.Identity;
public class TwoFactorRememberTokenProvider : DataProtectorTokenProvider<User>
{
public TwoFactorRememberTokenProvider(
IDataProtectionProvider dataProtectionProvider,
IOptions<TwoFactorRememberTokenProviderOptions> options,
ILogger<DataProtectorTokenProvider<User>> logger)
: base(dataProtectionProvider, options, logger)
{ }
}
public class TwoFactorRememberTokenProviderOptions : DataProtectionTokenProviderOptions
{ }

View File

@ -0,0 +1,183 @@
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Auth.Identity;
public class UserStore :
IUserStore<User>,
IUserPasswordStore<User>,
IUserEmailStore<User>,
IUserTwoFactorStore<User>,
IUserSecurityStampStore<User>
{
private readonly IServiceProvider _serviceProvider;
private readonly IUserRepository _userRepository;
private readonly ICurrentContext _currentContext;
public UserStore(
IServiceProvider serviceProvider,
IUserRepository userRepository,
ICurrentContext currentContext)
{
_serviceProvider = serviceProvider;
_userRepository = userRepository;
_currentContext = currentContext;
}
public void Dispose() { }
public async Task<IdentityResult> CreateAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
{
await _userRepository.CreateAsync(user);
return IdentityResult.Success;
}
public async Task<IdentityResult> DeleteAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
{
await _userRepository.DeleteAsync(user);
return IdentityResult.Success;
}
public async Task<User> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken = default(CancellationToken))
{
if (_currentContext?.User != null && _currentContext.User.Email == normalizedEmail)
{
return _currentContext.User;
}
_currentContext.User = await _userRepository.GetByEmailAsync(normalizedEmail);
return _currentContext.User;
}
public async Task<User> FindByIdAsync(string userId, CancellationToken cancellationToken = default(CancellationToken))
{
if (_currentContext?.User != null &&
string.Equals(_currentContext.User.Id.ToString(), userId, StringComparison.InvariantCultureIgnoreCase))
{
return _currentContext.User;
}
Guid userIdGuid;
if (!Guid.TryParse(userId, out userIdGuid))
{
return null;
}
_currentContext.User = await _userRepository.GetByIdAsync(userIdGuid);
return _currentContext.User;
}
public async Task<User> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken = default(CancellationToken))
{
return await FindByEmailAsync(normalizedUserName, cancellationToken);
}
public Task<string> GetEmailAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
{
return Task.FromResult(user.Email);
}
public Task<bool> GetEmailConfirmedAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
{
return Task.FromResult(user.EmailVerified);
}
public Task<string> GetNormalizedEmailAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
{
return Task.FromResult(user.Email);
}
public Task<string> GetNormalizedUserNameAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
{
return Task.FromResult(user.Email);
}
public Task<string> GetPasswordHashAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
{
return Task.FromResult(user.MasterPassword);
}
public Task<string> GetUserIdAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
{
return Task.FromResult(user.Id.ToString());
}
public Task<string> GetUserNameAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
{
return Task.FromResult(user.Email);
}
public Task<bool> HasPasswordAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
{
return Task.FromResult(!string.IsNullOrWhiteSpace(user.MasterPassword));
}
public Task SetEmailAsync(User user, string email, CancellationToken cancellationToken = default(CancellationToken))
{
user.Email = email;
return Task.FromResult(0);
}
public Task SetEmailConfirmedAsync(User user, bool confirmed, CancellationToken cancellationToken = default(CancellationToken))
{
user.EmailVerified = confirmed;
return Task.FromResult(0);
}
public Task SetNormalizedEmailAsync(User user, string normalizedEmail, CancellationToken cancellationToken = default(CancellationToken))
{
user.Email = normalizedEmail;
return Task.FromResult(0);
}
public Task SetNormalizedUserNameAsync(User user, string normalizedName, CancellationToken cancellationToken = default(CancellationToken))
{
user.Email = normalizedName;
return Task.FromResult(0);
}
public Task SetPasswordHashAsync(User user, string passwordHash, CancellationToken cancellationToken = default(CancellationToken))
{
user.MasterPassword = passwordHash;
return Task.FromResult(0);
}
public Task SetUserNameAsync(User user, string userName, CancellationToken cancellationToken = default(CancellationToken))
{
user.Email = userName;
return Task.FromResult(0);
}
public async Task<IdentityResult> UpdateAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
{
user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow;
await _userRepository.ReplaceAsync(user);
return IdentityResult.Success;
}
public Task SetTwoFactorEnabledAsync(User user, bool enabled, CancellationToken cancellationToken)
{
// Do nothing...
return Task.FromResult(0);
}
public async Task<bool> GetTwoFactorEnabledAsync(User user, CancellationToken cancellationToken)
{
return await _serviceProvider.GetRequiredService<IUserService>().TwoFactorIsEnabledAsync(user);
}
public Task SetSecurityStampAsync(User user, string stamp, CancellationToken cancellationToken)
{
user.SecurityStamp = stamp;
return Task.FromResult(0);
}
public Task<string> GetSecurityStampAsync(User user, CancellationToken cancellationToken)
{
return Task.FromResult(user.SecurityStamp);
}
}

View File

@ -0,0 +1,156 @@
using System.Text.Json;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Auth.Identity;
public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider<User>
{
private readonly IServiceProvider _serviceProvider;
private readonly IFido2 _fido2;
private readonly GlobalSettings _globalSettings;
public WebAuthnTokenProvider(IServiceProvider serviceProvider, IFido2 fido2, GlobalSettings globalSettings)
{
_serviceProvider = serviceProvider;
_fido2 = fido2;
_globalSettings = globalSettings;
}
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
if (!(await userService.CanAccessPremium(user)))
{
return false;
}
var webAuthnProvider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn);
if (!HasProperMetaData(webAuthnProvider))
{
return false;
}
return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.WebAuthn, user);
}
public async Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
if (!(await userService.CanAccessPremium(user)))
{
return null;
}
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn);
var keys = LoadKeys(provider);
var existingCredentials = keys.Select(key => key.Item2.Descriptor).ToList();
if (existingCredentials.Count == 0)
{
return null;
}
var exts = new AuthenticationExtensionsClientInputs()
{
UserVerificationMethod = true,
AppID = CoreHelpers.U2fAppIdUrl(_globalSettings),
};
var options = _fido2.GetAssertionOptions(existingCredentials, UserVerificationRequirement.Discouraged, exts);
// TODO: Remove this when newtonsoft legacy converters are gone
provider.MetaData["login"] = JsonSerializer.Serialize(options);
var providers = user.GetTwoFactorProviders();
providers[TwoFactorProviderType.WebAuthn] = provider;
user.SetTwoFactorProviders(providers);
await userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn, logEvent: false);
return options.ToJson();
}
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
if (!(await userService.CanAccessPremium(user)) || string.IsNullOrWhiteSpace(token))
{
return false;
}
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn);
var keys = LoadKeys(provider);
if (!provider.MetaData.ContainsKey("login"))
{
return false;
}
var clientResponse = JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(token,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
var jsonOptions = provider.MetaData["login"].ToString();
var options = AssertionOptions.FromJson(jsonOptions);
var webAuthCred = keys.Find(k => k.Item2.Descriptor.Id.SequenceEqual(clientResponse.Id));
if (webAuthCred == null)
{
return false;
}
// Callback to check user ownership of credential. Always return true since we have already
// established ownership in this context.
IsUserHandleOwnerOfCredentialIdAsync callback = (args, cancellationToken) => Task.FromResult(true);
var res = await _fido2.MakeAssertionAsync(clientResponse, options, webAuthCred.Item2.PublicKey, webAuthCred.Item2.SignatureCounter, callback);
provider.MetaData.Remove("login");
// Update SignatureCounter
webAuthCred.Item2.SignatureCounter = res.Counter;
var providers = user.GetTwoFactorProviders();
providers[TwoFactorProviderType.WebAuthn].MetaData[webAuthCred.Item1] = webAuthCred.Item2;
user.SetTwoFactorProviders(providers);
await userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn, logEvent: false);
return res.Status == "ok";
}
private bool HasProperMetaData(TwoFactorProvider provider)
{
return provider?.MetaData?.Any() ?? false;
}
private List<Tuple<string, TwoFactorProvider.WebAuthnData>> LoadKeys(TwoFactorProvider provider)
{
var keys = new List<Tuple<string, TwoFactorProvider.WebAuthnData>>();
if (!HasProperMetaData(provider))
{
return keys;
}
// Support up to 5 keys
for (var i = 1; i <= 5; i++)
{
var keyName = $"Key{i}";
if (provider.MetaData.ContainsKey(keyName))
{
var key = new TwoFactorProvider.WebAuthnData((dynamic)provider.MetaData[keyName]);
keys.Add(new Tuple<string, TwoFactorProvider.WebAuthnData>(keyName, key));
}
}
return keys;
}
}

View File

@ -0,0 +1,75 @@
using Bit.Core.Auth.Enums;
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Core.Settings;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using YubicoDotNetClient;
namespace Bit.Core.Auth.Identity;
public class YubicoOtpTokenProvider : IUserTwoFactorTokenProvider<User>
{
private readonly IServiceProvider _serviceProvider;
private readonly GlobalSettings _globalSettings;
public YubicoOtpTokenProvider(
IServiceProvider serviceProvider,
GlobalSettings globalSettings)
{
_serviceProvider = serviceProvider;
_globalSettings = globalSettings;
}
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
if (!(await userService.CanAccessPremium(user)))
{
return false;
}
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.YubiKey);
if (!provider?.MetaData.Values.Any(v => !string.IsNullOrWhiteSpace((string)v)) ?? true)
{
return false;
}
return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.YubiKey, user);
}
public Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
{
return Task.FromResult<string>(null);
}
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
if (!(await userService.CanAccessPremium(user)))
{
return false;
}
if (string.IsNullOrWhiteSpace(token) || token.Length < 32 || token.Length > 48)
{
return false;
}
var id = token.Substring(0, 12);
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.YubiKey);
if (!provider.MetaData.ContainsValue(id))
{
return false;
}
var client = new YubicoClient(_globalSettings.Yubico.ClientId, _globalSettings.Yubico.Key);
if (_globalSettings.Yubico.ValidationUrls != null && _globalSettings.Yubico.ValidationUrls.Length > 0)
{
client.SetUrls(_globalSettings.Yubico.ValidationUrls);
}
var response = await client.VerifyAsync(token);
return response.Status == YubicoResponseStatus.Ok;
}
}

View File

@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Http;
namespace Bit.Core.Auth.IdentityServer;
public static class TokenRetrieval
{
private static string _headerScheme = "Bearer ";
private static string _queuryScheme = "access_token";
private static string _authHeader = "Authorization";
public static Func<HttpRequest, string> FromAuthorizationHeaderOrQueryString()
{
return (request) =>
{
var authorization = request.Headers[_authHeader].FirstOrDefault();
if (string.IsNullOrWhiteSpace(authorization))
{
return request.Query[_queuryScheme].FirstOrDefault();
}
if (authorization.StartsWith(_headerScheme, StringComparison.OrdinalIgnoreCase))
{
return authorization.Substring(_headerScheme.Length).Trim();
}
return null;
};
}
}

View File

@ -0,0 +1,14 @@
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin;
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Auth.LoginFeatures;
public static class LoginServiceCollectionExtensions
{
public static void AddLoginServices(this IServiceCollection services)
{
services.AddScoped<IVerifyAuthRequestCommand, VerifyAuthRequestCommand>();
}
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
public interface IVerifyAuthRequestCommand
{
Task<bool> VerifyAuthRequestAsync(Guid authRequestId, string accessCode);
}

View File

@ -0,0 +1,25 @@
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
namespace Bit.Core.Auth.LoginFeatures.PasswordlessLogin;
public class VerifyAuthRequestCommand : IVerifyAuthRequestCommand
{
private readonly IAuthRequestRepository _authRequestRepository;
public VerifyAuthRequestCommand(IAuthRequestRepository authRequestRepository)
{
_authRequestRepository = authRequestRepository;
}
public async Task<bool> VerifyAuthRequestAsync(Guid authRequestId, string accessCode)
{
var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId);
if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, accessCode))
{
return false;
}
return true;
}
}

View File

@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Entities;
namespace Bit.Core.Auth.Models.Api.Request.Accounts;
public class KeysRequestModel
{
public string PublicKey { get; set; }
[Required]
public string EncryptedPrivateKey { get; set; }
public User ToUser(User existingUser)
{
if (string.IsNullOrWhiteSpace(existingUser.PublicKey) && !string.IsNullOrWhiteSpace(PublicKey))
{
existingUser.PublicKey = PublicKey;
}
if (string.IsNullOrWhiteSpace(existingUser.PrivateKey))
{
existingUser.PrivateKey = EncryptedPrivateKey;
}
return existingUser;
}
}

View File

@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Core.Auth.Models.Api.Request.Accounts;
public class PreloginRequestModel
{
[Required]
[EmailAddress]
[StringLength(256)]
public string Email { get; set; }
}

View File

@ -0,0 +1,73 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.Auth.Models.Api.Request.Accounts;
public class RegisterRequestModel : IValidatableObject, ICaptchaProtectedModel
{
[StringLength(50)]
public string Name { get; set; }
[Required]
[StrictEmailAddress]
[StringLength(256)]
public string Email { get; set; }
[Required]
[StringLength(1000)]
public string MasterPasswordHash { get; set; }
[StringLength(50)]
public string MasterPasswordHint { get; set; }
public string CaptchaResponse { get; set; }
public string Key { get; set; }
public KeysRequestModel Keys { get; set; }
public string Token { get; set; }
public Guid? OrganizationUserId { get; set; }
public KdfType? Kdf { get; set; }
public int? KdfIterations { get; set; }
public int? KdfMemory { get; set; }
public int? KdfParallelism { get; set; }
public Dictionary<string, object> ReferenceData { get; set; }
public User ToUser()
{
var user = new User
{
Name = Name,
Email = Email,
MasterPasswordHint = MasterPasswordHint,
Kdf = Kdf.GetValueOrDefault(KdfType.PBKDF2_SHA256),
KdfIterations = KdfIterations.GetValueOrDefault(5000),
KdfMemory = KdfMemory,
KdfParallelism = KdfParallelism
};
if (ReferenceData != null)
{
user.ReferenceData = JsonSerializer.Serialize(ReferenceData);
}
if (Key != null)
{
user.Key = Key;
}
if (Keys != null)
{
Keys.ToUser(user);
}
return user;
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Kdf.HasValue && KdfIterations.HasValue)
{
return KdfSettingsValidator.Validate(Kdf.Value, KdfIterations.Value, KdfMemory, KdfParallelism);
}
return Enumerable.Empty<ValidationResult>();
}
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Auth.Models.Api;
public interface ICaptchaProtectedModel
{
string CaptchaResponse { get; set; }
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Auth.Models.Api.Response.Accounts;
public interface ICaptchaProtectedResponseModel
{
public string CaptchaBypassToken { get; set; }
}

View File

@ -0,0 +1,20 @@
using Bit.Core.Enums;
using Bit.Core.Models.Data;
namespace Bit.Core.Auth.Models.Api.Response.Accounts;
public class PreloginResponseModel
{
public PreloginResponseModel(UserKdfInformation kdfInformation)
{
Kdf = kdfInformation.Kdf;
KdfIterations = kdfInformation.KdfIterations;
KdfMemory = kdfInformation.KdfMemory;
KdfParallelism = kdfInformation.KdfParallelism;
}
public KdfType Kdf { get; set; }
public int KdfIterations { get; set; }
public int? KdfMemory { get; set; }
public int? KdfParallelism { get; set; }
}

View File

@ -0,0 +1,14 @@
using Bit.Core.Models.Api;
namespace Bit.Core.Auth.Models.Api.Response.Accounts;
public class RegisterResponseModel : ResponseModel, ICaptchaProtectedResponseModel
{
public RegisterResponseModel(string captchaBypassToken)
: base("register")
{
CaptchaBypassToken = captchaBypassToken;
}
public string CaptchaBypassToken { get; set; }
}

View File

@ -0,0 +1,9 @@
namespace Bit.Core.Auth.Models.Business;
public class CaptchaResponse
{
public bool Success { get; set; }
public bool MaybeBot { get; set; }
public bool IsBot { get; set; }
public double Score { get; set; }
}

View File

@ -0,0 +1,13 @@
namespace Bit.Core.Auth.Models.Business;
public class ExpiringToken
{
public readonly string Token;
public readonly DateTime ExpirationDate;
public ExpiringToken(string token, DateTime expirationDate)
{
Token = token;
ExpirationDate = expirationDate;
}
}

View File

@ -0,0 +1,35 @@
using System.Text.Json.Serialization;
using Bit.Core.Auth.Entities;
namespace Bit.Core.Auth.Models.Business.Tokenables;
public class EmergencyAccessInviteTokenable : Tokens.ExpiringTokenable
{
public const string ClearTextPrefix = "";
public const string DataProtectorPurpose = "EmergencyAccessServiceDataProtector";
public const string TokenIdentifier = "EmergencyAccessInvite";
public string Identifier { get; set; } = TokenIdentifier;
public Guid Id { get; set; }
public string Email { get; set; }
[JsonConstructor]
public EmergencyAccessInviteTokenable(DateTime expirationDate)
{
ExpirationDate = expirationDate;
}
public EmergencyAccessInviteTokenable(EmergencyAccess user, int hoursTillExpiration)
{
Id = user.Id;
Email = user.Email;
ExpirationDate = DateTime.UtcNow.AddHours(hoursTillExpiration);
}
public bool IsValid(Guid id, string email)
{
return Id == id &&
Email.Equals(email, StringComparison.InvariantCultureIgnoreCase);
}
protected override bool TokenIsValid() => Identifier == TokenIdentifier && Id != default && !string.IsNullOrWhiteSpace(Email);
}

View File

@ -0,0 +1,43 @@
using System.Text.Json.Serialization;
using Bit.Core.Entities;
using Bit.Core.Tokens;
namespace Bit.Core.Auth.Models.Business.Tokenables;
public class HCaptchaTokenable : ExpiringTokenable
{
private const double _tokenLifetimeInHours = (double)5 / 60; // 5 minutes
public const string ClearTextPrefix = "BWCaptchaBypass_";
public const string DataProtectorPurpose = "CaptchaServiceDataProtector";
public const string TokenIdentifier = "CaptchaBypassToken";
public string Identifier { get; set; } = TokenIdentifier;
public Guid Id { get; set; }
public string Email { get; set; }
[JsonConstructor]
public HCaptchaTokenable()
{
ExpirationDate = DateTime.UtcNow.AddHours(_tokenLifetimeInHours);
}
public HCaptchaTokenable(User user) : this()
{
Id = user?.Id ?? default;
Email = user?.Email;
}
public bool TokenIsValid(User user)
{
if (Id == default || Email == default || user == null)
{
return false;
}
return Id == user.Id &&
Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase);
}
// Validates deserialized
protected override bool TokenIsValid() => Identifier == TokenIdentifier && Id != default && !string.IsNullOrWhiteSpace(Email);
}

View File

@ -0,0 +1,43 @@
using System.Text.Json.Serialization;
using Bit.Core.Entities;
using Bit.Core.Tokens;
namespace Bit.Core.Auth.Models.Business.Tokenables;
public class SsoTokenable : ExpiringTokenable
{
public const string ClearTextPrefix = "BWUserPrefix_";
public const string DataProtectorPurpose = "SsoTokenDataProtector";
public const string TokenIdentifier = "ssoToken";
public Guid OrganizationId { get; set; }
public string DomainHint { get; set; }
public string Identifier { get; set; } = TokenIdentifier;
[JsonConstructor]
public SsoTokenable() { }
public SsoTokenable(Organization organization, double tokenLifetimeInSeconds) : this()
{
OrganizationId = organization?.Id ?? default;
DomainHint = organization?.Identifier;
ExpirationDate = DateTime.UtcNow.AddSeconds(tokenLifetimeInSeconds);
}
public bool TokenIsValid(Organization organization)
{
if (OrganizationId == default || DomainHint == default || organization == null || !Valid)
{
return false;
}
return organization.Identifier.Equals(DomainHint, StringComparison.InvariantCultureIgnoreCase)
&& organization.Id.Equals(OrganizationId);
}
// Validates deserialized
protected override bool TokenIsValid() =>
Identifier == TokenIdentifier
&& OrganizationId != default
&& !string.IsNullOrWhiteSpace(DomainHint);
}

View File

@ -0,0 +1,14 @@

using Bit.Core.Auth.Entities;
namespace Bit.Core.Auth.Models.Data;
public class EmergencyAccessDetails : EmergencyAccess
{
public string GranteeName { get; set; }
public string GranteeEmail { get; set; }
public string GranteeAvatarColor { get; set; }
public string GrantorName { get; set; }
public string GrantorEmail { get; set; }
public string GrantorAvatarColor { get; set; }
}

View File

@ -0,0 +1,11 @@

using Bit.Core.Auth.Entities;
namespace Bit.Core.Auth.Models.Data;
public class EmergencyAccessNotify : EmergencyAccess
{
public string GrantorEmail { get; set; }
public string GranteeName { get; set; }
public string GranteeEmail { get; set; }
}

View File

@ -0,0 +1,10 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Vault.Models.Data;
namespace Bit.Core.Auth.Models.Data;
public class EmergencyAccessViewData
{
public EmergencyAccess EmergencyAccess { get; set; }
public IEnumerable<CipherDetails> Ciphers { get; set; }
}

View File

@ -0,0 +1,125 @@
using Bit.Core.Auth.Enums;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
namespace Bit.Core.Auth.Models.Data;
public class SsoConfigurationData
{
private static string _oidcSigninPath = "/oidc-signin";
private static string _oidcSignedOutPath = "/oidc-signedout";
private static string _saml2ModulePath = "/saml2";
public static SsoConfigurationData Deserialize(string data)
{
return CoreHelpers.LoadClassFromJsonData<SsoConfigurationData>(data);
}
public string Serialize()
{
return CoreHelpers.ClassToJsonData(this);
}
public SsoType ConfigType { get; set; }
public bool KeyConnectorEnabled { get; set; }
public string KeyConnectorUrl { get; set; }
// OIDC
public string Authority { get; set; }
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string MetadataAddress { get; set; }
public OpenIdConnectRedirectBehavior RedirectBehavior { get; set; }
public bool GetClaimsFromUserInfoEndpoint { get; set; }
public string AdditionalScopes { get; set; }
public string AdditionalUserIdClaimTypes { get; set; }
public string AdditionalEmailClaimTypes { get; set; }
public string AdditionalNameClaimTypes { get; set; }
public string AcrValues { get; set; }
public string ExpectedReturnAcrValue { get; set; }
// SAML2 IDP
public string IdpEntityId { get; set; }
public string IdpSingleSignOnServiceUrl { get; set; }
public string IdpSingleLogoutServiceUrl { get; set; }
public string IdpX509PublicCert { get; set; }
public Saml2BindingType IdpBindingType { get; set; }
public bool IdpAllowUnsolicitedAuthnResponse { get; set; }
public string IdpArtifactResolutionServiceUrl { get => null; set { /*IGNORE*/ } }
public bool IdpDisableOutboundLogoutRequests { get; set; }
public string IdpOutboundSigningAlgorithm { get; set; }
public bool IdpWantAuthnRequestsSigned { get; set; }
// SAML2 SP
public Saml2NameIdFormat SpNameIdFormat { get; set; }
public string SpOutboundSigningAlgorithm { get; set; }
public Saml2SigningBehavior SpSigningBehavior { get; set; }
public bool SpWantAssertionsSigned { get; set; }
public bool SpValidateCertificates { get; set; }
public string SpMinIncomingSigningAlgorithm { get; set; }
public static string BuildCallbackPath(string ssoUri = null)
{
return BuildSsoUrl(_oidcSigninPath, ssoUri);
}
public static string BuildSignedOutCallbackPath(string ssoUri = null)
{
return BuildSsoUrl(_oidcSignedOutPath, ssoUri);
}
public static string BuildSaml2ModulePath(string ssoUri = null, string scheme = null)
{
return string.Concat(BuildSsoUrl(_saml2ModulePath, ssoUri),
string.IsNullOrWhiteSpace(scheme) ? string.Empty : $"/{scheme}");
}
public static string BuildSaml2AcsUrl(string ssoUri = null, string scheme = null)
{
return string.Concat(BuildSaml2ModulePath(ssoUri, scheme), "/Acs");
}
public static string BuildSaml2MetadataUrl(string ssoUri = null, string scheme = null)
{
return BuildSaml2ModulePath(ssoUri, scheme);
}
public IEnumerable<string> GetAdditionalScopes() => AdditionalScopes?
.Split(',')?
.Where(c => !string.IsNullOrWhiteSpace(c))?
.Select(c => c.Trim()) ??
Array.Empty<string>();
public IEnumerable<string> GetAdditionalUserIdClaimTypes() => AdditionalUserIdClaimTypes?
.Split(',')?
.Where(c => !string.IsNullOrWhiteSpace(c))?
.Select(c => c.Trim()) ??
Array.Empty<string>();
public IEnumerable<string> GetAdditionalEmailClaimTypes() => AdditionalEmailClaimTypes?
.Split(',')?
.Where(c => !string.IsNullOrWhiteSpace(c))?
.Select(c => c.Trim()) ??
Array.Empty<string>();
public IEnumerable<string> GetAdditionalNameClaimTypes() => AdditionalNameClaimTypes?
.Split(',')?
.Where(c => !string.IsNullOrWhiteSpace(c))?
.Select(c => c.Trim()) ??
Array.Empty<string>();
private static string BuildSsoUrl(string relativePath, string ssoUri)
{
if (string.IsNullOrWhiteSpace(ssoUri) ||
!Uri.IsWellFormedUriString(ssoUri, UriKind.Absolute))
{
return relativePath;
}
if (Uri.TryCreate(string.Concat(ssoUri.TrimEnd('/'), relativePath), UriKind.Absolute, out var newUri))
{
return newUri.ToString();
}
return relativePath;
}
}

View File

@ -0,0 +1,11 @@
using Bit.Core.Auth.Enums;
namespace Bit.Core.Auth.Models;
public interface ITwoFactorProvidersUser
{
string TwoFactorProviders { get; }
Dictionary<TwoFactorProviderType, TwoFactorProvider> GetTwoFactorProviders();
Guid? GetUserId();
bool GetPremium();
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Models.Mail;
public class EmergencyAccessAcceptedViewModel : BaseMailModel
{
public string GranteeEmail { get; set; }
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Models.Mail;
public class EmergencyAccessApprovedViewModel : BaseMailModel
{
public string Name { get; set; }
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Models.Mail;
public class EmergencyAccessConfirmedViewModel : BaseMailModel
{
public string Name { get; set; }
}

View File

@ -0,0 +1,10 @@
namespace Bit.Core.Models.Mail;
public class EmergencyAccessInvitedViewModel : BaseMailModel
{
public string Name { get; set; }
public string Id { get; set; }
public string Email { get; set; }
public string Token { get; set; }
public string Url => $"{WebVaultUrl}/accept-emergency?id={Id}&name={Name}&email={Email}&token={Token}";
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Models.Mail;
public class EmergencyAccessRecoveryTimedOutViewModel : BaseMailModel
{
public string Name { get; set; }
public string Action { get; set; }
}

View File

@ -0,0 +1,8 @@
namespace Bit.Core.Models.Mail;
public class EmergencyAccessRecoveryViewModel : BaseMailModel
{
public string Name { get; set; }
public string Action { get; set; }
public int DaysLeft { get; set; }
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Models.Mail;
public class EmergencyAccessRejectedViewModel : BaseMailModel
{
public string Name { get; set; }
}

View File

@ -0,0 +1,8 @@
using Bit.Core.Models.Mail;
namespace Bit.Core.Auth.Models.Mail;
public class FailedAuthAttemptsModel : NewDeviceLoggedInModel
{
public string AffectedEmail { get; set; }
}

View File

@ -0,0 +1,8 @@
using Bit.Core.Models.Mail;
namespace Bit.Core.Auth.Models.Mail;
public class MasterPasswordHintViewModel : BaseMailModel
{
public string Hint { get; set; }
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Auth.Models.Mail;
public class PasswordlessSignInModel
{
public string Url { get; set; }
}

View File

@ -0,0 +1,11 @@
using Bit.Core.Models.Mail;
namespace Bit.Core.Auth.Models.Mail;
public class RecoverTwoFactorModel : BaseMailModel
{
public string TheDate { get; set; }
public string TheTime { get; set; }
public string TimeZone { get; set; }
public string IpAddress { get; set; }
}

View File

@ -0,0 +1,17 @@
using Bit.Core.Models.Mail;
namespace Bit.Core.Auth.Models.Mail;
public class VerifyDeleteModel : BaseMailModel
{
public string Url => string.Format("{0}/verify-recover-delete?userId={1}&token={2}&email={3}",
WebVaultUrl,
UserId,
Token,
EmailEncoded);
public Guid UserId { get; set; }
public string Email { get; set; }
public string EmailEncoded { get; set; }
public string Token { get; set; }
}

View File

@ -0,0 +1,14 @@
using Bit.Core.Models.Mail;
namespace Bit.Core.Auth.Models.Mail;
public class VerifyEmailModel : BaseMailModel
{
public string Url => string.Format("{0}/verify-email?userId={1}&token={2}",
WebVaultUrl,
UserId,
Token);
public Guid UserId { get; set; }
public string Token { get; set; }
}

View File

@ -0,0 +1,66 @@
using System.Text.Json;
using Bit.Core.Auth.Enums;
using Fido2NetLib.Objects;
namespace Bit.Core.Auth.Models;
public class TwoFactorProvider
{
public bool Enabled { get; set; }
public Dictionary<string, object> MetaData { get; set; } = new Dictionary<string, object>();
public class WebAuthnData
{
public WebAuthnData() { }
public WebAuthnData(dynamic o)
{
Name = o.Name;
try
{
Descriptor = o.Descriptor;
}
catch
{
// Fallback for older newtonsoft serialized tokens.
if (o.Descriptor.Type == 0)
{
o.Descriptor.Type = "public-key";
}
Descriptor = JsonSerializer.Deserialize<PublicKeyCredentialDescriptor>(o.Descriptor.ToString(),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
PublicKey = o.PublicKey;
UserHandle = o.UserHandle;
SignatureCounter = o.SignatureCounter;
CredType = o.CredType;
RegDate = o.RegDate;
AaGuid = o.AaGuid;
Migrated = o.Migrated;
}
public string Name { get; set; }
public PublicKeyCredentialDescriptor Descriptor { get; internal set; }
public byte[] PublicKey { get; internal set; }
public byte[] UserHandle { get; internal set; }
public uint SignatureCounter { get; set; }
public string CredType { get; internal set; }
public DateTime RegDate { get; internal set; }
public Guid AaGuid { get; internal set; }
public bool Migrated { get; internal set; }
}
public static bool RequiresPremium(TwoFactorProviderType type)
{
switch (type)
{
case TwoFactorProviderType.Duo:
case TwoFactorProviderType.YubiKey:
case TwoFactorProviderType.U2f: // Keep to ensure old U2f keys are considered premium
case TwoFactorProviderType.WebAuthn:
return true;
default:
return false;
}
}
}

View File

@ -0,0 +1,9 @@
using Bit.Core.Auth.Entities;
namespace Bit.Core.Repositories;
public interface IAuthRequestRepository : IRepository<AuthRequest, Guid>
{
Task<int> DeleteExpiredAsync();
Task<ICollection<AuthRequest>> GetManyByUserIdAsync(Guid userId);
}

View File

@ -0,0 +1,14 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Data;
namespace Bit.Core.Repositories;
public interface IEmergencyAccessRepository : IRepository<EmergencyAccess, Guid>
{
Task<int> GetCountByGrantorIdEmailAsync(Guid grantorId, string email, bool onlyRegisteredUsers);
Task<ICollection<EmergencyAccessDetails>> GetManyDetailsByGrantorIdAsync(Guid grantorId);
Task<ICollection<EmergencyAccessDetails>> GetManyDetailsByGranteeIdAsync(Guid granteeId);
Task<EmergencyAccessDetails> GetDetailsByIdGrantorIdAsync(Guid id, Guid grantorId);
Task<ICollection<EmergencyAccessNotify>> GetManyToNotifyAsync();
Task<ICollection<EmergencyAccessDetails>> GetExpiredRecoveriesAsync();
}

View File

@ -0,0 +1,12 @@
using Bit.Core.Auth.Entities;
namespace Bit.Core.Auth.Repositories;
public interface IGrantRepository
{
Task<Grant> GetByKeyAsync(string key);
Task<ICollection<Grant>> GetManyAsync(string subjectId, string sessionId, string clientId, string type);
Task SaveAsync(Grant obj);
Task DeleteByKeyAsync(string key);
Task DeleteManyAsync(string subjectId, string sessionId, string clientId, string type);
}

View File

@ -0,0 +1,11 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Repositories;
namespace Bit.Core.Auth.Repositories;
public interface ISsoConfigRepository : IRepository<SsoConfig, long>
{
Task<SsoConfig> GetByOrganizationIdAsync(Guid organizationId);
Task<SsoConfig> GetByIdentifierAsync(string identifier);
Task<ICollection<SsoConfig>> GetManyByRevisionNotBeforeDate(DateTime? notBefore);
}

View File

@ -0,0 +1,10 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Repositories;
namespace Bit.Core.Auth.Repositories;
public interface ISsoUserRepository : IRepository<SsoUser, long>
{
Task DeleteAsync(Guid userId, Guid? organizationId);
Task<SsoUser> GetByUserIdOrganizationIdAsync(Guid organizationId, Guid userId);
}

View File

@ -0,0 +1,15 @@
using Bit.Core.Auth.Models.Business;
using Bit.Core.Context;
using Bit.Core.Entities;
namespace Bit.Core.Auth.Services;
public interface ICaptchaValidationService
{
string SiteKey { get; }
string SiteKeyResponseKeyName { get; }
bool RequireCaptchaValidation(ICurrentContext currentContext, User user = null);
Task<CaptchaResponse> ValidateCaptchaResponseAsync(string captchResponse, string clientIpAddress,
User user = null);
string GenerateCaptchaBypassToken(User user);
}

View File

@ -0,0 +1,29 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Core.Vault.Models.Data;
namespace Bit.Core.Auth.Services;
public interface IEmergencyAccessService
{
Task<EmergencyAccess> InviteAsync(User invitingUser, string email, EmergencyAccessType type, int waitTime);
Task ResendInviteAsync(User invitingUser, Guid emergencyAccessId);
Task<EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User user, string token, IUserService userService);
Task DeleteAsync(Guid emergencyAccessId, Guid grantorId);
Task<EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId);
Task<EmergencyAccessDetails> GetAsync(Guid emergencyAccessId, Guid userId);
Task SaveAsync(EmergencyAccess emergencyAccess, User savingUser);
Task InitiateAsync(Guid id, User initiatingUser);
Task ApproveAsync(Guid id, User approvingUser);
Task RejectAsync(Guid id, User rejectingUser);
Task<ICollection<Policy>> GetPoliciesAsync(Guid id, User requestingUser);
Task<(EmergencyAccess, User)> TakeoverAsync(Guid id, User initiatingUser);
Task PasswordAsync(Guid id, User user, string newMasterPasswordHash, string key);
Task SendNotificationsAsync();
Task HandleTimedOutRequestsAsync();
Task<EmergencyAccessViewData> ViewAsync(Guid id, User user);
Task<AttachmentResponseData> GetAttachmentDownloadAsync(Guid id, Guid cipherId, string attachmentId, User user);
}

View File

@ -0,0 +1,9 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Entities;
namespace Bit.Core.Auth.Services;
public interface ISsoConfigService
{
Task SaveAsync(SsoConfig config, Organization organization);
}

View File

@ -0,0 +1,428 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Vault.Models.Data;
using Bit.Core.Vault.Repositories;
using Bit.Core.Vault.Services;
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.Auth.Services;
public class EmergencyAccessService : IEmergencyAccessService
{
private readonly IEmergencyAccessRepository _emergencyAccessRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IUserRepository _userRepository;
private readonly ICipherRepository _cipherRepository;
private readonly IPolicyRepository _policyRepository;
private readonly ICipherService _cipherService;
private readonly IMailService _mailService;
private readonly IUserService _userService;
private readonly GlobalSettings _globalSettings;
private readonly IPasswordHasher<User> _passwordHasher;
private readonly IOrganizationService _organizationService;
private readonly IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> _dataProtectorTokenizer;
public EmergencyAccessService(
IEmergencyAccessRepository emergencyAccessRepository,
IOrganizationUserRepository organizationUserRepository,
IUserRepository userRepository,
ICipherRepository cipherRepository,
IPolicyRepository policyRepository,
ICipherService cipherService,
IMailService mailService,
IUserService userService,
IPasswordHasher<User> passwordHasher,
GlobalSettings globalSettings,
IOrganizationService organizationService,
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> dataProtectorTokenizer)
{
_emergencyAccessRepository = emergencyAccessRepository;
_organizationUserRepository = organizationUserRepository;
_userRepository = userRepository;
_cipherRepository = cipherRepository;
_policyRepository = policyRepository;
_cipherService = cipherService;
_mailService = mailService;
_userService = userService;
_passwordHasher = passwordHasher;
_globalSettings = globalSettings;
_organizationService = organizationService;
_dataProtectorTokenizer = dataProtectorTokenizer;
}
public async Task<EmergencyAccess> InviteAsync(User invitingUser, string email, EmergencyAccessType type, int waitTime)
{
if (!await _userService.CanAccessPremium(invitingUser))
{
throw new BadRequestException("Not a premium user.");
}
if (type == EmergencyAccessType.Takeover && invitingUser.UsesKeyConnector)
{
throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector.");
}
var emergencyAccess = new EmergencyAccess
{
GrantorId = invitingUser.Id,
Email = email.ToLowerInvariant(),
Status = EmergencyAccessStatusType.Invited,
Type = type,
WaitTimeDays = waitTime,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
};
await _emergencyAccessRepository.CreateAsync(emergencyAccess);
await SendInviteAsync(emergencyAccess, NameOrEmail(invitingUser));
return emergencyAccess;
}
public async Task<EmergencyAccessDetails> GetAsync(Guid emergencyAccessId, Guid userId)
{
var emergencyAccess = await _emergencyAccessRepository.GetDetailsByIdGrantorIdAsync(emergencyAccessId, userId);
if (emergencyAccess == null)
{
throw new BadRequestException("Emergency Access not valid.");
}
return emergencyAccess;
}
public async Task ResendInviteAsync(User invitingUser, Guid emergencyAccessId)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (emergencyAccess == null || emergencyAccess.GrantorId != invitingUser.Id ||
emergencyAccess.Status != EmergencyAccessStatusType.Invited)
{
throw new BadRequestException("Emergency Access not valid.");
}
await SendInviteAsync(emergencyAccess, NameOrEmail(invitingUser));
}
public async Task<EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User user, string token, IUserService userService)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (emergencyAccess == null)
{
throw new BadRequestException("Emergency Access not valid.");
}
if (!_dataProtectorTokenizer.TryUnprotect(token, out var data) && data.IsValid(emergencyAccessId, user.Email))
{
throw new BadRequestException("Invalid token.");
}
if (emergencyAccess.Status == EmergencyAccessStatusType.Accepted)
{
throw new BadRequestException("Invitation already accepted. You will receive an email when the grantor confirms you as an emergency access contact.");
}
else if (emergencyAccess.Status != EmergencyAccessStatusType.Invited)
{
throw new BadRequestException("Invitation already accepted.");
}
if (string.IsNullOrWhiteSpace(emergencyAccess.Email) ||
!emergencyAccess.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase))
{
throw new BadRequestException("User email does not match invite.");
}
var granteeEmail = emergencyAccess.Email;
emergencyAccess.Status = EmergencyAccessStatusType.Accepted;
emergencyAccess.GranteeId = user.Id;
emergencyAccess.Email = null;
var grantor = await userService.GetUserByIdAsync(emergencyAccess.GrantorId);
await _emergencyAccessRepository.ReplaceAsync(emergencyAccess);
await _mailService.SendEmergencyAccessAcceptedEmailAsync(granteeEmail, grantor.Email);
return emergencyAccess;
}
public async Task DeleteAsync(Guid emergencyAccessId, Guid grantorId)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (emergencyAccess == null || (emergencyAccess.GrantorId != grantorId && emergencyAccess.GranteeId != grantorId))
{
throw new BadRequestException("Emergency Access not valid.");
}
await _emergencyAccessRepository.DeleteAsync(emergencyAccess);
}
public async Task<EmergencyAccess> ConfirmUserAsync(Guid emergencyAcccessId, string key, Guid confirmingUserId)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAcccessId);
if (emergencyAccess == null || emergencyAccess.Status != EmergencyAccessStatusType.Accepted ||
emergencyAccess.GrantorId != confirmingUserId)
{
throw new BadRequestException("Emergency Access not valid.");
}
var grantor = await _userRepository.GetByIdAsync(confirmingUserId);
if (emergencyAccess.Type == EmergencyAccessType.Takeover && grantor.UsesKeyConnector)
{
throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector.");
}
var grantee = await _userRepository.GetByIdAsync(emergencyAccess.GranteeId.Value);
emergencyAccess.Status = EmergencyAccessStatusType.Confirmed;
emergencyAccess.KeyEncrypted = key;
emergencyAccess.Email = null;
await _emergencyAccessRepository.ReplaceAsync(emergencyAccess);
await _mailService.SendEmergencyAccessConfirmedEmailAsync(NameOrEmail(grantor), grantee.Email);
return emergencyAccess;
}
public async Task SaveAsync(EmergencyAccess emergencyAccess, User savingUser)
{
if (!await _userService.CanAccessPremium(savingUser))
{
throw new BadRequestException("Not a premium user.");
}
if (emergencyAccess.GrantorId != savingUser.Id)
{
throw new BadRequestException("Emergency Access not valid.");
}
if (emergencyAccess.Type == EmergencyAccessType.Takeover)
{
var grantor = await _userService.GetUserByIdAsync(emergencyAccess.GrantorId);
if (grantor.UsesKeyConnector)
{
throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector.");
}
}
await _emergencyAccessRepository.ReplaceAsync(emergencyAccess);
}
public async Task InitiateAsync(Guid id, User initiatingUser)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
if (emergencyAccess == null || emergencyAccess.GranteeId != initiatingUser.Id ||
emergencyAccess.Status != EmergencyAccessStatusType.Confirmed)
{
throw new BadRequestException("Emergency Access not valid.");
}
var grantor = await _userRepository.GetByIdAsync(emergencyAccess.GrantorId);
if (emergencyAccess.Type == EmergencyAccessType.Takeover && grantor.UsesKeyConnector)
{
throw new BadRequestException("You cannot takeover an account that is using Key Connector.");
}
var now = DateTime.UtcNow;
emergencyAccess.Status = EmergencyAccessStatusType.RecoveryInitiated;
emergencyAccess.RevisionDate = now;
emergencyAccess.RecoveryInitiatedDate = now;
emergencyAccess.LastNotificationDate = now;
await _emergencyAccessRepository.ReplaceAsync(emergencyAccess);
await _mailService.SendEmergencyAccessRecoveryInitiated(emergencyAccess, NameOrEmail(initiatingUser), grantor.Email);
}
public async Task ApproveAsync(Guid id, User approvingUser)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
if (emergencyAccess == null || emergencyAccess.GrantorId != approvingUser.Id ||
emergencyAccess.Status != EmergencyAccessStatusType.RecoveryInitiated)
{
throw new BadRequestException("Emergency Access not valid.");
}
emergencyAccess.Status = EmergencyAccessStatusType.RecoveryApproved;
await _emergencyAccessRepository.ReplaceAsync(emergencyAccess);
var grantee = await _userRepository.GetByIdAsync(emergencyAccess.GranteeId.Value);
await _mailService.SendEmergencyAccessRecoveryApproved(emergencyAccess, NameOrEmail(approvingUser), grantee.Email);
}
public async Task RejectAsync(Guid id, User rejectingUser)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
if (emergencyAccess == null || emergencyAccess.GrantorId != rejectingUser.Id ||
(emergencyAccess.Status != EmergencyAccessStatusType.RecoveryInitiated &&
emergencyAccess.Status != EmergencyAccessStatusType.RecoveryApproved))
{
throw new BadRequestException("Emergency Access not valid.");
}
emergencyAccess.Status = EmergencyAccessStatusType.Confirmed;
await _emergencyAccessRepository.ReplaceAsync(emergencyAccess);
var grantee = await _userRepository.GetByIdAsync(emergencyAccess.GranteeId.Value);
await _mailService.SendEmergencyAccessRecoveryRejected(emergencyAccess, NameOrEmail(rejectingUser), grantee.Email);
}
public async Task<ICollection<Policy>> GetPoliciesAsync(Guid id, User requestingUser)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.Takeover))
{
throw new BadRequestException("Emergency Access not valid.");
}
var grantor = await _userRepository.GetByIdAsync(emergencyAccess.GrantorId);
var grantorOrganizations = await _organizationUserRepository.GetManyByUserAsync(grantor.Id);
var isOrganizationOwner = grantorOrganizations.Any<OrganizationUser>(organization => organization.Type == OrganizationUserType.Owner);
var policies = isOrganizationOwner ? await _policyRepository.GetManyByUserIdAsync(grantor.Id) : null;
return policies;
}
public async Task<(EmergencyAccess, User)> TakeoverAsync(Guid id, User requestingUser)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.Takeover))
{
throw new BadRequestException("Emergency Access not valid.");
}
var grantor = await _userRepository.GetByIdAsync(emergencyAccess.GrantorId);
if (emergencyAccess.Type == EmergencyAccessType.Takeover && grantor.UsesKeyConnector)
{
throw new BadRequestException("You cannot takeover an account that is using Key Connector.");
}
return (emergencyAccess, grantor);
}
public async Task PasswordAsync(Guid id, User requestingUser, string newMasterPasswordHash, string key)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.Takeover))
{
throw new BadRequestException("Emergency Access not valid.");
}
var grantor = await _userRepository.GetByIdAsync(emergencyAccess.GrantorId);
grantor.MasterPassword = _passwordHasher.HashPassword(grantor, newMasterPasswordHash);
grantor.Key = key;
// Disable TwoFactor providers since they will otherwise block logins
grantor.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>());
await _userRepository.ReplaceAsync(grantor);
// Remove grantor from all organizations unless Owner
var orgUser = await _organizationUserRepository.GetManyByUserAsync(grantor.Id);
foreach (var o in orgUser)
{
if (o.Type != OrganizationUserType.Owner)
{
await _organizationService.DeleteUserAsync(o.OrganizationId, grantor.Id);
}
}
}
public async Task SendNotificationsAsync()
{
var toNotify = await _emergencyAccessRepository.GetManyToNotifyAsync();
foreach (var notify in toNotify)
{
var ea = notify.ToEmergencyAccess();
ea.LastNotificationDate = DateTime.UtcNow;
await _emergencyAccessRepository.ReplaceAsync(ea);
var granteeNameOrEmail = string.IsNullOrWhiteSpace(notify.GranteeName) ? notify.GranteeEmail : notify.GranteeName;
await _mailService.SendEmergencyAccessRecoveryReminder(ea, granteeNameOrEmail, notify.GrantorEmail);
}
}
public async Task HandleTimedOutRequestsAsync()
{
var expired = await _emergencyAccessRepository.GetExpiredRecoveriesAsync();
foreach (var details in expired)
{
var ea = details.ToEmergencyAccess();
ea.Status = EmergencyAccessStatusType.RecoveryApproved;
await _emergencyAccessRepository.ReplaceAsync(ea);
var grantorNameOrEmail = string.IsNullOrWhiteSpace(details.GrantorName) ? details.GrantorEmail : details.GrantorName;
var granteeNameOrEmail = string.IsNullOrWhiteSpace(details.GranteeName) ? details.GranteeEmail : details.GranteeName;
await _mailService.SendEmergencyAccessRecoveryApproved(ea, grantorNameOrEmail, details.GranteeEmail);
await _mailService.SendEmergencyAccessRecoveryTimedOut(ea, granteeNameOrEmail, details.GrantorEmail);
}
}
public async Task<EmergencyAccessViewData> ViewAsync(Guid id, User requestingUser)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.View))
{
throw new BadRequestException("Emergency Access not valid.");
}
var ciphers = await _cipherRepository.GetManyByUserIdAsync(emergencyAccess.GrantorId, false);
return new EmergencyAccessViewData
{
EmergencyAccess = emergencyAccess,
Ciphers = ciphers,
};
}
public async Task<AttachmentResponseData> GetAttachmentDownloadAsync(Guid id, Guid cipherId, string attachmentId, User requestingUser)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.View))
{
throw new BadRequestException("Emergency Access not valid.");
}
var cipher = await _cipherRepository.GetByIdAsync(cipherId, emergencyAccess.GrantorId);
return await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId);
}
private async Task SendInviteAsync(EmergencyAccess emergencyAccess, string invitingUsersName)
{
var token = _dataProtectorTokenizer.Protect(new EmergencyAccessInviteTokenable(emergencyAccess, _globalSettings.OrganizationInviteExpirationHours));
await _mailService.SendEmergencyAccessInviteEmailAsync(emergencyAccess, invitingUsersName, token);
}
private string NameOrEmail(User user)
{
return string.IsNullOrWhiteSpace(user.Name) ? user.Email : user.Name;
}
private bool IsValidRequest(EmergencyAccess availibleAccess, User requestingUser, EmergencyAccessType requestedAccessType)
{
return availibleAccess != null &&
availibleAccess.GranteeId == requestingUser.Id &&
availibleAccess.Status == EmergencyAccessStatusType.RecoveryApproved &&
availibleAccess.Type == requestedAccessType;
}
}

View File

@ -0,0 +1,131 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Auth.Services;
public class HCaptchaValidationService : ICaptchaValidationService
{
private readonly ILogger<HCaptchaValidationService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly GlobalSettings _globalSettings;
private readonly IDataProtectorTokenFactory<HCaptchaTokenable> _tokenizer;
public HCaptchaValidationService(
ILogger<HCaptchaValidationService> logger,
IHttpClientFactory httpClientFactory,
IDataProtectorTokenFactory<HCaptchaTokenable> tokenizer,
GlobalSettings globalSettings)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_globalSettings = globalSettings;
_tokenizer = tokenizer;
}
public string SiteKeyResponseKeyName => "HCaptcha_SiteKey";
public string SiteKey => _globalSettings.Captcha.HCaptchaSiteKey;
public string GenerateCaptchaBypassToken(User user) => _tokenizer.Protect(new HCaptchaTokenable(user));
public async Task<CaptchaResponse> ValidateCaptchaResponseAsync(string captchaResponse, string clientIpAddress,
User user = null)
{
var response = new CaptchaResponse { Success = false };
if (string.IsNullOrWhiteSpace(captchaResponse))
{
return response;
}
if (user != null && ValidateCaptchaBypassToken(captchaResponse, user))
{
response.Success = true;
return response;
}
var httpClient = _httpClientFactory.CreateClient("HCaptchaValidationService");
var requestMessage = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://hcaptcha.com/siteverify"),
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "response", captchaResponse.TrimStart("hcaptcha|".ToCharArray()) },
{ "secret", _globalSettings.Captcha.HCaptchaSecretKey },
{ "sitekey", SiteKey },
{ "remoteip", clientIpAddress }
})
};
HttpResponseMessage responseMessage;
try
{
responseMessage = await httpClient.SendAsync(requestMessage);
}
catch (Exception e)
{
_logger.LogError(11389, e, "Unable to verify with HCaptcha.");
return response;
}
if (!responseMessage.IsSuccessStatusCode)
{
return response;
}
using var hcaptchaResponse = await responseMessage.Content.ReadFromJsonAsync<HCaptchaResponse>();
response.Success = hcaptchaResponse.Success;
var score = hcaptchaResponse.Score.GetValueOrDefault();
response.MaybeBot = score >= _globalSettings.Captcha.MaybeBotScoreThreshold;
response.IsBot = score >= _globalSettings.Captcha.IsBotScoreThreshold;
response.Score = score;
return response;
}
public bool RequireCaptchaValidation(ICurrentContext currentContext, User user = null)
{
if (user == null)
{
return currentContext.IsBot || _globalSettings.Captcha.ForceCaptchaRequired;
}
var failedLoginCeiling = _globalSettings.Captcha.MaximumFailedLoginAttempts;
var failedLoginCount = user?.FailedLoginCount ?? 0;
var cloudEmailUnverified = !_globalSettings.SelfHosted && !user.EmailVerified;
return currentContext.IsBot ||
_globalSettings.Captcha.ForceCaptchaRequired ||
cloudEmailUnverified ||
failedLoginCeiling > 0 && failedLoginCount >= failedLoginCeiling;
}
private static bool TokenIsValidApiKey(string bypassToken, User user) =>
!string.IsNullOrWhiteSpace(bypassToken) && user != null && user.ApiKey == bypassToken;
private bool TokenIsValidCaptchaBypassToken(string encryptedToken, User user)
{
return _tokenizer.TryUnprotect(encryptedToken, out var data) &&
data.Valid && data.TokenIsValid(user);
}
private bool ValidateCaptchaBypassToken(string bypassToken, User user) =>
TokenIsValidApiKey(bypassToken, user) || TokenIsValidCaptchaBypassToken(bypassToken, user);
public class HCaptchaResponse : IDisposable
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("score")]
public double? Score { get; set; }
[JsonPropertyName("score_reason")]
public List<string> ScoreReason { get; set; }
public void Dispose() { }
}
}

View File

@ -0,0 +1,109 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
namespace Bit.Core.Auth.Services;
public class SsoConfigService : ISsoConfigService
{
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IEventService _eventService;
public SsoConfigService(
ISsoConfigRepository ssoConfigRepository,
IPolicyRepository policyRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IEventService eventService)
{
_ssoConfigRepository = ssoConfigRepository;
_policyRepository = policyRepository;
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_eventService = eventService;
}
public async Task SaveAsync(SsoConfig config, Organization organization)
{
var now = DateTime.UtcNow;
config.RevisionDate = now;
if (config.Id == default)
{
config.CreationDate = now;
}
var useKeyConnector = config.GetData().KeyConnectorEnabled;
if (useKeyConnector)
{
await VerifyDependenciesAsync(config, organization);
}
var oldConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(config.OrganizationId);
var disabledKeyConnector = oldConfig?.GetData()?.KeyConnectorEnabled == true && !useKeyConnector;
if (disabledKeyConnector && await AnyOrgUserHasKeyConnectorEnabledAsync(config.OrganizationId))
{
throw new BadRequestException("Key Connector cannot be disabled at this moment.");
}
await LogEventsAsync(config, oldConfig);
await _ssoConfigRepository.UpsertAsync(config);
}
private async Task<bool> AnyOrgUserHasKeyConnectorEnabledAsync(Guid organizationId)
{
var userDetails =
await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
return userDetails.Any(u => u.UsesKeyConnector);
}
private async Task VerifyDependenciesAsync(SsoConfig config, Organization organization)
{
if (!organization.UseKeyConnector)
{
throw new BadRequestException("Organization cannot use Key Connector.");
}
var singleOrgPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.SingleOrg);
if (singleOrgPolicy is not { Enabled: true })
{
throw new BadRequestException("Key Connector requires the Single Organization policy to be enabled.");
}
var ssoPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.RequireSso);
if (ssoPolicy is not { Enabled: true })
{
throw new BadRequestException("Key Connector requires the Single Sign-On Authentication policy to be enabled.");
}
if (!config.Enabled)
{
throw new BadRequestException("You must enable SSO to use Key Connector.");
}
}
private async Task LogEventsAsync(SsoConfig config, SsoConfig oldConfig)
{
var organization = await _organizationRepository.GetByIdAsync(config.OrganizationId);
if (oldConfig?.Enabled != config.Enabled)
{
var e = config.Enabled ? EventType.Organization_EnabledSso : EventType.Organization_DisabledSso;
await _eventService.LogOrganizationEventAsync(organization, e);
}
var keyConnectorEnabled = config.GetData().KeyConnectorEnabled;
if (oldConfig?.GetData()?.KeyConnectorEnabled != keyConnectorEnabled)
{
var e = keyConnectorEnabled
? EventType.Organization_EnabledKeyConnector
: EventType.Organization_DisabledKeyConnector;
await _eventService.LogOrganizationEventAsync(organization, e);
}
}
}

View File

@ -0,0 +1,18 @@
using Bit.Core.Auth.Models.Business;
using Bit.Core.Context;
using Bit.Core.Entities;
namespace Bit.Core.Auth.Services;
public class NoopCaptchaValidationService : ICaptchaValidationService
{
public string SiteKeyResponseKeyName => null;
public string SiteKey => null;
public bool RequireCaptchaValidation(ICurrentContext currentContext, User user = null) => false;
public string GenerateCaptchaBypassToken(User user) => "";
public Task<CaptchaResponse> ValidateCaptchaResponseAsync(string captchaResponse, string clientIpAddress,
User user = null)
{
return Task.FromResult(new CaptchaResponse { Success = true });
}
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Auth.Settings;
public interface IPasswordlessAuthSettings
{
bool KnownDevicesOnly { get; set; }
}

View File

@ -0,0 +1,8 @@
namespace Bit.Core.Settings;
public interface ISsoSettings
{
int CacheLifetimeInSeconds { get; set; }
double SsoTokenLifetimeInSeconds { get; set; }
bool EnforceSsoPolicyForAllUsers { get; set; }
}

View File

@ -0,0 +1,16 @@
namespace Bit.Core.Sso;
public static class SamlSigningAlgorithms
{
public const string Default = Sha256;
public const string Sha256 = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
public const string Sha384 = "http://www.w3.org/2000/09/xmldsig#rsa-sha384";
public const string Sha512 = "http://www.w3.org/2000/09/xmldsig#rsa-sha512";
public static IEnumerable<string> GetEnumerable()
{
yield return Sha256;
yield return Sha384;
yield return Sha512;
}
}

View File

@ -0,0 +1,36 @@
using Bit.Core.Auth.Models.Api;
using Bit.Core.Auth.Services;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Auth.Utilities;
public class CaptchaProtectedAttribute : ActionFilterAttribute
{
public string ModelParameterName { get; set; } = "model";
public override void OnActionExecuting(ActionExecutingContext context)
{
var currentContext = context.HttpContext.RequestServices.GetRequiredService<ICurrentContext>();
var captchaValidationService = context.HttpContext.RequestServices.GetRequiredService<ICaptchaValidationService>();
if (captchaValidationService.RequireCaptchaValidation(currentContext, null))
{
var captchaResponse = (context.ActionArguments[ModelParameterName] as ICaptchaProtectedModel)?.CaptchaResponse;
if (string.IsNullOrWhiteSpace(captchaResponse))
{
throw new BadRequestException(captchaValidationService.SiteKeyResponseKeyName, captchaValidationService.SiteKey);
}
var captchaValidationResponse = captchaValidationService.ValidateCaptchaResponseAsync(captchaResponse,
currentContext.IpAddress, null).GetAwaiter().GetResult();
if (!captchaValidationResponse.Success || captchaValidationResponse.IsBot)
{
throw new BadRequestException("Captcha is invalid. Please refresh and try again");
}
}
}
}

View File

@ -0,0 +1,277 @@
/*
Original source modified from https://github.com/duosecurity/duo_api_csharp
=============================================================================
=============================================================================
Copyright (c) 2018 Duo Security
All rights reserved
*/
using System.Globalization;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Web;
using Bit.Core.Models.Api.Response.Duo;
namespace Bit.Core.Auth.Utilities;
public class DuoApi
{
private const string UrlScheme = "https";
private const string UserAgent = "Bitwarden_DuoAPICSharp/1.0 (.NET Core)";
private readonly string _host;
private readonly string _ikey;
private readonly string _skey;
private readonly HttpClient _httpClient = new();
public DuoApi(string ikey, string skey, string host)
{
_ikey = ikey;
_skey = skey;
_host = host;
if (!ValidHost(host))
{
throw new DuoException("Invalid Duo host configured.", new ArgumentException(nameof(host)));
}
}
public static bool ValidHost(string host)
{
if (Uri.TryCreate($"https://{host}", UriKind.Absolute, out var uri))
{
return (string.IsNullOrWhiteSpace(uri.PathAndQuery) || uri.PathAndQuery == "/") &&
uri.Host.StartsWith("api-") &&
(uri.Host.EndsWith(".duosecurity.com") || uri.Host.EndsWith(".duofederal.com"));
}
return false;
}
public static string CanonicalizeParams(Dictionary<string, string> parameters)
{
var ret = new List<string>();
foreach (var pair in parameters)
{
var p = string.Format("{0}={1}", HttpUtility.UrlEncode(pair.Key), HttpUtility.UrlEncode(pair.Value));
// Signatures require upper-case hex digits.
p = Regex.Replace(p, "(%[0-9A-Fa-f][0-9A-Fa-f])", c => c.Value.ToUpperInvariant());
// Escape only the expected characters.
p = Regex.Replace(p, "([!'()*])", c => "%" + Convert.ToByte(c.Value[0]).ToString("X"));
p = p.Replace("%7E", "~");
// UrlEncode converts space (" ") to "+". The
// signature algorithm requires "%20" instead. Actual
// + has already been replaced with %2B.
p = p.Replace("+", "%20");
ret.Add(p);
}
ret.Sort(StringComparer.Ordinal);
return string.Join("&", ret.ToArray());
}
protected string CanonicalizeRequest(string method, string path, string canonParams, string date)
{
string[] lines = {
date,
method.ToUpperInvariant(),
_host.ToLower(),
path,
canonParams,
};
return string.Join("\n", lines);
}
public string Sign(string method, string path, string canonParams, string date)
{
var canon = CanonicalizeRequest(method, path, canonParams, date);
var sig = HmacSign(canon);
var auth = string.Concat(_ikey, ':', sig);
return string.Concat("Basic ", Encode64(auth));
}
/// <param name="timeout">The request timeout, in milliseconds.
/// Specify 0 to use the system-default timeout. Use caution if
/// you choose to specify a custom timeout - some API
/// calls (particularly in the Auth APIs) will not
/// return a response until an out-of-band authentication process
/// has completed. In some cases, this may take as much as a
/// small number of minutes.</param>
private async Task<(string result, HttpStatusCode statusCode)> ApiCall(string method, string path, Dictionary<string, string> parameters, int timeout)
{
if (parameters == null)
{
parameters = new Dictionary<string, string>();
}
var canonParams = CanonicalizeParams(parameters);
var query = string.Empty;
if (!method.Equals("POST") && !method.Equals("PUT"))
{
if (parameters.Count > 0)
{
query = "?" + canonParams;
}
}
var url = $"{UrlScheme}://{_host}{path}{query}";
var dateString = RFC822UtcNow();
var auth = Sign(method, path, canonParams, dateString);
var request = new HttpRequestMessage
{
Method = new HttpMethod(method),
RequestUri = new Uri(url),
};
request.Headers.Add("Authorization", auth);
request.Headers.Add("X-Duo-Date", dateString);
request.Headers.UserAgent.ParseAdd(UserAgent);
if (timeout > 0)
{
_httpClient.Timeout = TimeSpan.FromMilliseconds(timeout);
}
if (method.Equals("POST") || method.Equals("PUT"))
{
request.Content = new StringContent(canonParams, Encoding.UTF8, "application/x-www-form-urlencoded");
}
var response = await _httpClient.SendAsync(request);
var result = await response.Content.ReadAsStringAsync();
var statusCode = response.StatusCode;
return (result, statusCode);
}
public async Task<Response> JSONApiCall(string method, string path, Dictionary<string, string> parameters = null)
{
return await JSONApiCall(method, path, parameters, 0);
}
/// <param name="timeout">The request timeout, in milliseconds.
/// Specify 0 to use the system-default timeout. Use caution if
/// you choose to specify a custom timeout - some API
/// calls (particularly in the Auth APIs) will not
/// return a response until an out-of-band authentication process
/// has completed. In some cases, this may take as much as a
/// small number of minutes.</param>
private async Task<Response> JSONApiCall(string method, string path, Dictionary<string, string> parameters, int timeout)
{
var (res, statusCode) = await ApiCall(method, path, parameters, timeout);
try
{
var obj = JsonSerializer.Deserialize<DuoResponseModel>(res);
if (obj.Stat == "OK")
{
return obj.Response;
}
throw new ApiException(obj.Code ?? 0, (int)statusCode, obj.Message, obj.MessageDetail);
}
catch (ApiException)
{
throw;
}
catch (Exception e)
{
throw new BadResponseException((int)statusCode, e);
}
}
private int? ToNullableInt(string s)
{
int i;
if (int.TryParse(s, out i))
{
return i;
}
return null;
}
private string HmacSign(string data)
{
var keyBytes = Encoding.ASCII.GetBytes(_skey);
var dataBytes = Encoding.ASCII.GetBytes(data);
using (var hmac = new HMACSHA1(keyBytes))
{
var hash = hmac.ComputeHash(dataBytes);
var hex = BitConverter.ToString(hash);
return hex.Replace("-", string.Empty).ToLower();
}
}
private static string Encode64(string plaintext)
{
var plaintextBytes = Encoding.ASCII.GetBytes(plaintext);
return Convert.ToBase64String(plaintextBytes);
}
private static string RFC822UtcNow()
{
// Can't use the "zzzz" format because it adds a ":"
// between the offset's hours and minutes.
var dateString = DateTime.UtcNow.ToString("ddd, dd MMM yyyy HH:mm:ss", CultureInfo.InvariantCulture);
var offset = 0;
var zone = "+" + offset.ToString(CultureInfo.InvariantCulture).PadLeft(2, '0');
dateString += " " + zone.PadRight(5, '0');
return dateString;
}
}
public class DuoException : Exception
{
public int HttpStatus { get; private set; }
public DuoException(string message, Exception inner)
: base(message, inner)
{ }
public DuoException(int httpStatus, string message, Exception inner)
: base(message, inner)
{
HttpStatus = httpStatus;
}
}
public class ApiException : DuoException
{
public int Code { get; private set; }
public string ApiMessage { get; private set; }
public string ApiMessageDetail { get; private set; }
public ApiException(int code, int httpStatus, string apiMessage, string apiMessageDetail)
: base(httpStatus, FormatMessage(code, apiMessage, apiMessageDetail), null)
{
Code = code;
ApiMessage = apiMessage;
ApiMessageDetail = apiMessageDetail;
}
private static string FormatMessage(int code, string apiMessage, string apiMessageDetail)
{
return string.Format("Duo API Error {0}: '{1}' ('{2}')", code, apiMessage, apiMessageDetail);
}
}
public class BadResponseException : DuoException
{
public BadResponseException(int httpStatus, Exception inner)
: base(httpStatus, FormatMessage(httpStatus, inner), inner)
{ }
private static string FormatMessage(int httpStatus, Exception inner)
{
var innerMessage = "(null)";
if (inner != null)
{
innerMessage = string.Format("'{0}'", inner.Message);
}
return string.Format("Got error {0} with HTTP Status {1}", innerMessage, httpStatus);
}
}

View File

@ -0,0 +1,240 @@
/*
Original source modified from https://github.com/duosecurity/duo_dotnet
=============================================================================
=============================================================================
ref: https://github.com/duosecurity/duo_dotnet/blob/master/LICENSE
Copyright (c) 2011, Duo Security, Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. The name of the author may not be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
using System.Security.Cryptography;
using System.Text;
namespace Bit.Core.Auth.Utilities.Duo;
public static class DuoWeb
{
private const string DuoProfix = "TX";
private const string AppPrefix = "APP";
private const string AuthPrefix = "AUTH";
private const int DuoExpire = 300;
private const int AppExpire = 3600;
private const int IKeyLength = 20;
private const int SKeyLength = 40;
private const int AKeyLength = 40;
public static string ErrorUser = "ERR|The username passed to sign_request() is invalid.";
public static string ErrorIKey = "ERR|The Duo integration key passed to sign_request() is invalid.";
public static string ErrorSKey = "ERR|The Duo secret key passed to sign_request() is invalid.";
public static string ErrorAKey = "ERR|The application secret key passed to sign_request() must be at least " +
"40 characters.";
public static string ErrorUnknown = "ERR|An unknown error has occurred.";
// throw on invalid bytes
private static Encoding _encoding = new UTF8Encoding(false, true);
private static DateTime _epoc = new DateTime(1970, 1, 1);
/// <summary>
/// Generate a signed request for Duo authentication.
/// The returned value should be passed into the Duo.init() call
/// in the rendered web page used for Duo authentication.
/// </summary>
/// <param name="ikey">Duo integration key</param>
/// <param name="skey">Duo secret key</param>
/// <param name="akey">Application secret key</param>
/// <param name="username">Primary-authenticated username</param>
/// <param name="currentTime">(optional) The current UTC time</param>
/// <returns>signed request</returns>
public static string SignRequest(string ikey, string skey, string akey, string username,
DateTime? currentTime = null)
{
string duoSig;
string appSig;
var currentTimeValue = currentTime ?? DateTime.UtcNow;
if (username == string.Empty)
{
return ErrorUser;
}
if (username.Contains("|"))
{
return ErrorUser;
}
if (ikey.Length != IKeyLength)
{
return ErrorIKey;
}
if (skey.Length != SKeyLength)
{
return ErrorSKey;
}
if (akey.Length < AKeyLength)
{
return ErrorAKey;
}
try
{
duoSig = SignVals(skey, username, ikey, DuoProfix, DuoExpire, currentTimeValue);
appSig = SignVals(akey, username, ikey, AppPrefix, AppExpire, currentTimeValue);
}
catch
{
return ErrorUnknown;
}
return $"{duoSig}:{appSig}";
}
/// <summary>
/// Validate the signed response returned from Duo.
/// Returns the username of the authenticated user, or null.
/// </summary>
/// <param name="ikey">Duo integration key</param>
/// <param name="skey">Duo secret key</param>
/// <param name="akey">Application secret key</param>
/// <param name="sigResponse">The signed response POST'ed to the server</param>
/// <param name="currentTime">(optional) The current UTC time</param>
/// <returns>authenticated username, or null</returns>
public static string VerifyResponse(string ikey, string skey, string akey, string sigResponse,
DateTime? currentTime = null)
{
string authUser = null;
string appUser = null;
var currentTimeValue = currentTime ?? DateTime.UtcNow;
try
{
var sigs = sigResponse.Split(':');
var authSig = sigs[0];
var appSig = sigs[1];
authUser = ParseVals(skey, authSig, AuthPrefix, ikey, currentTimeValue);
appUser = ParseVals(akey, appSig, AppPrefix, ikey, currentTimeValue);
}
catch
{
return null;
}
if (authUser != appUser)
{
return null;
}
return authUser;
}
private static string SignVals(string key, string username, string ikey, string prefix, long expire,
DateTime currentTime)
{
var ts = (long)(currentTime - _epoc).TotalSeconds;
expire = ts + expire;
var val = $"{username}|{ikey}|{expire.ToString()}";
var cookie = $"{prefix}|{Encode64(val)}";
var sig = Sign(key, cookie);
return $"{cookie}|{sig}";
}
private static string ParseVals(string key, string val, string prefix, string ikey, DateTime currentTime)
{
var ts = (long)(currentTime - _epoc).TotalSeconds;
var parts = val.Split('|');
if (parts.Length != 3)
{
return null;
}
var uPrefix = parts[0];
var uB64 = parts[1];
var uSig = parts[2];
var sig = Sign(key, $"{uPrefix}|{uB64}");
if (Sign(key, sig) != Sign(key, uSig))
{
return null;
}
if (uPrefix != prefix)
{
return null;
}
var cookie = Decode64(uB64);
var cookieParts = cookie.Split('|');
if (cookieParts.Length != 3)
{
return null;
}
var username = cookieParts[0];
var uIKey = cookieParts[1];
var expire = cookieParts[2];
if (uIKey != ikey)
{
return null;
}
var expireTs = Convert.ToInt32(expire);
if (ts >= expireTs)
{
return null;
}
return username;
}
private static string Sign(string skey, string data)
{
var keyBytes = Encoding.ASCII.GetBytes(skey);
var dataBytes = Encoding.ASCII.GetBytes(data);
using (var hmac = new HMACSHA1(keyBytes))
{
var hash = hmac.ComputeHash(dataBytes);
var hex = BitConverter.ToString(hash);
return hex.Replace("-", "").ToLower();
}
}
private static string Encode64(string plaintext)
{
var plaintextBytes = _encoding.GetBytes(plaintext);
return Convert.ToBase64String(plaintextBytes);
}
private static string Decode64(string encoded)
{
var plaintextBytes = Convert.FromBase64String(encoded);
return _encoding.GetString(plaintextBytes);
}
}