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:
43
src/Core/Auth/Entities/AuthRequest.cs
Normal file
43
src/Core/Auth/Entities/AuthRequest.cs
Normal 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);
|
||||
}
|
||||
}
|
47
src/Core/Auth/Entities/EmergencyAccess.cs
Normal file
47
src/Core/Auth/Entities/EmergencyAccess.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
23
src/Core/Auth/Entities/Grant.cs
Normal file
23
src/Core/Auth/Entities/Grant.cs
Normal 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; }
|
||||
}
|
30
src/Core/Auth/Entities/SsoConfig.cs
Normal file
30
src/Core/Auth/Entities/SsoConfig.cs
Normal 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();
|
||||
}
|
||||
}
|
20
src/Core/Auth/Entities/SsoUser.cs
Normal file
20
src/Core/Auth/Entities/SsoUser.cs
Normal 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;
|
||||
}
|
||||
}
|
7
src/Core/Auth/Enums/AuthRequestType.cs
Normal file
7
src/Core/Auth/Enums/AuthRequestType.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace Bit.Core.Auth.Enums;
|
||||
|
||||
public enum AuthRequestType : byte
|
||||
{
|
||||
AuthenticateAndUnlock = 0,
|
||||
Unlock = 1
|
||||
}
|
10
src/Core/Auth/Enums/EmergencyAccessStatusType.cs
Normal file
10
src/Core/Auth/Enums/EmergencyAccessStatusType.cs
Normal file
@ -0,0 +1,10 @@
|
||||
namespace Bit.Core.Auth.Enums;
|
||||
|
||||
public enum EmergencyAccessStatusType : byte
|
||||
{
|
||||
Invited = 0,
|
||||
Accepted = 1,
|
||||
Confirmed = 2,
|
||||
RecoveryInitiated = 3,
|
||||
RecoveryApproved = 4,
|
||||
}
|
7
src/Core/Auth/Enums/EmergencyAccessType.cs
Normal file
7
src/Core/Auth/Enums/EmergencyAccessType.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace Bit.Core.Auth.Enums;
|
||||
|
||||
public enum EmergencyAccessType : byte
|
||||
{
|
||||
View = 0,
|
||||
Takeover = 1,
|
||||
}
|
7
src/Core/Auth/Enums/Saml2BindingType.cs
Normal file
7
src/Core/Auth/Enums/Saml2BindingType.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace Bit.Core.Auth.Enums;
|
||||
|
||||
public enum Saml2BindingType : byte
|
||||
{
|
||||
HttpRedirect = 1,
|
||||
HttpPost = 2,
|
||||
}
|
14
src/Core/Auth/Enums/Saml2NameIdFormat.cs
Normal file
14
src/Core/Auth/Enums/Saml2NameIdFormat.cs
Normal 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,
|
||||
}
|
8
src/Core/Auth/Enums/Saml2SigningBehavior.cs
Normal file
8
src/Core/Auth/Enums/Saml2SigningBehavior.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace Bit.Core.Auth.Enums;
|
||||
|
||||
public enum Saml2SigningBehavior : byte
|
||||
{
|
||||
IfIdpWantAuthnRequestsSigned = 0,
|
||||
Always = 1,
|
||||
Never = 3
|
||||
}
|
7
src/Core/Auth/Enums/SsoType.cs
Normal file
7
src/Core/Auth/Enums/SsoType.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace Bit.Core.Auth.Enums;
|
||||
|
||||
public enum SsoType : byte
|
||||
{
|
||||
OpenIdConnect = 1,
|
||||
Saml2 = 2,
|
||||
}
|
13
src/Core/Auth/Enums/TwoFactorProviderType.cs
Normal file
13
src/Core/Auth/Enums/TwoFactorProviderType.cs
Normal 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,
|
||||
}
|
10
src/Core/Auth/Exceptions/DuplicateAuthRequestException.cs
Normal file
10
src/Core/Auth/Exceptions/DuplicateAuthRequestException.cs
Normal 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.")
|
||||
{
|
||||
|
||||
}
|
||||
}
|
45
src/Core/Auth/Identity/AuthenticatorTokenProvider.cs
Normal file
45
src/Core/Auth/Identity/AuthenticatorTokenProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
86
src/Core/Auth/Identity/DuoWebTokenProvider.cs
Normal file
86
src/Core/Auth/Identity/DuoWebTokenProvider.cs
Normal 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");
|
||||
}
|
||||
}
|
83
src/Core/Auth/Identity/EmailTokenProvider.cs
Normal file
83
src/Core/Auth/Identity/EmailTokenProvider.cs
Normal 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]}";
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
21
src/Core/Auth/Identity/LowerInvariantLookupNormalizer.cs
Normal file
21
src/Core/Auth/Identity/LowerInvariantLookupNormalizer.cs
Normal 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();
|
||||
}
|
||||
}
|
75
src/Core/Auth/Identity/OrganizationDuoWebTokenProvider.cs
Normal file
75
src/Core/Auth/Identity/OrganizationDuoWebTokenProvider.cs
Normal 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");
|
||||
}
|
||||
}
|
60
src/Core/Auth/Identity/RoleStore.cs
Normal file
60
src/Core/Auth/Identity/RoleStore.cs
Normal 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();
|
||||
}
|
||||
}
|
20
src/Core/Auth/Identity/TwoFactorRememberTokenProvider.cs
Normal file
20
src/Core/Auth/Identity/TwoFactorRememberTokenProvider.cs
Normal 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
|
||||
{ }
|
183
src/Core/Auth/Identity/UserStore.cs
Normal file
183
src/Core/Auth/Identity/UserStore.cs
Normal 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);
|
||||
}
|
||||
}
|
156
src/Core/Auth/Identity/WebAuthnTokenProvider.cs
Normal file
156
src/Core/Auth/Identity/WebAuthnTokenProvider.cs
Normal 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;
|
||||
}
|
||||
}
|
75
src/Core/Auth/Identity/YubicoOtpTokenProvider.cs
Normal file
75
src/Core/Auth/Identity/YubicoOtpTokenProvider.cs
Normal 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;
|
||||
}
|
||||
}
|
29
src/Core/Auth/IdentityServer/TokenRetrieval.cs
Normal file
29
src/Core/Auth/IdentityServer/TokenRetrieval.cs
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
@ -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>();
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
|
||||
|
||||
public interface IVerifyAuthRequestCommand
|
||||
{
|
||||
Task<bool> VerifyAuthRequestAsync(Guid authRequestId, string accessCode);
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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; }
|
||||
}
|
@ -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>();
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.Auth.Models.Api;
|
||||
|
||||
public interface ICaptchaProtectedModel
|
||||
{
|
||||
string CaptchaResponse { get; set; }
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.Auth.Models.Api.Response.Accounts;
|
||||
|
||||
public interface ICaptchaProtectedResponseModel
|
||||
{
|
||||
public string CaptchaBypassToken { get; set; }
|
||||
}
|
@ -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; }
|
||||
}
|
@ -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; }
|
||||
}
|
9
src/Core/Auth/Models/Business/CaptchaResponse.cs
Normal file
9
src/Core/Auth/Models/Business/CaptchaResponse.cs
Normal 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; }
|
||||
}
|
13
src/Core/Auth/Models/Business/ExpiringToken.cs
Normal file
13
src/Core/Auth/Models/Business/ExpiringToken.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
43
src/Core/Auth/Models/Business/Tokenables/SsoTokenable.cs
Normal file
43
src/Core/Auth/Models/Business/Tokenables/SsoTokenable.cs
Normal 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);
|
||||
}
|
14
src/Core/Auth/Models/Data/EmergencyAccessDetails.cs
Normal file
14
src/Core/Auth/Models/Data/EmergencyAccessDetails.cs
Normal 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; }
|
||||
}
|
11
src/Core/Auth/Models/Data/EmergencyAccessNotify.cs
Normal file
11
src/Core/Auth/Models/Data/EmergencyAccessNotify.cs
Normal 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; }
|
||||
}
|
10
src/Core/Auth/Models/Data/EmergencyAccessViewData.cs
Normal file
10
src/Core/Auth/Models/Data/EmergencyAccessViewData.cs
Normal 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; }
|
||||
}
|
125
src/Core/Auth/Models/Data/SsoConfigurationData.cs
Normal file
125
src/Core/Auth/Models/Data/SsoConfigurationData.cs
Normal 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;
|
||||
}
|
||||
}
|
11
src/Core/Auth/Models/ITwoFactorProvidersUser.cs
Normal file
11
src/Core/Auth/Models/ITwoFactorProvidersUser.cs
Normal 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();
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.Models.Mail;
|
||||
|
||||
public class EmergencyAccessAcceptedViewModel : BaseMailModel
|
||||
{
|
||||
public string GranteeEmail { get; set; }
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.Models.Mail;
|
||||
|
||||
public class EmergencyAccessApprovedViewModel : BaseMailModel
|
||||
{
|
||||
public string Name { get; set; }
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.Models.Mail;
|
||||
|
||||
public class EmergencyAccessConfirmedViewModel : BaseMailModel
|
||||
{
|
||||
public string Name { get; set; }
|
||||
}
|
10
src/Core/Auth/Models/Mail/EmergencyAccessInvitedViewModel.cs
Normal file
10
src/Core/Auth/Models/Mail/EmergencyAccessInvitedViewModel.cs
Normal 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}";
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
namespace Bit.Core.Models.Mail;
|
||||
|
||||
public class EmergencyAccessRecoveryTimedOutViewModel : BaseMailModel
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Action { get; set; }
|
||||
}
|
@ -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; }
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.Models.Mail;
|
||||
|
||||
public class EmergencyAccessRejectedViewModel : BaseMailModel
|
||||
{
|
||||
public string Name { get; set; }
|
||||
}
|
8
src/Core/Auth/Models/Mail/FailedAuthAttemptsModel.cs
Normal file
8
src/Core/Auth/Models/Mail/FailedAuthAttemptsModel.cs
Normal 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; }
|
||||
}
|
8
src/Core/Auth/Models/Mail/MasterPasswordHintViewModel.cs
Normal file
8
src/Core/Auth/Models/Mail/MasterPasswordHintViewModel.cs
Normal 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; }
|
||||
}
|
6
src/Core/Auth/Models/Mail/PasswordlessSignInModel.cs
Normal file
6
src/Core/Auth/Models/Mail/PasswordlessSignInModel.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.Auth.Models.Mail;
|
||||
|
||||
public class PasswordlessSignInModel
|
||||
{
|
||||
public string Url { get; set; }
|
||||
}
|
11
src/Core/Auth/Models/Mail/RecoverTwoFactorModel.cs
Normal file
11
src/Core/Auth/Models/Mail/RecoverTwoFactorModel.cs
Normal 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; }
|
||||
}
|
17
src/Core/Auth/Models/Mail/VerifyDeleteModel.cs
Normal file
17
src/Core/Auth/Models/Mail/VerifyDeleteModel.cs
Normal 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; }
|
||||
}
|
14
src/Core/Auth/Models/Mail/VerifyEmailModel.cs
Normal file
14
src/Core/Auth/Models/Mail/VerifyEmailModel.cs
Normal 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; }
|
||||
}
|
66
src/Core/Auth/Models/TwoFactorProvider.cs
Normal file
66
src/Core/Auth/Models/TwoFactorProvider.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
9
src/Core/Auth/Repositories/IAuthRequestRepository.cs
Normal file
9
src/Core/Auth/Repositories/IAuthRequestRepository.cs
Normal 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);
|
||||
}
|
14
src/Core/Auth/Repositories/IEmergencyAccessRepository.cs
Normal file
14
src/Core/Auth/Repositories/IEmergencyAccessRepository.cs
Normal 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();
|
||||
}
|
12
src/Core/Auth/Repositories/IGrantRepository.cs
Normal file
12
src/Core/Auth/Repositories/IGrantRepository.cs
Normal 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);
|
||||
}
|
11
src/Core/Auth/Repositories/ISsoConfigRepository.cs
Normal file
11
src/Core/Auth/Repositories/ISsoConfigRepository.cs
Normal 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);
|
||||
}
|
10
src/Core/Auth/Repositories/ISsoUserRepository.cs
Normal file
10
src/Core/Auth/Repositories/ISsoUserRepository.cs
Normal 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);
|
||||
}
|
15
src/Core/Auth/Services/ICaptchaValidationService.cs
Normal file
15
src/Core/Auth/Services/ICaptchaValidationService.cs
Normal 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);
|
||||
}
|
29
src/Core/Auth/Services/IEmergencyAccessService.cs
Normal file
29
src/Core/Auth/Services/IEmergencyAccessService.cs
Normal 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);
|
||||
}
|
9
src/Core/Auth/Services/ISsoConfigService.cs
Normal file
9
src/Core/Auth/Services/ISsoConfigService.cs
Normal 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);
|
||||
}
|
428
src/Core/Auth/Services/Implementations/EmergencyAccessService.cs
Normal file
428
src/Core/Auth/Services/Implementations/EmergencyAccessService.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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() { }
|
||||
}
|
||||
}
|
109
src/Core/Auth/Services/Implementations/SsoConfigService.cs
Normal file
109
src/Core/Auth/Services/Implementations/SsoConfigService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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 });
|
||||
}
|
||||
}
|
6
src/Core/Auth/Settings/IPasswordlessAuthSettings.cs
Normal file
6
src/Core/Auth/Settings/IPasswordlessAuthSettings.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.Auth.Settings;
|
||||
|
||||
public interface IPasswordlessAuthSettings
|
||||
{
|
||||
bool KnownDevicesOnly { get; set; }
|
||||
}
|
8
src/Core/Auth/Settings/ISsoSettings.cs
Normal file
8
src/Core/Auth/Settings/ISsoSettings.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace Bit.Core.Settings;
|
||||
|
||||
public interface ISsoSettings
|
||||
{
|
||||
int CacheLifetimeInSeconds { get; set; }
|
||||
double SsoTokenLifetimeInSeconds { get; set; }
|
||||
bool EnforceSsoPolicyForAllUsers { get; set; }
|
||||
}
|
16
src/Core/Auth/Sso/SamlSigningAlgorithms.cs
Normal file
16
src/Core/Auth/Sso/SamlSigningAlgorithms.cs
Normal 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;
|
||||
}
|
||||
}
|
36
src/Core/Auth/Utilities/CaptchaProtectedAttribute.cs
Normal file
36
src/Core/Auth/Utilities/CaptchaProtectedAttribute.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
277
src/Core/Auth/Utilities/DuoApi.cs
Normal file
277
src/Core/Auth/Utilities/DuoApi.cs
Normal 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);
|
||||
}
|
||||
}
|
240
src/Core/Auth/Utilities/DuoWeb.cs
Normal file
240
src/Core/Auth/Utilities/DuoWeb.cs
Normal 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);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user