mirror of
https://github.com/bitwarden/server.git
synced 2025-05-28 23:04:50 -05:00
Feature/token service (#1785)
* Implement draft token service * Add tokenizer and factory * Handle expiring tokens through base class * Allow direct token validity checks * Add safe unprotect to tokenizer * Add interface to tokenizer factory * Use tokenizer * Fix rebase * Handle cleartext prefix in tokenizer base * Use epoch milliseconds for expiration in tokens * Use tokenizers * Test tokens * Linter fixes * Add TokenizerFactory to DI services * Test epoch milliseconds deserialization * Use separate injectables for each token type * Fix directory * Add functional unprotect to token * Fix namespace and correct object names * Remove Tokenable interface * Test remaining Tokens classes * Dotnet format * Fix sut provider errors with update * Remove useless property Co-authored-by: Hinton <oscar@oscarhinton.com>
This commit is contained in:
parent
924ebca153
commit
e2c6fc81f4
@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Bit.Core.Models.Table;
|
||||||
|
|
||||||
|
namespace Bit.Core.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);
|
||||||
|
}
|
||||||
|
}
|
40
src/Core/Models/Business/Tokenables/HCaptchaTokenable.cs
Normal file
40
src/Core/Models/Business/Tokenables/HCaptchaTokenable.cs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Bit.Core.Models.Table;
|
||||||
|
using Bit.Core.Tokens;
|
||||||
|
|
||||||
|
namespace Bit.Core.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;
|
||||||
|
Email = user.Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TokenIsValid(User user)
|
||||||
|
{
|
||||||
|
return Id == user.Id &&
|
||||||
|
Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validates deserialized
|
||||||
|
protected override bool TokenIsValid() => Identifier == TokenIdentifier && Id != default && !string.IsNullOrWhiteSpace(Email);
|
||||||
|
}
|
||||||
|
}
|
@ -5,12 +5,12 @@ using System.Threading.Tasks;
|
|||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Models;
|
using Bit.Core.Models;
|
||||||
|
using Bit.Core.Models.Business.Tokenables;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
using Bit.Core.Models.Table;
|
using Bit.Core.Models.Table;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Tokens;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
|
||||||
namespace Bit.Core.Services
|
namespace Bit.Core.Services
|
||||||
@ -25,10 +25,10 @@ namespace Bit.Core.Services
|
|||||||
private readonly ICipherService _cipherService;
|
private readonly ICipherService _cipherService;
|
||||||
private readonly IMailService _mailService;
|
private readonly IMailService _mailService;
|
||||||
private readonly IUserService _userService;
|
private readonly IUserService _userService;
|
||||||
private readonly IDataProtector _dataProtector;
|
|
||||||
private readonly GlobalSettings _globalSettings;
|
private readonly GlobalSettings _globalSettings;
|
||||||
private readonly IPasswordHasher<User> _passwordHasher;
|
private readonly IPasswordHasher<User> _passwordHasher;
|
||||||
private readonly IOrganizationService _organizationService;
|
private readonly IOrganizationService _organizationService;
|
||||||
|
private readonly IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> _dataProtectorTokenizer;
|
||||||
|
|
||||||
public EmergencyAccessService(
|
public EmergencyAccessService(
|
||||||
IEmergencyAccessRepository emergencyAccessRepository,
|
IEmergencyAccessRepository emergencyAccessRepository,
|
||||||
@ -40,9 +40,9 @@ namespace Bit.Core.Services
|
|||||||
IMailService mailService,
|
IMailService mailService,
|
||||||
IUserService userService,
|
IUserService userService,
|
||||||
IPasswordHasher<User> passwordHasher,
|
IPasswordHasher<User> passwordHasher,
|
||||||
IDataProtectionProvider dataProtectionProvider,
|
|
||||||
GlobalSettings globalSettings,
|
GlobalSettings globalSettings,
|
||||||
IOrganizationService organizationService)
|
IOrganizationService organizationService,
|
||||||
|
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> dataProtectorTokenizer)
|
||||||
{
|
{
|
||||||
_emergencyAccessRepository = emergencyAccessRepository;
|
_emergencyAccessRepository = emergencyAccessRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
@ -53,9 +53,9 @@ namespace Bit.Core.Services
|
|||||||
_mailService = mailService;
|
_mailService = mailService;
|
||||||
_userService = userService;
|
_userService = userService;
|
||||||
_passwordHasher = passwordHasher;
|
_passwordHasher = passwordHasher;
|
||||||
_dataProtector = dataProtectionProvider.CreateProtector("EmergencyAccessServiceDataProtector");
|
|
||||||
_globalSettings = globalSettings;
|
_globalSettings = globalSettings;
|
||||||
_organizationService = organizationService;
|
_organizationService = organizationService;
|
||||||
|
_dataProtectorTokenizer = dataProtectorTokenizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<EmergencyAccess> InviteAsync(User invitingUser, string email, EmergencyAccessType type, int waitTime)
|
public async Task<EmergencyAccess> InviteAsync(User invitingUser, string email, EmergencyAccessType type, int waitTime)
|
||||||
@ -118,8 +118,7 @@ namespace Bit.Core.Services
|
|||||||
throw new BadRequestException("Emergency Access not valid.");
|
throw new BadRequestException("Emergency Access not valid.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!CoreHelpers.TokenIsValid("EmergencyAccessInvite", _dataProtector, token, user.Email, emergencyAccessId,
|
if (!_dataProtectorTokenizer.TryUnprotect(token, out var data) && data.IsValid(emergencyAccessId, user.Email))
|
||||||
_globalSettings.OrganizationInviteExpirationHours))
|
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Invalid token.");
|
throw new BadRequestException("Invalid token.");
|
||||||
}
|
}
|
||||||
@ -403,8 +402,7 @@ namespace Bit.Core.Services
|
|||||||
|
|
||||||
private async Task SendInviteAsync(EmergencyAccess emergencyAccess, string invitingUsersName)
|
private async Task SendInviteAsync(EmergencyAccess emergencyAccess, string invitingUsersName)
|
||||||
{
|
{
|
||||||
var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow);
|
var token = _dataProtectorTokenizer.Protect(new EmergencyAccessInviteTokenable(emergencyAccess, _globalSettings.OrganizationInviteExpirationHours));
|
||||||
var token = _dataProtector.Protect($"EmergencyAccessInvite {emergencyAccess.Id} {emergencyAccess.Email} {nowMillis}");
|
|
||||||
await _mailService.SendEmergencyAccessInviteEmailAsync(emergencyAccess, invitingUsersName, token);
|
await _mailService.SendEmergencyAccessInviteEmailAsync(emergencyAccess, invitingUsersName, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,10 +3,10 @@ using System.Collections.Generic;
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Models.Business.Tokenables;
|
||||||
using Bit.Core.Models.Table;
|
using Bit.Core.Models.Table;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Tokens;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
@ -14,31 +14,27 @@ namespace Bit.Core.Services
|
|||||||
{
|
{
|
||||||
public class HCaptchaValidationService : ICaptchaValidationService
|
public class HCaptchaValidationService : ICaptchaValidationService
|
||||||
{
|
{
|
||||||
private const double TokenLifetimeInHours = (double)5 / 60; // 5 minutes
|
|
||||||
private const string TokenName = "CaptchaBypassToken";
|
|
||||||
private const string TokenClearTextPrefix = "BWCaptchaBypass_";
|
|
||||||
private readonly ILogger<HCaptchaValidationService> _logger;
|
private readonly ILogger<HCaptchaValidationService> _logger;
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly GlobalSettings _globalSettings;
|
private readonly GlobalSettings _globalSettings;
|
||||||
private readonly IDataProtector _dataProtector;
|
private readonly IDataProtectorTokenFactory<HCaptchaTokenable> _tokenizer;
|
||||||
|
|
||||||
public HCaptchaValidationService(
|
public HCaptchaValidationService(
|
||||||
ILogger<HCaptchaValidationService> logger,
|
ILogger<HCaptchaValidationService> logger,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IDataProtectionProvider dataProtectorProvider,
|
IDataProtectorTokenFactory<HCaptchaTokenable> tokenizer,
|
||||||
GlobalSettings globalSettings)
|
GlobalSettings globalSettings)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_globalSettings = globalSettings;
|
_globalSettings = globalSettings;
|
||||||
_dataProtector = dataProtectorProvider.CreateProtector("CaptchaServiceDataProtector");
|
_tokenizer = tokenizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string SiteKeyResponseKeyName => "HCaptcha_SiteKey";
|
public string SiteKeyResponseKeyName => "HCaptcha_SiteKey";
|
||||||
public string SiteKey => _globalSettings.Captcha.HCaptchaSiteKey;
|
public string SiteKey => _globalSettings.Captcha.HCaptchaSiteKey;
|
||||||
|
|
||||||
public string GenerateCaptchaBypassToken(User user) =>
|
public string GenerateCaptchaBypassToken(User user) => _tokenizer.Protect(new HCaptchaTokenable(user));
|
||||||
$"{TokenClearTextPrefix}{_dataProtector.Protect(CaptchaBypassTokenContent(user))}";
|
|
||||||
|
|
||||||
public bool ValidateCaptchaBypassToken(string bypassToken, User user) =>
|
public bool ValidateCaptchaBypassToken(string bypassToken, User user) =>
|
||||||
TokenIsApiKey(bypassToken, user) || TokenIsCaptchaBypassToken(bypassToken, user);
|
TokenIsApiKey(bypassToken, user) || TokenIsCaptchaBypassToken(bypassToken, user);
|
||||||
@ -89,20 +85,12 @@ namespace Bit.Core.Services
|
|||||||
public bool RequireCaptchaValidation(ICurrentContext currentContext) =>
|
public bool RequireCaptchaValidation(ICurrentContext currentContext) =>
|
||||||
currentContext.IsBot || _globalSettings.Captcha.ForceCaptchaRequired;
|
currentContext.IsBot || _globalSettings.Captcha.ForceCaptchaRequired;
|
||||||
|
|
||||||
private static string CaptchaBypassTokenContent(User user) =>
|
|
||||||
string.Join(' ', new object[] {
|
|
||||||
TokenName,
|
|
||||||
user?.Id,
|
|
||||||
user?.Email,
|
|
||||||
CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow.AddHours(TokenLifetimeInHours))
|
|
||||||
});
|
|
||||||
|
|
||||||
private static bool TokenIsApiKey(string bypassToken, User user) =>
|
private static bool TokenIsApiKey(string bypassToken, User user) =>
|
||||||
!string.IsNullOrWhiteSpace(bypassToken) && user != null && user.ApiKey == bypassToken;
|
!string.IsNullOrWhiteSpace(bypassToken) && user != null && user.ApiKey == bypassToken;
|
||||||
private bool TokenIsCaptchaBypassToken(string encryptedToken, User user) =>
|
private bool TokenIsCaptchaBypassToken(string encryptedToken, User user)
|
||||||
encryptedToken.StartsWith(TokenClearTextPrefix) && user != null &&
|
{
|
||||||
CoreHelpers.TokenIsValid(TokenName, _dataProtector, encryptedToken[TokenClearTextPrefix.Length..],
|
return _tokenizer.TryUnprotect(encryptedToken, out var data) &&
|
||||||
user.Email, user.Id, TokenLifetimeInHours);
|
data.Valid && data.TokenIsValid(user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
15
src/Core/Tokens/BadTokenException.cs
Normal file
15
src/Core/Tokens/BadTokenException.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Bit.Core.Tokens
|
||||||
|
{
|
||||||
|
public class BadTokenException : Exception
|
||||||
|
{
|
||||||
|
public BadTokenException()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public BadTokenException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
48
src/Core/Tokens/DataProtectorTokenFactory.cs
Normal file
48
src/Core/Tokens/DataProtectorTokenFactory.cs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
|
||||||
|
namespace Bit.Core.Tokens
|
||||||
|
{
|
||||||
|
public class DataProtectorTokenFactory<T> : IDataProtectorTokenFactory<T> where T : Tokenable
|
||||||
|
{
|
||||||
|
private readonly IDataProtector _dataProtector;
|
||||||
|
private readonly string _clearTextPrefix;
|
||||||
|
|
||||||
|
public DataProtectorTokenFactory(string clearTextPrefix, string purpose, IDataProtectionProvider dataProtectionProvider)
|
||||||
|
{
|
||||||
|
_dataProtector = dataProtectionProvider.CreateProtector(purpose);
|
||||||
|
_clearTextPrefix = clearTextPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Protect(T data) =>
|
||||||
|
data.ToToken().ProtectWith(_dataProtector).WithPrefix(_clearTextPrefix).ToString();
|
||||||
|
|
||||||
|
public T Unprotect(string token) =>
|
||||||
|
Tokenable.FromToken<T>(new Token(token).RemovePrefix(_clearTextPrefix).UnprotectWith(_dataProtector).ToString());
|
||||||
|
|
||||||
|
public bool TokenValid(string token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Unprotect(token).Valid;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryUnprotect(string token, out T data)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
data = Unprotect(token);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
data = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
15
src/Core/Tokens/ExpiringTokenable.cs
Normal file
15
src/Core/Tokens/ExpiringTokenable.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
|
||||||
|
namespace Bit.Core.Tokens
|
||||||
|
{
|
||||||
|
public abstract class ExpiringTokenable : Tokenable
|
||||||
|
{
|
||||||
|
[JsonConverter(typeof(EpochDateTimeJsonConverter))]
|
||||||
|
public DateTime ExpirationDate { get; set; }
|
||||||
|
public override bool Valid => ExpirationDate > DateTime.UtcNow && TokenIsValid();
|
||||||
|
|
||||||
|
protected abstract bool TokenIsValid();
|
||||||
|
}
|
||||||
|
}
|
10
src/Core/Tokens/IDataProtectorTokenFactory.cs
Normal file
10
src/Core/Tokens/IDataProtectorTokenFactory.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace Bit.Core.Tokens
|
||||||
|
{
|
||||||
|
public interface IDataProtectorTokenFactory<T> where T : Tokenable
|
||||||
|
{
|
||||||
|
string Protect(T data);
|
||||||
|
T Unprotect(string token);
|
||||||
|
bool TryUnprotect(string token, out T data);
|
||||||
|
bool TokenValid(string token);
|
||||||
|
}
|
||||||
|
}
|
37
src/Core/Tokens/Token.cs
Normal file
37
src/Core/Tokens/Token.cs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
|
||||||
|
namespace Bit.Core.Tokens
|
||||||
|
{
|
||||||
|
public class Token
|
||||||
|
{
|
||||||
|
private readonly string _token;
|
||||||
|
|
||||||
|
public Token(string token)
|
||||||
|
{
|
||||||
|
_token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Token WithPrefix(string prefix)
|
||||||
|
{
|
||||||
|
return new Token($"{prefix}{_token}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Token RemovePrefix(string expectedPrefix)
|
||||||
|
{
|
||||||
|
if (!_token.StartsWith(expectedPrefix))
|
||||||
|
{
|
||||||
|
throw new BadTokenException($"Expected prefix, {expectedPrefix}, was not present.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Token(_token[expectedPrefix.Length..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Token ProtectWith(IDataProtector dataProtector) =>
|
||||||
|
new(dataProtector.Protect(ToString()));
|
||||||
|
|
||||||
|
public Token UnprotectWith(IDataProtector dataProtector) =>
|
||||||
|
new(dataProtector.Unprotect(ToString()));
|
||||||
|
|
||||||
|
public override string ToString() => _token;
|
||||||
|
}
|
||||||
|
}
|
20
src/Core/Tokens/Tokenable.cs
Normal file
20
src/Core/Tokens/Tokenable.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Bit.Core.Tokens
|
||||||
|
{
|
||||||
|
public abstract class Tokenable
|
||||||
|
{
|
||||||
|
public abstract bool Valid { get; }
|
||||||
|
|
||||||
|
public Token ToToken()
|
||||||
|
{
|
||||||
|
return new Token(JsonSerializer.Serialize(this, this.GetType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static T FromToken<T>(string token) => FromToken<T>(new Token(token));
|
||||||
|
public static T FromToken<T>(Token token)
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<T>(token.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
18
src/Core/Utilities/EpochDateTimeJsonConverter.cs
Normal file
18
src/Core/Utilities/EpochDateTimeJsonConverter.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Bit.Core.Utilities
|
||||||
|
{
|
||||||
|
public class EpochDateTimeJsonConverter : JsonConverter<DateTime>
|
||||||
|
{
|
||||||
|
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
return CoreHelpers.FromEpocMilliseconds(reader.GetInt64());
|
||||||
|
}
|
||||||
|
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
writer.WriteNumberValue(CoreHelpers.ToEpocMilliseconds(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -9,11 +9,13 @@ using AutoMapper;
|
|||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Identity;
|
using Bit.Core.Identity;
|
||||||
using Bit.Core.IdentityServer;
|
using Bit.Core.IdentityServer;
|
||||||
|
using Bit.Core.Models.Business.Tokenables;
|
||||||
using Bit.Core.Models.Table;
|
using Bit.Core.Models.Table;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Resources;
|
using Bit.Core.Resources;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
|
using Bit.Core.Tokens;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using IdentityModel;
|
using IdentityModel;
|
||||||
using IdentityServer4.AccessTokenValidation;
|
using IdentityServer4.AccessTokenValidation;
|
||||||
@ -181,6 +183,22 @@ namespace Bit.Core.Utilities
|
|||||||
services.AddScoped<ISendService, SendService>();
|
services.AddScoped<ISendService, SendService>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void AddTokenizers(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<IDataProtectorTokenFactory<EmergencyAccessInviteTokenable>>(serviceProvider =>
|
||||||
|
new DataProtectorTokenFactory<EmergencyAccessInviteTokenable>(
|
||||||
|
EmergencyAccessInviteTokenable.ClearTextPrefix,
|
||||||
|
EmergencyAccessInviteTokenable.DataProtectorPurpose,
|
||||||
|
serviceProvider.GetDataProtectionProvider())
|
||||||
|
);
|
||||||
|
services.AddSingleton<IDataProtectorTokenFactory<HCaptchaTokenable>>(serviceProvider =>
|
||||||
|
new DataProtectorTokenFactory<HCaptchaTokenable>(
|
||||||
|
HCaptchaTokenable.ClearTextPrefix,
|
||||||
|
HCaptchaTokenable.DataProtectorPurpose,
|
||||||
|
serviceProvider.GetDataProtectionProvider())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings)
|
public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings)
|
||||||
{
|
{
|
||||||
// Required for UserService
|
// Required for UserService
|
||||||
@ -201,6 +219,7 @@ namespace Bit.Core.Utilities
|
|||||||
services.AddSingleton<IPaymentService, StripePaymentService>();
|
services.AddSingleton<IPaymentService, StripePaymentService>();
|
||||||
services.AddSingleton<IMailService, HandlebarsMailService>();
|
services.AddSingleton<IMailService, HandlebarsMailService>();
|
||||||
services.AddSingleton<ILicensingService, LicensingService>();
|
services.AddSingleton<ILicensingService, LicensingService>();
|
||||||
|
services.AddTokenizers();
|
||||||
|
|
||||||
if (CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) &&
|
if (CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) &&
|
||||||
CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName))
|
CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName))
|
||||||
|
@ -118,6 +118,11 @@ namespace Bit.Test.Common.AutoFixture
|
|||||||
{
|
{
|
||||||
return _sutProvider.GetDependency(parameterInfo.ParameterType, parameterInfo.Name);
|
return _sutProvider.GetDependency(parameterInfo.ParameterType, parameterInfo.Name);
|
||||||
}
|
}
|
||||||
|
// Return default type if set
|
||||||
|
else if (_sutProvider.DependencyIsSet(parameterInfo.ParameterType, ""))
|
||||||
|
{
|
||||||
|
return _sutProvider.GetDependency(parameterInfo.ParameterType, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// This is the equivalent of _fixture.Create<parameterInfo.ParameterType>, but no overload for
|
// This is the equivalent of _fixture.Create<parameterInfo.ParameterType>, but no overload for
|
||||||
|
@ -0,0 +1,34 @@
|
|||||||
|
using System;
|
||||||
|
using AutoFixture.Xunit2;
|
||||||
|
using Bit.Core.Models.Business.Tokenables;
|
||||||
|
using Bit.Core.Models.Table;
|
||||||
|
using Bit.Core.Tokens;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.Models.Business.Tokenables
|
||||||
|
{
|
||||||
|
public class EmergencyAccessInviteTokenableTests
|
||||||
|
{
|
||||||
|
[Theory, AutoData]
|
||||||
|
public void SerializationSetsCorrectDateTime(EmergencyAccess emergencyAccess)
|
||||||
|
{
|
||||||
|
var token = new EmergencyAccessInviteTokenable(emergencyAccess, 2);
|
||||||
|
Assert.Equal(Tokenable.FromToken<EmergencyAccessInviteTokenable>(token.ToToken().ToString()).ExpirationDate,
|
||||||
|
token.ExpirationDate,
|
||||||
|
TimeSpan.FromMilliseconds(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsInvalidIfIdentifierIsWrong()
|
||||||
|
{
|
||||||
|
var token = new EmergencyAccessInviteTokenable(DateTime.MaxValue)
|
||||||
|
{
|
||||||
|
Email = "email",
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Identifier = "not correct"
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.False(token.Valid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using AutoFixture.Xunit2;
|
||||||
|
using Bit.Core.Models.Business.Tokenables;
|
||||||
|
using Bit.Core.Models.Table;
|
||||||
|
using Bit.Core.Tokens;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.Models.Business.Tokenables
|
||||||
|
{
|
||||||
|
public class HCaptchaTokenableTests
|
||||||
|
{
|
||||||
|
[Theory, AutoData]
|
||||||
|
public void CanUpdateExpirationToNonStandard(User user)
|
||||||
|
{
|
||||||
|
var token = new HCaptchaTokenable(user)
|
||||||
|
{
|
||||||
|
ExpirationDate = DateTime.MinValue
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(DateTime.MinValue, token.ExpirationDate, TimeSpan.FromMilliseconds(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, AutoData]
|
||||||
|
public void SetsDataFromUser(User user)
|
||||||
|
{
|
||||||
|
var token = new HCaptchaTokenable(user);
|
||||||
|
|
||||||
|
Assert.Equal(user.Id, token.Id);
|
||||||
|
Assert.Equal(user.Email, token.Email);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, AutoData]
|
||||||
|
public void SerializationSetsCorrectDateTime(User user)
|
||||||
|
{
|
||||||
|
var expectedDateTime = DateTime.UtcNow.AddHours(-5);
|
||||||
|
var token = new HCaptchaTokenable(user)
|
||||||
|
{
|
||||||
|
ExpirationDate = expectedDateTime
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = Tokenable.FromToken<HCaptchaTokenable>(token.ToToken());
|
||||||
|
|
||||||
|
Assert.Equal(expectedDateTime, result.ExpirationDate, TimeSpan.FromMilliseconds(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, AutoData]
|
||||||
|
public void IsInvalidIfIdentifierIsWrong(User user)
|
||||||
|
{
|
||||||
|
var token = new HCaptchaTokenable(user)
|
||||||
|
{
|
||||||
|
Identifier = "not correct"
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.False(token.Valid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
54
test/Core.Test/Tokens/DataProtectorTokenFactoryTests.cs
Normal file
54
test/Core.Test/Tokens/DataProtectorTokenFactoryTests.cs
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
using AutoFixture;
|
||||||
|
using Bit.Core.Tokens;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Bit.Test.Common.Helpers;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.Tokens
|
||||||
|
{
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class DataProtectorTokenFactoryTests
|
||||||
|
{
|
||||||
|
public static SutProvider<DataProtectorTokenFactory<TestTokenable>> GetSutProvider()
|
||||||
|
{
|
||||||
|
var fixture = new Fixture();
|
||||||
|
return new SutProvider<DataProtectorTokenFactory<TestTokenable>>(fixture)
|
||||||
|
.SetDependency<IDataProtectionProvider>(fixture.Create<EphemeralDataProtectionProvider>())
|
||||||
|
.Create();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public void CanRoundTripTokenables(TestTokenable tokenable)
|
||||||
|
{
|
||||||
|
var sutProvider = GetSutProvider();
|
||||||
|
|
||||||
|
var token = sutProvider.Sut.Protect(tokenable);
|
||||||
|
var recoveredTokenable = sutProvider.Sut.Unprotect(token);
|
||||||
|
|
||||||
|
AssertHelper.AssertPropertyEqual(tokenable, recoveredTokenable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public void PrependsClearText(TestTokenable tokenable)
|
||||||
|
{
|
||||||
|
var sutProvider = GetSutProvider();
|
||||||
|
|
||||||
|
var token = sutProvider.Sut.Protect(tokenable);
|
||||||
|
|
||||||
|
Assert.StartsWith(sutProvider.GetDependency<string>("clearTextPrefix"), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public void EncryptsToken(TestTokenable tokenable)
|
||||||
|
{
|
||||||
|
var sutProvider = GetSutProvider();
|
||||||
|
var prefix = sutProvider.GetDependency<string>("clearTextPrefix");
|
||||||
|
|
||||||
|
var token = sutProvider.Sut.Protect(tokenable);
|
||||||
|
|
||||||
|
Assert.NotEqual(new Token(token).RemovePrefix(prefix), tokenable.ToToken());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
72
test/Core.Test/Tokens/ExpiringTokenTests.cs
Normal file
72
test/Core.Test/Tokens/ExpiringTokenTests.cs
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using AutoFixture.Xunit2;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.Tokens
|
||||||
|
{
|
||||||
|
public class ExpiringTokenTests
|
||||||
|
{
|
||||||
|
[Theory, AutoData]
|
||||||
|
public void ExpirationSerializesToEpochMilliseconds(DateTime expirationDate)
|
||||||
|
{
|
||||||
|
var sut = new TestExpiringTokenable
|
||||||
|
{
|
||||||
|
ExpirationDate = expirationDate
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = JsonSerializer.Serialize(sut);
|
||||||
|
var expectedDate = CoreHelpers.ToEpocMilliseconds(expirationDate);
|
||||||
|
|
||||||
|
Assert.Contains($"\"ExpirationDate\":{expectedDate}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, AutoData]
|
||||||
|
public void ExpirationSerializationRoundTrip(DateTime expirationDate)
|
||||||
|
{
|
||||||
|
var sut = new TestExpiringTokenable
|
||||||
|
{
|
||||||
|
ExpirationDate = expirationDate
|
||||||
|
};
|
||||||
|
|
||||||
|
var intermediate = JsonSerializer.Serialize(sut);
|
||||||
|
var result = JsonSerializer.Deserialize<TestExpiringTokenable>(intermediate);
|
||||||
|
|
||||||
|
Assert.Equal(sut.ExpirationDate, result.ExpirationDate, TimeSpan.FromMilliseconds(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvalidIfPastExpiryDate()
|
||||||
|
{
|
||||||
|
var sut = new TestExpiringTokenable
|
||||||
|
{
|
||||||
|
ExpirationDate = DateTime.UtcNow.AddHours(-1)
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.False(sut.Valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidIfWithinExpirationAndTokenReportsValid()
|
||||||
|
{
|
||||||
|
var sut = new TestExpiringTokenable
|
||||||
|
{
|
||||||
|
ExpirationDate = DateTime.UtcNow.AddHours(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.True(sut.Valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HonorsTokenIsValidAbstractMember()
|
||||||
|
{
|
||||||
|
var sut = new TestExpiringTokenable(forceInvalid: true)
|
||||||
|
{
|
||||||
|
ExpirationDate = DateTime.UtcNow.AddHours(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.False(sut.Valid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
test/Core.Test/Tokens/TestTokenable.cs
Normal file
22
test/Core.Test/Tokens/TestTokenable.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
using Bit.Core.Tokens;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.Tokens
|
||||||
|
{
|
||||||
|
public class TestTokenable : Tokenable
|
||||||
|
{
|
||||||
|
public override bool Valid => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TestExpiringTokenable : ExpiringTokenable
|
||||||
|
{
|
||||||
|
private bool _forceInvalid;
|
||||||
|
|
||||||
|
public TestExpiringTokenable() : this(false) { }
|
||||||
|
|
||||||
|
public TestExpiringTokenable(bool forceInvalid)
|
||||||
|
{
|
||||||
|
_forceInvalid = forceInvalid;
|
||||||
|
}
|
||||||
|
protected override bool TokenIsValid() => !_forceInvalid;
|
||||||
|
}
|
||||||
|
}
|
39
test/Core.Test/Tokens/TokenTests.cs
Normal file
39
test/Core.Test/Tokens/TokenTests.cs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
using AutoFixture.Xunit2;
|
||||||
|
using Bit.Core.Tokens;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.Tokens
|
||||||
|
{
|
||||||
|
public class TokenTests
|
||||||
|
{
|
||||||
|
[Theory, AutoData]
|
||||||
|
public void InitializeWithString_ReturnsString(string initString)
|
||||||
|
{
|
||||||
|
var token = new Token(initString);
|
||||||
|
|
||||||
|
Assert.Equal(initString, token.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, AutoData]
|
||||||
|
public void AddsPrefix(Token token, string prefix)
|
||||||
|
{
|
||||||
|
Assert.Equal($"{prefix}{token.ToString()}", token.WithPrefix(prefix).ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, AutoData]
|
||||||
|
public void RemovePrefix_WithPrefix_RemovesPrefix(string initString, string prefix)
|
||||||
|
{
|
||||||
|
var token = new Token(initString).WithPrefix(prefix);
|
||||||
|
|
||||||
|
Assert.Equal(initString, token.RemovePrefix(prefix).ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, AutoData]
|
||||||
|
public void RemovePrefix_WithoutPrefix_Throws(Token token, string prefix)
|
||||||
|
{
|
||||||
|
var exception = Assert.Throws<BadTokenException>(() => token.RemovePrefix(prefix));
|
||||||
|
|
||||||
|
Assert.Equal($"Expected prefix, {prefix}, was not present.", exception.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user