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