From 4e4a720aa4915bbd7f99ca2cc3c39afdde4d81e5 Mon Sep 17 00:00:00 2001
From: Wvader <34067397+wvader@users.noreply.github.com>
Date: Mon, 26 Sep 2022 02:40:18 +0100
Subject: [PATCH] Add auth module
---
BlueWest.Data.Auth/BlueWest.Data.Auth.csproj | 47 +++++++
BlueWest.Data.Auth/Class1.cs | 5 +
BlueWest.Data.Auth/Session/ISessionCache.cs | 56 ++++++++
BlueWest.Data.Auth/Session/LoginRequest.cs | 39 ++++++
.../Session/RegisterViewModel.cs | 62 +++++++++
.../Session/SessionConstants.cs | 14 ++
.../Users/ApplicationUserManager.cs | 114 +++++++++++++++
BlueWest.Data.Auth/Users/Auth/AuthConsts.cs | 17 +++
BlueWest.Data.Auth/Users/Auth/AuthManager.cs | 130 ++++++++++++++++++
.../Users/Auth/Crypto/BaseCryptoItem.cs | 98 +++++++++++++
.../Users/Auth/Crypto/Hasher.cs | 114 +++++++++++++++
.../Users/Auth/Crypto/IHasher.cs | 33 +++++
.../Users/Auth/Crypto/IJwtFactory.cs | 8 ++
.../Users/Auth/Crypto/IJwtTokenHandler.cs | 12 ++
.../Users/Auth/Crypto/ISessionHasher.cs | 12 ++
.../Users/Auth/Crypto/ITokenFactory.cs | 6 +
.../Users/Auth/Crypto/JwtFactory.cs | 85 ++++++++++++
.../Users/Auth/Crypto/JwtIssuerOptions.cs | 54 ++++++++
.../Users/Auth/Crypto/JwtTokenHandler.cs | 53 +++++++
.../Users/Auth/Crypto/SHA_512.cs | 62 +++++++++
BlueWest.Data.Auth/Users/Auth/IAuthManager.cs | 29 ++++
.../Users/Auth/SignInManager.cs | 33 +++++
BlueWest.Data.Auth/Users/Constants.cs | 24 ++++
BlueWest.Data.Auth/Users/IUserManager.cs | 35 +++++
BlueWest.Data.Auth/Users/UserRepository.cs | 69 ++++++++++
BlueWest.sln | 6 +
26 files changed, 1217 insertions(+)
create mode 100644 BlueWest.Data.Auth/BlueWest.Data.Auth.csproj
create mode 100644 BlueWest.Data.Auth/Class1.cs
create mode 100644 BlueWest.Data.Auth/Session/ISessionCache.cs
create mode 100644 BlueWest.Data.Auth/Session/LoginRequest.cs
create mode 100644 BlueWest.Data.Auth/Session/RegisterViewModel.cs
create mode 100644 BlueWest.Data.Auth/Session/SessionConstants.cs
create mode 100644 BlueWest.Data.Auth/Users/ApplicationUserManager.cs
create mode 100644 BlueWest.Data.Auth/Users/Auth/AuthConsts.cs
create mode 100644 BlueWest.Data.Auth/Users/Auth/AuthManager.cs
create mode 100644 BlueWest.Data.Auth/Users/Auth/Crypto/BaseCryptoItem.cs
create mode 100644 BlueWest.Data.Auth/Users/Auth/Crypto/Hasher.cs
create mode 100644 BlueWest.Data.Auth/Users/Auth/Crypto/IHasher.cs
create mode 100644 BlueWest.Data.Auth/Users/Auth/Crypto/IJwtFactory.cs
create mode 100644 BlueWest.Data.Auth/Users/Auth/Crypto/IJwtTokenHandler.cs
create mode 100644 BlueWest.Data.Auth/Users/Auth/Crypto/ISessionHasher.cs
create mode 100644 BlueWest.Data.Auth/Users/Auth/Crypto/ITokenFactory.cs
create mode 100644 BlueWest.Data.Auth/Users/Auth/Crypto/JwtFactory.cs
create mode 100644 BlueWest.Data.Auth/Users/Auth/Crypto/JwtIssuerOptions.cs
create mode 100644 BlueWest.Data.Auth/Users/Auth/Crypto/JwtTokenHandler.cs
create mode 100644 BlueWest.Data.Auth/Users/Auth/Crypto/SHA_512.cs
create mode 100644 BlueWest.Data.Auth/Users/Auth/IAuthManager.cs
create mode 100644 BlueWest.Data.Auth/Users/Auth/SignInManager.cs
create mode 100644 BlueWest.Data.Auth/Users/Constants.cs
create mode 100644 BlueWest.Data.Auth/Users/IUserManager.cs
create mode 100644 BlueWest.Data.Auth/Users/UserRepository.cs
diff --git a/BlueWest.Data.Auth/BlueWest.Data.Auth.csproj b/BlueWest.Data.Auth/BlueWest.Data.Auth.csproj
new file mode 100644
index 0000000..bc92b8f
--- /dev/null
+++ b/BlueWest.Data.Auth/BlueWest.Data.Auth.csproj
@@ -0,0 +1,47 @@
+
+
+
+ net6.0
+ enable
+ enable
+
+
+
+ net6.0
+ 10
+ BlueWest.WebApi
+ true
+ true
+ bin\$(Configuration)\$(AssemblyName).xml
+ true
+ preview
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/BlueWest.Data.Auth/Class1.cs b/BlueWest.Data.Auth/Class1.cs
new file mode 100644
index 0000000..77bc2b9
--- /dev/null
+++ b/BlueWest.Data.Auth/Class1.cs
@@ -0,0 +1,5 @@
+namespace BlueWest.Data.Auth;
+
+public class Class1
+{
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Session/ISessionCache.cs b/BlueWest.Data.Auth/Session/ISessionCache.cs
new file mode 100644
index 0000000..d1eee22
--- /dev/null
+++ b/BlueWest.Data.Auth/Session/ISessionCache.cs
@@ -0,0 +1,56 @@
+using System.Threading.Tasks;
+using BlueWest.Data.Application;
+using BlueWest.WebApi.Context.Users;
+using Microsoft.Extensions.Hosting;
+
+namespace BlueWest.WebApi
+{
+ ///
+ /// Methods for handling session cache data.
+ ///
+ public interface ISessionCache : IHostedService
+ {
+ ///
+ /// Gets a Session Token by Id.
+ ///
+ ///
+ ///
+ Task GetSessionTokenByIdAsync(string tokenId);
+ ///
+ /// Create a new session token
+ ///
+ ///
+ Task AddSessionToken(SessionToken token);
+
+ ///
+ /// Check for validity of the session
+ ///
+ ///
+ ///
+ Task IsSessionValidAsync(string sessionTokenId);
+
+ ///
+ /// Checks if the session is valid
+ ///
+ ///
+ ///
+ bool IsSessionValid(string sessionTokenId);
+
+
+
+ ///
+ /// Save Cache
+ ///
+ ///
+ Task SaveAsync();
+
+ ///
+ /// Save Cache
+ ///
+ ///
+ void Save();
+
+
+ }
+}
+
diff --git a/BlueWest.Data.Auth/Session/LoginRequest.cs b/BlueWest.Data.Auth/Session/LoginRequest.cs
new file mode 100644
index 0000000..02f9fa9
--- /dev/null
+++ b/BlueWest.Data.Auth/Session/LoginRequest.cs
@@ -0,0 +1,39 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace BlueWest.WebApi.Context.Users
+{
+ // from: https://github.com/dotnet/aspnetcore/tree/main/src/Identity/samples/IdentitySample.Mvc/Models/AccountViewModels
+ ///
+ /// Login Request adata
+ ///
+ public class LoginRequest
+ {
+ ///
+ /// Email
+ ///
+ [Required]
+ [EmailAddress]
+ public string Email { get; set; }
+
+ ///
+ /// Password
+ ///
+ [Required]
+ [DataType(DataType.Password)]
+ public string Password { get; set; }
+
+ [Required]
+ public string Uuid { get; set; }
+
+ ///
+ /// Gets Uuid for this login request
+ ///
+ ///
+ public string GetUuid()
+ {
+ return $"{Uuid}|{Email}";
+ }
+
+ }
+}
+
diff --git a/BlueWest.Data.Auth/Session/RegisterViewModel.cs b/BlueWest.Data.Auth/Session/RegisterViewModel.cs
new file mode 100644
index 0000000..dc548a9
--- /dev/null
+++ b/BlueWest.Data.Auth/Session/RegisterViewModel.cs
@@ -0,0 +1,62 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace BlueWest.WebApi.Context.Users;
+
+///
+///
+///
+public class RegisterViewModel
+{
+ ///
+ /// Email
+ ///
+ [Required]
+ [EmailAddress]
+ [Display(Name = "Email")]
+ public string Email { get; set; }
+
+ ///
+ /// Password
+ ///
+ [Required]
+ [StringLength(100, ErrorMessage = "The {0} must be at least {2} characters long.", MinimumLength = 6)]
+ [DataType(DataType.Password)]
+ [Display(Name = "Password")]
+ public string Password { get; set; }
+ ///
+ /// Username
+ ///
+ public string Username { get; set; }
+
+ ///
+ /// ConfirmPassword
+ ///
+ [DataType(DataType.Password)]
+ [Display(Name = "Confirm password")]
+ [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
+ public string ConfirmPassword { get; set; }
+
+
+ ///
+ /// ConfirmPassword
+ ///
+ [DataType(DataType.PhoneNumber)]
+ [Display(Name = "Phone Number")]
+ public string PhoneNumber { get; set; }
+
+
+
+ ///
+ /// Convert RegisterViewModel to ApplicationUser
+ ///
+ ///
+ public ApplicationUser ToUser()
+ {
+ var newUser = new ApplicationUser();
+ newUser.Email = Email;
+ newUser.PasswordHash = Password;
+ newUser.UserName = Username;
+ newUser.PhoneNumber = PhoneNumber;
+ return newUser;
+ }
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Session/SessionConstants.cs b/BlueWest.Data.Auth/Session/SessionConstants.cs
new file mode 100644
index 0000000..a9e4caf
--- /dev/null
+++ b/BlueWest.Data.Auth/Session/SessionConstants.cs
@@ -0,0 +1,14 @@
+using System;
+
+namespace BlueWest.WebApi
+{
+ internal static class SessionConstants
+ {
+
+ public static TimeSpan DefaultSessionMaxAge = TimeSpan.FromHours(24);
+ public const string ApiNamePolicy = "ApiUser";
+ public const string SessionTokenHeaderName = "x-bw2-auth";
+ public const string CookieDomain = "http://localhost:5173";
+ }
+}
+
diff --git a/BlueWest.Data.Auth/Users/ApplicationUserManager.cs b/BlueWest.Data.Auth/Users/ApplicationUserManager.cs
new file mode 100644
index 0000000..c7592b5
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/ApplicationUserManager.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using BlueWest.Cryptography;
+using BlueWest.Data.Application;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace BlueWest.WebApi.Context.Users;
+
+///
+/// User Manager Object
+///
+internal class ApplicationUserManager : UserManager, IUserManager
+{
+ private readonly IHasher _hasher;
+ private readonly UserRepository _usersRepo;
+ public ApplicationUserManager(
+ UserRepository store,
+ IOptions optionsAccessor,
+ IHasher passwordHasher,
+ IEnumerable> userValidators,
+ IEnumerable> passwordValidators,
+ ILookupNormalizer keyNormalizer,
+ IdentityErrorDescriber errors,
+ IServiceProvider services,
+ ILogger> logger) : base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger)
+ {
+ _hasher = passwordHasher;
+ _usersRepo = store;
+ }
+
+ public override async Task CheckPasswordAsync(ApplicationUser user, string password)
+ {
+ ThrowIfDisposed();
+ var passwordStore = GetPasswordStore();
+
+ var result = await VerifyPasswordAsync(passwordStore, user, password);
+ if (result == PasswordVerificationResult.SuccessRehashNeeded)
+ {
+ //Remove the IPasswordStore parameter so we can call the protected, not private, method
+ await UpdatePasswordHash(user, password, validatePassword: false);
+ await UpdateUserAsync(user);
+ }
+
+ var success = result != PasswordVerificationResult.Failed;
+ if (!success)
+ {
+ var userId = user != null ? GetUserIdAsync(user).Result : "(null)";
+ Logger.LogWarning(0, "Invalid password for user {userId}.", userId);
+ }
+ return success;
+ }
+
+
+ protected override async Task VerifyPasswordAsync(IUserPasswordStore store, ApplicationUser user, string password)
+ {
+ string existingHash;
+
+ if (user != null)
+ existingHash = await store.GetPasswordHashAsync(user, CancellationToken);
+ else
+ existingHash = "not a real hash";
+
+ if (existingHash == null)
+ {
+ return PasswordVerificationResult.Failed;
+ }
+ return PasswordHasher.VerifyHashedPassword(user, existingHash, password);
+ }
+
+
+
+ public override async Task ChangePasswordAsync(ApplicationUser user, string currentPassword, string newPassword)
+ {
+ ThrowIfDisposed();
+ var passwordStore = GetPasswordStore();
+ if (user == null)
+ {
+ throw new ArgumentNullException(nameof(user));
+ }
+
+ if (await VerifyPasswordAsync(passwordStore, user, currentPassword) != PasswordVerificationResult.Failed)
+ {
+ var result = await UpdatePasswordHash(user, newPassword, validatePassword: false);
+ if (!result.Succeeded)
+ {
+ return result;
+ }
+
+ return await UpdateUserAsync(user);
+ }
+ Logger.LogWarning(2, "Change password failed for user {userId}.", await GetUserIdAsync(user));
+ return IdentityResult.Failed(ErrorDescriber.PasswordMismatch());
+ }
+
+ public override Task SetAuthenticationTokenAsync(ApplicationUser user, string loginProvider, string tokenName, string tokenValue)
+ {
+ return base.SetAuthenticationTokenAsync(user, loginProvider, tokenName, tokenValue);
+ }
+
+ private IUserPasswordStore GetPasswordStore()
+ {
+ if (Store is IUserPasswordStore passwordStore)
+ {
+ return passwordStore;
+ }
+
+ return null;
+ }
+
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Users/Auth/AuthConsts.cs b/BlueWest.Data.Auth/Users/Auth/AuthConsts.cs
new file mode 100644
index 0000000..33a8106
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/Auth/AuthConsts.cs
@@ -0,0 +1,17 @@
+using System.Security.Claims;
+using System.Threading.Tasks;
+using BlueWest.Data.Application;
+
+namespace BlueWest.WebApi.Context.Users;
+
+public static class AuthConsts
+{
+ ///
+ /// Helper object to return a negative callback
+ ///
+ public static (bool, SessionTokenUnique, ClaimsIdentity) NegativeToken => (false, null, null);
+
+ public static (bool, SessionTokenUnique, ClaimsIdentity) OkAuth(SessionTokenUnique sessionTokenUnique, ClaimsIdentity claimsIdentity, bool success = true) => (success, sessionTokenUnique, claimsIdentity);
+
+
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Users/Auth/AuthManager.cs b/BlueWest.Data.Auth/Users/Auth/AuthManager.cs
new file mode 100644
index 0000000..b9bfabc
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/Auth/AuthManager.cs
@@ -0,0 +1,130 @@
+using System;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using BlueWest.Cryptography;
+using BlueWest.Data.Application;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Identity;
+using static BlueWest.WebApi.Context.Users.AuthConsts;
+
+namespace BlueWest.WebApi.Context.Users
+{
+ internal class AuthManager : IAuthManager
+ {
+ private readonly ApplicationUserManager _userManager;
+ private readonly IHasher _hasher;
+ private readonly IJwtFactory _jwtFactory;
+ private readonly ISessionCache _sessionCache;
+
+ ///
+ /// Auth manager constructor
+ ///
+ ///
+ ///
+ ///
+ ///
+ public AuthManager(
+ ApplicationUserManager userManager,
+ IHasher hasher,
+ IJwtFactory jwtFactory,
+ ISessionCache sessionCache)
+ {
+ _userManager = userManager;
+ _hasher = hasher;
+ _jwtFactory = jwtFactory;
+ _sessionCache = sessionCache;
+ }
+
+ private async Task GetSessionToken(LoginRequest loginRequest)
+ {
+ var uuid = loginRequest.GetUuid();
+ var hashUuid = GetHashFromUuid(uuid);
+ var sessionToken = await _sessionCache.GetSessionTokenByIdAsync(hashUuid);
+ return sessionToken;
+ }
+
+ private string GetHashFromUuid(string uuid)
+ {
+ return _hasher.CreateHash(uuid, BaseCryptoItem.HashAlgorithm.SHA2_512);
+ }
+
+ private SessionToken GetNewSessionToken(LoginRequest loginRequest, ApplicationUser user, string token)
+ {
+ long timeNow = DateTimeOffset.Now.ToUnixTimeMilliseconds();
+
+ var newToken = new SessionToken
+ {
+ Id = GetHashFromUuid(loginRequest.GetUuid()),
+ UserId = user.Id,
+ CreatedDate = timeNow,
+ IsValid = true,
+ ValidFor = SessionConstants.DefaultSessionMaxAge.Milliseconds,
+ AccessToken = token
+ };
+
+ return newToken;
+ }
+
+ public bool SessionTokenIsValid(SessionToken token)
+ {
+ var hasChanges = token.Validate();
+
+ if (hasChanges)
+ {
+ _sessionCache.SaveAsync();
+ }
+
+ return token.IsValid;
+ }
+
+ public async Task<(bool, SessionTokenUnique, ClaimsIdentity)> GetSessionTokenIdByLoginRequest(LoginRequest loginRequest)
+ {
+ var user = await _userManager.FindByEmailAsync(loginRequest.Email);
+
+ if (user == null) return NegativeToken;
+
+ if (!await _userManager.CheckPasswordAsync(user, loginRequest.Password)) return NegativeToken;
+
+ var identity = new ClaimsIdentity(JwtBearerDefaults.AuthenticationScheme);
+ identity.AddClaim(new Claim(ClaimTypes.Email, user.Email));
+
+ var sessionToken = await GetSessionToken(loginRequest);
+
+ if (sessionToken == null || !SessionTokenIsValid(sessionToken))
+ {
+ var (success, bearerToken) = await GenerateBearerToken(identity, user);
+ var newSessionToken = GetNewSessionToken(loginRequest, user, bearerToken);
+ await _sessionCache.AddSessionToken(newSessionToken);
+ var tokenUnique = new SessionTokenUnique(newSessionToken);
+
+ return OkAuth(tokenUnique, identity, success);
+ }
+
+ var response = new SessionTokenUnique(sessionToken);
+ return OkAuth(response, identity);
+ }
+
+ private async Task<(bool, string)> GenerateBearerToken(ClaimsIdentity identity, ApplicationUser user)
+ {
+ var jwtToken = await _jwtFactory.GenerateEncodedToken(user.Id, user.UserName);
+ var completed = await _userManager.SetAuthenticationTokenAsync(user, SessionConstants.ApiNamePolicy,
+ SessionConstants.ApiNamePolicy, jwtToken.Token);
+ return (completed == IdentityResult.Success, jwtToken.Token);
+ }
+
+
+ public async Task VerifyLoginByEmailAsync(string email, string password)
+ {
+ var user = await _userManager.FindByEmailAsync(email);
+ return user != null && await _userManager.CheckPasswordAsync(user, password);
+ }
+
+
+ public async Task CreateUserAsync(RegisterViewModel userSignupDto)
+ {
+ userSignupDto.Password = _hasher.CreateHash(userSignupDto.Password, BaseCryptoItem.HashAlgorithm.SHA3_512);;
+ var newUser = userSignupDto.ToUser();
+ return await _userManager.CreateAsync(newUser);
+ }
+ }
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Users/Auth/Crypto/BaseCryptoItem.cs b/BlueWest.Data.Auth/Users/Auth/Crypto/BaseCryptoItem.cs
new file mode 100644
index 0000000..7494398
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/Auth/Crypto/BaseCryptoItem.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace BlueWest.Cryptography
+{
+ internal abstract class BaseCryptoItem
+ {
+ public enum HashAlgorithm
+ {
+ // ReSharper disable once InconsistentNaming
+ SHA2_512 = 1,
+
+ // ReSharper disable once InconsistentNaming
+ PBKDF2_SHA512 = 2,
+
+ // ReSharper disable once InconsistentNaming
+ SHA3_512 = 3
+ }
+
+ ///
+ /// HexStringToByteArray
+ ///
+ ///
+ ///
+ protected byte[] HexStringToByteArray(string stringInHexFormat)
+ {
+ var converted = Enumerable.Range(0, stringInHexFormat.Length)
+ .Where(x => x % 2 == 0)
+ .Select(x => Convert.ToByte(stringInHexFormat.Substring(x, 2), 16))
+ .ToArray();
+
+ return converted;
+ }
+
+ ///
+ /// ByteArrayToString
+ ///
+ ///
+ ///
+ protected string ByteArrayToString(byte[] bytes)
+ {
+ var sb = new StringBuilder();
+
+ for (int i = 0; i < bytes.Length; i++)
+ {
+ sb.Append(bytes[i].ToString("X2"));
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Generates a random string
+ ///
+ /// The length of the random string
+ ///
+ protected string CreateRandomString(int length)
+ {
+ var rng = RandomNumberGenerator.Create();
+ var buffer = new byte[length / 2];
+ rng.GetBytes(buffer);
+ var randomString = BitConverter.ToString(buffer).Replace("-", "");
+ return randomString;
+ }
+
+ // TODO Refactor me
+ ///
+ /// Get Cryptographic algorithm
+ ///
+ ///
+ ///
+ ///
+ ///
+ protected void GetAlgorithm(string cipherText, out int? algorithm, out int? keyIndex,
+ out string trimmedCipherText)
+ {
+ algorithm = null;
+ keyIndex = null;
+ trimmedCipherText = cipherText;
+
+ if (cipherText.Length <= 5 ) return;
+
+ var cipherInfo = cipherText[0].ToString();
+
+ if (int.TryParse(cipherInfo, out int foundAlgorithm))
+ {
+ algorithm = foundAlgorithm;
+ }
+
+ if (int.TryParse(cipherInfo, out int foundKeyIndex))
+ keyIndex = foundKeyIndex;
+
+ trimmedCipherText = cipherText.Substring(cipherText.IndexOf(cipherInfo, StringComparison.Ordinal) + 1);
+ }
+ }
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Users/Auth/Crypto/Hasher.cs b/BlueWest.Data.Auth/Users/Auth/Crypto/Hasher.cs
new file mode 100644
index 0000000..f930403
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/Auth/Crypto/Hasher.cs
@@ -0,0 +1,114 @@
+
+using System;
+using BlueWest.WebApi.Context.Users;
+using Microsoft.AspNetCore.Identity;
+
+
+namespace BlueWest.Cryptography
+{
+
+ ///
+ /// Hasher
+ ///
+ internal class Hasher : BaseCryptoItem, IHasher
+ {
+ private const int SaltLength = 64;
+
+ ///
+ /// CreateHash
+ ///
+ ///
+ ///
+ ///
+ public string CreateHash(string text, HashAlgorithm algorithm)
+ {
+ var salt = CreateRandomString(SaltLength);
+ return CreateHash(text, salt, algorithm, true);
+ }
+
+ ///
+ /// CreateHash
+ ///
+ ///
+ ///
+ ///
+ ///
+ public string CreateHash(string text, string saltName, BaseCryptoItem.HashAlgorithm algorithm)
+ {
+ return CreateHash(text, saltName, algorithm, false);
+ }
+
+ private string CreateHash(string text, string salt, HashAlgorithm algorithm, bool storeSalt)
+ {
+ string hash;
+
+ switch (algorithm)
+ {
+ case HashAlgorithm.SHA2_512:
+ var sha2 = new SHA2_512();
+ hash = sha2.Hash(text, salt, storeSalt);
+ break;
+ case HashAlgorithm.SHA3_512:
+ var sha3 = new SHA2_512();
+ hash = sha3.Hash(text, salt, storeSalt);
+ break;
+ default:
+ throw new NotImplementedException();
+ }
+
+ return hash;
+ }
+
+ ///
+ /// Check for a matching hash.
+ ///
+ ///
+ ///
+ ///
+ public bool MatchesHash(string text, string hash)
+ {
+ string salt = "";
+
+ GetAlgorithm(hash, out var algoAsInt, out _, out _, out salt);
+
+ if (!algoAsInt.HasValue) return false;
+
+ var hashAlgorithm = (HashAlgorithm) algoAsInt.Value;
+ var hashed = CreateHash(text, salt, hashAlgorithm, true);
+ return hashed == hash;
+ }
+
+ ///
+ /// Hash password
+ ///
+ ///
+ ///
+ ///
+ public string HashPassword(ApplicationUser ApplicationUser, string password)
+ {
+ return CreateHash(password, HashAlgorithm.SHA3_512);
+ }
+
+ public PasswordVerificationResult VerifyHashedPassword(ApplicationUser ApplicationUser, string hashedPassword,
+ string providedPassword)
+ {
+ var match = MatchesHash(providedPassword, hashedPassword);
+ return match ? PasswordVerificationResult.Success : PasswordVerificationResult.Failed;
+ }
+
+ private void GetAlgorithm(string cipherText, out int? algorithm, out int? keyIndex,
+ out string trimmedCipherText, out string salt)
+ {
+ GetAlgorithm(cipherText, out algorithm, out keyIndex, out trimmedCipherText);
+ if (algorithm.HasValue && trimmedCipherText.Length > SaltLength)
+ {
+ salt = trimmedCipherText.Substring(0, SaltLength);
+ trimmedCipherText = trimmedCipherText.Substring(SaltLength);
+ }
+ else
+ {
+ salt = null;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Users/Auth/Crypto/IHasher.cs b/BlueWest.Data.Auth/Users/Auth/Crypto/IHasher.cs
new file mode 100644
index 0000000..0c473c8
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/Auth/Crypto/IHasher.cs
@@ -0,0 +1,33 @@
+using BlueWest.WebApi.Context.Users;
+using Microsoft.AspNetCore.Identity;
+
+namespace BlueWest.Cryptography;
+
+///
+/// IHasher contract
+///
+internal interface IHasher : IPasswordHasher
+{
+ ///
+ /// Create hash
+ ///
+ ///
+ ///
+ ///
+ string CreateHash(string text, BaseCryptoItem.HashAlgorithm algorithm);
+ ///
+ /// Create hash
+ ///
+ ///
+ ///
+ ///
+ ///
+ string CreateHash(string text, string salt, BaseCryptoItem.HashAlgorithm algorithm);
+ ///
+ /// MatchesHash
+ ///
+ ///
+ ///
+ ///
+ bool MatchesHash(string text, string hash);
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Users/Auth/Crypto/IJwtFactory.cs b/BlueWest.Data.Auth/Users/Auth/Crypto/IJwtFactory.cs
new file mode 100644
index 0000000..84f3aeb
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/Auth/Crypto/IJwtFactory.cs
@@ -0,0 +1,8 @@
+using System.Threading.Tasks;
+
+namespace BlueWest.WebApi.Context.Users;
+
+public interface IJwtFactory
+{
+ Task GenerateEncodedToken(string id, string userName);
+}
diff --git a/BlueWest.Data.Auth/Users/Auth/Crypto/IJwtTokenHandler.cs b/BlueWest.Data.Auth/Users/Auth/Crypto/IJwtTokenHandler.cs
new file mode 100644
index 0000000..a88b355
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/Auth/Crypto/IJwtTokenHandler.cs
@@ -0,0 +1,12 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using Microsoft.IdentityModel.Tokens;
+
+namespace BlueWest.WebApi.Context.Users
+{
+ public interface IJwtTokenHandler
+ {
+ string WriteToken(JwtSecurityToken jwt);
+ ClaimsPrincipal ValidateToken(string token, TokenValidationParameters tokenValidationParameters);
+ }
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Users/Auth/Crypto/ISessionHasher.cs b/BlueWest.Data.Auth/Users/Auth/Crypto/ISessionHasher.cs
new file mode 100644
index 0000000..52525a0
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/Auth/Crypto/ISessionHasher.cs
@@ -0,0 +1,12 @@
+namespace BlueWest.Cryptography
+{
+ public interface ISessionHasher
+ {
+ ///
+ /// Generates a token for the current session
+ ///
+ void GenerateSessionToken();
+
+ }
+}
+
diff --git a/BlueWest.Data.Auth/Users/Auth/Crypto/ITokenFactory.cs b/BlueWest.Data.Auth/Users/Auth/Crypto/ITokenFactory.cs
new file mode 100644
index 0000000..392e198
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/Auth/Crypto/ITokenFactory.cs
@@ -0,0 +1,6 @@
+namespace BlueWest.WebApi.Context.Users;
+
+internal interface ITokenFactory
+{
+ string GenerateToken(int size= 32);
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Users/Auth/Crypto/JwtFactory.cs b/BlueWest.Data.Auth/Users/Auth/Crypto/JwtFactory.cs
new file mode 100644
index 0000000..a0dd1aa
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/Auth/Crypto/JwtFactory.cs
@@ -0,0 +1,85 @@
+using System;
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Security.Principal;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Options;
+using static BlueWest.WebApi.Context.Users.Constants;
+
+namespace BlueWest.WebApi.Context.Users;
+
+internal class JwtFactory : IJwtFactory
+{
+ private readonly IJwtTokenHandler _jwtTokenHandler;
+ private readonly JwtIssuerOptions _jwtOptions;
+
+ public JwtFactory(IJwtTokenHandler jwtTokenHandler, IOptions jwtOptions)
+ {
+ _jwtTokenHandler = jwtTokenHandler;
+ _jwtOptions = jwtOptions.Value;
+ ThrowIfInvalidOptions(_jwtOptions);
+ }
+
+ public async Task GenerateEncodedToken(string id, string userName)
+ {
+ var identity = GenerateClaimsIdentity(id, userName);
+
+ var claims = new[]
+ {
+ new Claim(JwtRegisteredClaimNames.Sub, userName),
+ new Claim(JwtRegisteredClaimNames.Jti, await _jwtOptions.JtiGenerator()),
+ new Claim(JwtRegisteredClaimNames.Aud, _jwtOptions.Audience),
+
+ new Claim(JwtRegisteredClaimNames.Iat, ToUnixEpochDate(_jwtOptions.IssuedAt).ToString(),
+ ClaimValueTypes.Integer64),
+ identity.FindFirst(JwtClaimIdentifiers.Rol),
+ identity.FindFirst(JwtClaimIdentifiers.Id)
+ };
+
+ // Create the JWT security token and encode it.
+ var jwt = new JwtSecurityToken(
+ _jwtOptions.Issuer,
+ _jwtOptions.Audience,
+ claims,
+ _jwtOptions.NotBefore,
+ _jwtOptions.Expiration,
+ _jwtOptions.SigningCredentials);
+
+ return new AccessToken(_jwtTokenHandler.WriteToken(jwt), (int)_jwtOptions.ValidFor.TotalSeconds);
+ }
+
+ private static ClaimsIdentity GenerateClaimsIdentity(string id, string userName)
+ {
+ return new ClaimsIdentity(new GenericIdentity(userName, "Token"), new[]
+ {
+ new Claim(JwtClaimIdentifiers.Id, id),
+ new Claim(JwtClaimIdentifiers.Rol, JwtClaims.ApiAccess)
+ });
+ }
+
+ /// Date converted to seconds since Unix epoch (Jan 1, 1970, midnight UTC).
+ private static long ToUnixEpochDate(DateTime date)
+ => (long)Math.Round((date.ToUniversalTime() -
+ new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero))
+ .TotalSeconds);
+
+ private static void ThrowIfInvalidOptions(JwtIssuerOptions options)
+ {
+ if (options == null) throw new ArgumentNullException(nameof(options));
+
+ if (options.ValidFor <= TimeSpan.Zero)
+ {
+ throw new ArgumentException("Must be a non-zero TimeSpan.", nameof(JwtIssuerOptions.ValidFor));
+ }
+
+ if (options.SigningCredentials == null)
+ {
+ throw new ArgumentNullException(nameof(JwtIssuerOptions.SigningCredentials));
+ }
+
+ if (options.JtiGenerator == null)
+ {
+ throw new ArgumentNullException(nameof(JwtIssuerOptions.JtiGenerator));
+ }
+ }
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Users/Auth/Crypto/JwtIssuerOptions.cs b/BlueWest.Data.Auth/Users/Auth/Crypto/JwtIssuerOptions.cs
new file mode 100644
index 0000000..336f7c7
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/Auth/Crypto/JwtIssuerOptions.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.IdentityModel.Tokens;
+
+namespace BlueWest.WebApi.Context.Users;
+
+internal class JwtIssuerOptions
+{
+ ///
+ /// 4.1.1. "iss" (Issuer) Claim - The "iss" (issuer) claim identifies the principal that issued the JWT.
+ ///
+ public string Issuer { get; set; }
+
+ ///
+ /// 4.1.2. "sub" (Subject) Claim - The "sub" (subject) claim identifies the principal that is the subject of the JWT.
+ ///
+ public string Subject { get; set; }
+
+ ///
+ /// 4.1.3. "aud" (Audience) Claim - The "aud" (audience) claim identifies the recipients that the JWT is intended for.
+ ///
+ public string Audience { get; set; }
+
+ ///
+ /// 4.1.4. "exp" (Expiration Time) Claim - The "exp" (expiration time) claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing.
+ ///
+ public DateTime Expiration => IssuedAt.Add(ValidFor);
+
+ ///
+ /// 4.1.5. "nbf" (Not Before) Claim - The "nbf" (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing.
+ ///
+ public DateTime NotBefore => DateTime.UtcNow;
+
+ ///
+ /// 4.1.6. "iat" (Issued At) Claim - The "iat" (issued at) claim identifies the time at which the JWT was issued.
+ ///
+ public DateTime IssuedAt => DateTime.UtcNow;
+
+ ///
+ /// Set the timespan the token will be valid for (default is 120 min)
+ ///
+ public TimeSpan ValidFor { get; set; } = SessionConstants.DefaultSessionMaxAge;
+
+ ///
+ /// "jti" (JWT ID) Claim (default ID is a GUID)
+ ///
+ public Func> JtiGenerator =>
+ () => Task.FromResult(Guid.NewGuid().ToString());
+
+ ///
+ /// The signing key to use when generating tokens.
+ ///
+ public SigningCredentials SigningCredentials { get; set; }
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Users/Auth/Crypto/JwtTokenHandler.cs b/BlueWest.Data.Auth/Users/Auth/Crypto/JwtTokenHandler.cs
new file mode 100644
index 0000000..54820c9
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/Auth/Crypto/JwtTokenHandler.cs
@@ -0,0 +1,53 @@
+using System;
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using Microsoft.IdentityModel.Tokens;
+
+namespace BlueWest.WebApi.Context.Users;
+
+public class JwtTokenHandler : IJwtTokenHandler
+{
+ private readonly JwtSecurityTokenHandler _jwtSecurityTokenHandler;
+
+ ///
+ /// JwtTokenHandler
+ ///
+ public JwtTokenHandler()
+ {
+ _jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
+ }
+
+ ///
+ /// Write token
+ ///
+ ///
+ ///
+ public string WriteToken(JwtSecurityToken jwt)
+ {
+ return _jwtSecurityTokenHandler.WriteToken(jwt);
+ }
+
+ ///
+ /// Validate Token
+ ///
+ ///
+ ///
+ ///
+ ///
+ public ClaimsPrincipal ValidateToken(string token, TokenValidationParameters tokenValidationParameters)
+ {
+ try
+ {
+ var principal = _jwtSecurityTokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken);
+
+ if (!(securityToken is JwtSecurityToken jwtSecurityToken) || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
+ throw new SecurityTokenException("Invalid token");
+
+ return principal;
+ }
+ catch (Exception e)
+ {
+ return null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Users/Auth/Crypto/SHA_512.cs b/BlueWest.Data.Auth/Users/Auth/Crypto/SHA_512.cs
new file mode 100644
index 0000000..e2523a6
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/Auth/Crypto/SHA_512.cs
@@ -0,0 +1,62 @@
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.AspNetCore.Cryptography.KeyDerivation;
+namespace BlueWest.Cryptography
+{
+ ///
+ /// SHA2_512 : BaseCryptoItem
+ ///
+ internal class SHA2_512 : BaseCryptoItem
+ {
+ ///
+ /// Hash with the provided salt
+ ///
+ ///
+ ///
+ ///
+ ///
+ public string Hash(string text, string salt, bool storeSalt)
+ {
+ var fullText = string.Concat(text, salt);
+ var data = Encoding.UTF8.GetBytes(fullText);
+ string hash;
+ using SHA512 sha = SHA512.Create();
+ var hashBytes = sha.ComputeHash(data);
+ var asString = ByteArrayToString(hashBytes);
+
+ if (storeSalt)
+ {
+ hash = $"{(int)HashAlgorithm.SHA3_512}{salt}{asString}";
+ return hash;
+ }
+
+ hash = $"{(int)HashAlgorithm.SHA3_512}{asString}";
+ return hash;
+ }
+
+
+ ///
+ /// Hash_PBKDF2 algorithm.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public string Hash_PBKDF2(string plainText, string salt, bool saveSaltInResult)
+ {
+ var saltAsBytes = Encoding.ASCII.GetBytes(salt);
+
+ string hashed = ByteArrayToString(KeyDerivation.Pbkdf2(
+ password: plainText,
+ salt: saltAsBytes,
+ prf: KeyDerivationPrf.HMACSHA512, //.NET 3.1 uses HMACSHA256 here
+ iterationCount: 100000, //.NET 3.1 uses 10,000 iterations here
+ numBytesRequested: 64)); //.NET 3.1 uses 32 bytes here
+
+ if (saveSaltInResult)
+ return string.Format("[{0}]{1}{2}", (int)HashAlgorithm.PBKDF2_SHA512, salt, hashed);
+ else
+ return string.Format("[{0}]{1}", (int)HashAlgorithm.PBKDF2_SHA512, hashed);
+ }
+ }
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Users/Auth/IAuthManager.cs b/BlueWest.Data.Auth/Users/Auth/IAuthManager.cs
new file mode 100644
index 0000000..42fbe7c
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/Auth/IAuthManager.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using BlueWest.Data.Application;
+using Microsoft.AspNetCore.Identity;
+
+namespace BlueWest.WebApi.Context.Users;
+
+///
+/// Auth manager contract interface.
+///
+public interface IAuthManager
+{
+ ///
+ /// CreateUserAsync
+ ///
+ ///
+ ///
+ Task CreateUserAsync(RegisterViewModel registerViewModel);
+
+ ///
+ /// Does Login
+ ///
+ ///
+ ///
+ public Task<(bool, SessionTokenUnique, ClaimsIdentity)> GetSessionTokenIdByLoginRequest(LoginRequest loginRequest);
+
+
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Users/Auth/SignInManager.cs b/BlueWest.Data.Auth/Users/Auth/SignInManager.cs
new file mode 100644
index 0000000..bae6ae4
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/Auth/SignInManager.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Linq;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace BlueWest.WebApi.Context.Users;
+
+///
+/// SignInManager
+///
+internal class SignInManager : SignInManager
+{
+ public SignInManager(
+ UserManager userManager,
+ IHttpContextAccessor contextAccessor,
+ IUserClaimsPrincipalFactory claimsFactory,
+ IOptions optionsAccessor,
+ ILogger> logger,
+ IAuthenticationSchemeProvider schemes,
+ IUserConfirmation confirmation) :
+ base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation)
+ {
+
+ }
+
+
+
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Users/Constants.cs b/BlueWest.Data.Auth/Users/Constants.cs
new file mode 100644
index 0000000..25d8b1e
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/Constants.cs
@@ -0,0 +1,24 @@
+namespace BlueWest.WebApi.Context.Users;
+
+public static class Constants
+{
+
+ ///
+ /// JwtClaimIdentifiers
+ ///
+ public static class JwtClaimIdentifiers
+ {
+ public const string Rol = "rol", Id = "id";
+ }
+
+ ///
+ /// JwtClaims
+ ///
+ public static class JwtClaims
+ {
+ ///
+ /// JwtClaims.ApiAccess
+ ///
+ public const string ApiAccess = "api_access";
+ }
+}
\ No newline at end of file
diff --git a/BlueWest.Data.Auth/Users/IUserManager.cs b/BlueWest.Data.Auth/Users/IUserManager.cs
new file mode 100644
index 0000000..9db5b03
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/IUserManager.cs
@@ -0,0 +1,35 @@
+using System.Threading.Tasks;
+using BlueWest.Data.Application;
+using Microsoft.AspNetCore.Identity;
+
+namespace BlueWest.WebApi.Context.Users
+{
+ public interface IUserManager
+ {
+ ///
+ /// Create user.
+ ///
+ ///
+ ///
+ Task CreateAsync(ApplicationUser user);
+
+ ///
+ /// Checks for user password
+ ///
+ ///
+ ///
+ ///
+ Task CheckPasswordAsync(ApplicationUser user, string password);
+
+ ///
+ /// Find by email
+ ///
+ ///
+ ///
+ Task FindByEmailAsync(string email);
+
+
+
+ }
+}
+
diff --git a/BlueWest.Data.Auth/Users/UserRepository.cs b/BlueWest.Data.Auth/Users/UserRepository.cs
new file mode 100644
index 0000000..36d384a
--- /dev/null
+++ b/BlueWest.Data.Auth/Users/UserRepository.cs
@@ -0,0 +1,69 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using BlueWest.Domain;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+
+namespace BlueWest.WebApi.Context.Users
+{
+ ///
+ /// Users Repository
+ ///
+ public class UserRepository : UserStore
+ {
+ private readonly ApplicationUserDbContext _context;
+
+ ///
+ /// User repository
+ ///
+ ///
+ ///
+ public UserRepository(ApplicationUserDbContext context, IdentityErrorDescriber describer = null) : base(context, describer)
+ {
+ _context = context;
+ }
+
+ ///
+ /// Get Application Users
+ ///
+ ///
+ public async Task> GetUsers()
+ {
+ var users = await _context.Users.ToListAsync();
+ return users;
+ }
+
+ ///
+ public override Task GetPasswordHashAsync(ApplicationUser user, CancellationToken cancellationToken = default)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return Task.FromCanceled(cancellationToken);
+ }
+
+ return Task.FromResult(user.PasswordHash);
+ }
+
+ ///
+ public override Task HasPasswordAsync(ApplicationUser user, CancellationToken cancellationToken = default)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return Task.FromCanceled(cancellationToken);
+ }
+
+ return Task.FromResult(!string.IsNullOrEmpty(user.PasswordHash));
+ }
+
+ }
+}
diff --git a/BlueWest.sln b/BlueWest.sln
index 7ed2058..3ff5e0b 100644
--- a/BlueWest.sln
+++ b/BlueWest.sln
@@ -43,6 +43,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlueWest.Domain", "BlueWest
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlueWest.Razor.Library", "BlueWest.Razor.Library\BlueWest.Razor.Library.csproj", "{CA6DF60F-B33E-4688-A4ED-4427B446E852}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlueWest.Data.Auth", "BlueWest.Data.Auth\BlueWest.Data.Auth.csproj", "{2998FE17-18AD-4888-A696-7F6340F8A543}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -97,6 +99,10 @@ Global
{CA6DF60F-B33E-4688-A4ED-4427B446E852}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CA6DF60F-B33E-4688-A4ED-4427B446E852}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CA6DF60F-B33E-4688-A4ED-4427B446E852}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2998FE17-18AD-4888-A696-7F6340F8A543}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2998FE17-18AD-4888-A696-7F6340F8A543}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2998FE17-18AD-4888-A696-7F6340F8A543}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2998FE17-18AD-4888-A696-7F6340F8A543}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE