diff --git a/src/Core/Models/Business/Tokenables/EmergencyAccessInviteTokenable.cs b/src/Core/Models/Business/Tokenables/EmergencyAccessInviteTokenable.cs new file mode 100644 index 0000000000..ed79d3b431 --- /dev/null +++ b/src/Core/Models/Business/Tokenables/EmergencyAccessInviteTokenable.cs @@ -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); + } +} diff --git a/src/Core/Models/Business/Tokenables/HCaptchaTokenable.cs b/src/Core/Models/Business/Tokenables/HCaptchaTokenable.cs new file mode 100644 index 0000000000..804705b1c4 --- /dev/null +++ b/src/Core/Models/Business/Tokenables/HCaptchaTokenable.cs @@ -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); + } +} diff --git a/src/Core/Services/Implementations/EmergencyAccessService.cs b/src/Core/Services/Implementations/EmergencyAccessService.cs index b51bfbf286..2bd1fc9efd 100644 --- a/src/Core/Services/Implementations/EmergencyAccessService.cs +++ b/src/Core/Services/Implementations/EmergencyAccessService.cs @@ -5,12 +5,12 @@ using System.Threading.Tasks; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models; +using Bit.Core.Models.Business.Tokenables; using Bit.Core.Models.Data; using Bit.Core.Models.Table; using Bit.Core.Repositories; using Bit.Core.Settings; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.DataProtection; +using Bit.Core.Tokens; using Microsoft.AspNetCore.Identity; namespace Bit.Core.Services @@ -25,10 +25,10 @@ namespace Bit.Core.Services private readonly ICipherService _cipherService; private readonly IMailService _mailService; private readonly IUserService _userService; - private readonly IDataProtector _dataProtector; private readonly GlobalSettings _globalSettings; private readonly IPasswordHasher _passwordHasher; private readonly IOrganizationService _organizationService; + private readonly IDataProtectorTokenFactory _dataProtectorTokenizer; public EmergencyAccessService( IEmergencyAccessRepository emergencyAccessRepository, @@ -40,9 +40,9 @@ namespace Bit.Core.Services IMailService mailService, IUserService userService, IPasswordHasher passwordHasher, - IDataProtectionProvider dataProtectionProvider, GlobalSettings globalSettings, - IOrganizationService organizationService) + IOrganizationService organizationService, + IDataProtectorTokenFactory dataProtectorTokenizer) { _emergencyAccessRepository = emergencyAccessRepository; _organizationUserRepository = organizationUserRepository; @@ -53,9 +53,9 @@ namespace Bit.Core.Services _mailService = mailService; _userService = userService; _passwordHasher = passwordHasher; - _dataProtector = dataProtectionProvider.CreateProtector("EmergencyAccessServiceDataProtector"); _globalSettings = globalSettings; _organizationService = organizationService; + _dataProtectorTokenizer = dataProtectorTokenizer; } public async Task InviteAsync(User invitingUser, string email, EmergencyAccessType type, int waitTime) @@ -118,8 +118,7 @@ namespace Bit.Core.Services throw new BadRequestException("Emergency Access not valid."); } - if (!CoreHelpers.TokenIsValid("EmergencyAccessInvite", _dataProtector, token, user.Email, emergencyAccessId, - _globalSettings.OrganizationInviteExpirationHours)) + if (!_dataProtectorTokenizer.TryUnprotect(token, out var data) && data.IsValid(emergencyAccessId, user.Email)) { throw new BadRequestException("Invalid token."); } @@ -403,8 +402,7 @@ namespace Bit.Core.Services private async Task SendInviteAsync(EmergencyAccess emergencyAccess, string invitingUsersName) { - var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow); - var token = _dataProtector.Protect($"EmergencyAccessInvite {emergencyAccess.Id} {emergencyAccess.Email} {nowMillis}"); + var token = _dataProtectorTokenizer.Protect(new EmergencyAccessInviteTokenable(emergencyAccess, _globalSettings.OrganizationInviteExpirationHours)); await _mailService.SendEmergencyAccessInviteEmailAsync(emergencyAccess, invitingUsersName, token); } diff --git a/src/Core/Services/Implementations/HCaptchaValidationService.cs b/src/Core/Services/Implementations/HCaptchaValidationService.cs index 4acd6cbfec..a2f92b4df1 100644 --- a/src/Core/Services/Implementations/HCaptchaValidationService.cs +++ b/src/Core/Services/Implementations/HCaptchaValidationService.cs @@ -3,10 +3,10 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; using Bit.Core.Context; +using Bit.Core.Models.Business.Tokenables; using Bit.Core.Models.Table; using Bit.Core.Settings; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.DataProtection; +using Bit.Core.Tokens; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -14,31 +14,27 @@ namespace Bit.Core.Services { 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 _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly GlobalSettings _globalSettings; - private readonly IDataProtector _dataProtector; + private readonly IDataProtectorTokenFactory _tokenizer; public HCaptchaValidationService( ILogger logger, IHttpClientFactory httpClientFactory, - IDataProtectionProvider dataProtectorProvider, + IDataProtectorTokenFactory tokenizer, GlobalSettings globalSettings) { _logger = logger; _httpClientFactory = httpClientFactory; _globalSettings = globalSettings; - _dataProtector = dataProtectorProvider.CreateProtector("CaptchaServiceDataProtector"); + _tokenizer = tokenizer; } public string SiteKeyResponseKeyName => "HCaptcha_SiteKey"; public string SiteKey => _globalSettings.Captcha.HCaptchaSiteKey; - public string GenerateCaptchaBypassToken(User user) => - $"{TokenClearTextPrefix}{_dataProtector.Protect(CaptchaBypassTokenContent(user))}"; + public string GenerateCaptchaBypassToken(User user) => _tokenizer.Protect(new HCaptchaTokenable(user)); public bool ValidateCaptchaBypassToken(string bypassToken, User user) => TokenIsApiKey(bypassToken, user) || TokenIsCaptchaBypassToken(bypassToken, user); @@ -89,20 +85,12 @@ namespace Bit.Core.Services public bool RequireCaptchaValidation(ICurrentContext currentContext) => 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) => !string.IsNullOrWhiteSpace(bypassToken) && user != null && user.ApiKey == bypassToken; - private bool TokenIsCaptchaBypassToken(string encryptedToken, User user) => - encryptedToken.StartsWith(TokenClearTextPrefix) && user != null && - CoreHelpers.TokenIsValid(TokenName, _dataProtector, encryptedToken[TokenClearTextPrefix.Length..], - user.Email, user.Id, TokenLifetimeInHours); - + private bool TokenIsCaptchaBypassToken(string encryptedToken, User user) + { + return _tokenizer.TryUnprotect(encryptedToken, out var data) && + data.Valid && data.TokenIsValid(user); + } } } diff --git a/src/Core/Tokens/BadTokenException.cs b/src/Core/Tokens/BadTokenException.cs new file mode 100644 index 0000000000..9a5d8f4da2 --- /dev/null +++ b/src/Core/Tokens/BadTokenException.cs @@ -0,0 +1,15 @@ +using System; + +namespace Bit.Core.Tokens +{ + public class BadTokenException : Exception + { + public BadTokenException() + { + } + + public BadTokenException(string message) : base(message) + { + } + } +} diff --git a/src/Core/Tokens/DataProtectorTokenFactory.cs b/src/Core/Tokens/DataProtectorTokenFactory.cs new file mode 100644 index 0000000000..17f7873ae7 --- /dev/null +++ b/src/Core/Tokens/DataProtectorTokenFactory.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.DataProtection; + +namespace Bit.Core.Tokens +{ + public class DataProtectorTokenFactory : IDataProtectorTokenFactory 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(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; + } + } + } +} diff --git a/src/Core/Tokens/ExpiringTokenable.cs b/src/Core/Tokens/ExpiringTokenable.cs new file mode 100644 index 0000000000..cd7b66d227 --- /dev/null +++ b/src/Core/Tokens/ExpiringTokenable.cs @@ -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(); + } +} diff --git a/src/Core/Tokens/IDataProtectorTokenFactory.cs b/src/Core/Tokens/IDataProtectorTokenFactory.cs new file mode 100644 index 0000000000..038eff0f7d --- /dev/null +++ b/src/Core/Tokens/IDataProtectorTokenFactory.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Tokens +{ + public interface IDataProtectorTokenFactory where T : Tokenable + { + string Protect(T data); + T Unprotect(string token); + bool TryUnprotect(string token, out T data); + bool TokenValid(string token); + } +} diff --git a/src/Core/Tokens/Token.cs b/src/Core/Tokens/Token.cs new file mode 100644 index 0000000000..396b8747d5 --- /dev/null +++ b/src/Core/Tokens/Token.cs @@ -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; + } +} diff --git a/src/Core/Tokens/Tokenable.cs b/src/Core/Tokens/Tokenable.cs new file mode 100644 index 0000000000..c5c57c2f74 --- /dev/null +++ b/src/Core/Tokens/Tokenable.cs @@ -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(string token) => FromToken(new Token(token)); + public static T FromToken(Token token) + { + return JsonSerializer.Deserialize(token.ToString()); + } + } +} diff --git a/src/Core/Utilities/EpochDateTimeJsonConverter.cs b/src/Core/Utilities/EpochDateTimeJsonConverter.cs new file mode 100644 index 0000000000..4f3b36c42c --- /dev/null +++ b/src/Core/Utilities/EpochDateTimeJsonConverter.cs @@ -0,0 +1,18 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Bit.Core.Utilities +{ + public class EpochDateTimeJsonConverter : JsonConverter + { + 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)); + } + } +} diff --git a/src/Core/Utilities/ServiceCollectionExtensions.cs b/src/Core/Utilities/ServiceCollectionExtensions.cs index 8e28f5a6ab..8431d9d8cd 100644 --- a/src/Core/Utilities/ServiceCollectionExtensions.cs +++ b/src/Core/Utilities/ServiceCollectionExtensions.cs @@ -9,11 +9,13 @@ using AutoMapper; using Bit.Core.Enums; using Bit.Core.Identity; using Bit.Core.IdentityServer; +using Bit.Core.Models.Business.Tokenables; using Bit.Core.Models.Table; using Bit.Core.Repositories; using Bit.Core.Resources; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Tokens; using Bit.Core.Utilities; using IdentityModel; using IdentityServer4.AccessTokenValidation; @@ -181,6 +183,22 @@ namespace Bit.Core.Utilities services.AddScoped(); } + public static void AddTokenizers(this IServiceCollection services) + { + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + EmergencyAccessInviteTokenable.ClearTextPrefix, + EmergencyAccessInviteTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider()) + ); + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + HCaptchaTokenable.ClearTextPrefix, + HCaptchaTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider()) + ); + } + public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings) { // Required for UserService @@ -201,6 +219,7 @@ namespace Bit.Core.Utilities services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddTokenizers(); if (CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) && CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName)) diff --git a/test/Common/AutoFixture/SutProvider.cs b/test/Common/AutoFixture/SutProvider.cs index e7fc104491..32578ead9f 100644 --- a/test/Common/AutoFixture/SutProvider.cs +++ b/test/Common/AutoFixture/SutProvider.cs @@ -118,6 +118,11 @@ namespace Bit.Test.Common.AutoFixture { 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, but no overload for diff --git a/test/Core.Test/Models/Business/Tokenables/EmergencyAccessInviteTokenableTests.cs b/test/Core.Test/Models/Business/Tokenables/EmergencyAccessInviteTokenableTests.cs new file mode 100644 index 0000000000..48d0245cac --- /dev/null +++ b/test/Core.Test/Models/Business/Tokenables/EmergencyAccessInviteTokenableTests.cs @@ -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(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); + } + } +} diff --git a/test/Core.Test/Models/Business/Tokenables/HCaptchaTokenableTests.cs b/test/Core.Test/Models/Business/Tokenables/HCaptchaTokenableTests.cs new file mode 100644 index 0000000000..47b49f4ee9 --- /dev/null +++ b/test/Core.Test/Models/Business/Tokenables/HCaptchaTokenableTests.cs @@ -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(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); + } + } +} diff --git a/test/Core.Test/Tokens/DataProtectorTokenFactoryTests.cs b/test/Core.Test/Tokens/DataProtectorTokenFactoryTests.cs new file mode 100644 index 0000000000..35fcd33a58 --- /dev/null +++ b/test/Core.Test/Tokens/DataProtectorTokenFactoryTests.cs @@ -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> GetSutProvider() + { + var fixture = new Fixture(); + return new SutProvider>(fixture) + .SetDependency(fixture.Create()) + .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("clearTextPrefix"), token); + } + + [Theory, BitAutoData] + public void EncryptsToken(TestTokenable tokenable) + { + var sutProvider = GetSutProvider(); + var prefix = sutProvider.GetDependency("clearTextPrefix"); + + var token = sutProvider.Sut.Protect(tokenable); + + Assert.NotEqual(new Token(token).RemovePrefix(prefix), tokenable.ToToken()); + } + } +} diff --git a/test/Core.Test/Tokens/ExpiringTokenTests.cs b/test/Core.Test/Tokens/ExpiringTokenTests.cs new file mode 100644 index 0000000000..b70f6b33bd --- /dev/null +++ b/test/Core.Test/Tokens/ExpiringTokenTests.cs @@ -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(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); + } + } +} diff --git a/test/Core.Test/Tokens/TestTokenable.cs b/test/Core.Test/Tokens/TestTokenable.cs new file mode 100644 index 0000000000..0f2e2536c9 --- /dev/null +++ b/test/Core.Test/Tokens/TestTokenable.cs @@ -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; + } +} diff --git a/test/Core.Test/Tokens/TokenTests.cs b/test/Core.Test/Tokens/TokenTests.cs new file mode 100644 index 0000000000..bc1ad85688 --- /dev/null +++ b/test/Core.Test/Tokens/TokenTests.cs @@ -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(() => token.RemovePrefix(prefix)); + + Assert.Equal($"Expected prefix, {prefix}, was not present.", exception.Message); + } + } +}