diff --git a/.env b/.env index af67fc3..f06ff63 100644 --- a/.env +++ b/.env @@ -16,4 +16,86 @@ # databases (if Sqlite) and other data that should be saved in a place other than the place where the program is launched. # REQUIRED if the application is inside the container # If you want to change this value, you need to change the values in Settings.json and move the file itself to the desired location. -PATH_TO_SAVE= \ No newline at end of file +PATH_TO_SAVE= + +# Security + +# JWT signature token +# string (UTF8) +# This token will be used to create and verify the signature of JWT tokens. +# The token must be equal to 64 characters +SECURITY_SIGNING_TOKEN= + +# Token for JWT encryption +# string (UTF8) +# This token will be used to encrypt and decrypt JWT tokens. +# The token must be equal to 32 characters +SECURITY_ENCRYPTION_TOKEN= + +# Time in minutes, which indicates after which time the Refresh Token will become invalid +# integer +# The token indicates how long after the user is inactive, he will need to log in again +SECURITY_LIFE_TIME_RT=1440 + +# The time in a minute, which indicates that this is exactly what it takes to become a non-state +# integer +# Do not specify a time that is too long or too short. Optimally 5 > x > 60 +SECURITY_LIFE_TIME_JWT=15 + +# Time in minutes, which indicates after which time the token of the first factor will become invalid +# integer +# Do not specify a short time. The user must be able to log in using the second factor +SECURITY_LIFE_TIME_1_FA=15 + +# An identifier that points to the server that created the token +# string +SECURITY_JWT_ISSUER= + +# ID of the audience for which the token is intended +# string +SECURITY_JWT_AUDIENCE= + +### Hashing + +# In order to set up hashing correctly, you need to start from the security requirements +# You can use the settings that were used in https://github.com/P-H-C/phc-winner-argon2 +# These parameters have a STRONG impact on performance +# When testing the system, these values were used: +# 10 <= SECURITY_HASH_ITERATION <= 25 iterations +# 16384 <= SECURITY_HASH_MEMORY <= 32768 KB +# 4 <= SECURITY_HASH_PARALLELISM <= 8 lines +# If we take all the large values, it will take a little more than 1 second to get the hash. If this time is critical, reduce the parameters + +# The number of iterations used to hash passwords in the Argon2 algorithm +# integer +# This parameter determines the number of iterations that the Argon2 algorithm goes through when hashing passwords. +# Increasing this value can improve security by increasing the time it takes to calculate the password hash. +# The average number of iterations to increase the security level should be set to at least 10. +SECURITY_HASH_ITERATION= + +# The amount of memory used to hash passwords in the Argon2 algorithm +# integer +# 65536 +# This parameter determines the number of kilobytes of memory that will be used for the password hashing process. +# Increasing this value may increase security, but it may also require more system resources. +SECURITY_HASH_MEMORY= + +# Parallelism determines how many of the memory fragments divided into strips will be used to generate a hash +# integer +# This value affects the hash itself, but can be changed to achieve an ideal execution time, taking into account the processor and the number of cores. +SECURITY_HASH_PARALLELISM= + +# The size of the output hash generated by the password hashing algorithm +# integer +SECURITY_HASH_SIZE=32 + +# Additional protection for Argon2 +# string (BASE64) +# (optional) +# We recommend installing a token so that even if the data is compromised, an attacker cannot brute force a password without a token +SECURITY_HASH_TOKEN= + +# The size of the salt used to hash passwords +# integer +# The salt is a random value added to the password before hashing to prevent the use of rainbow hash tables and other attacks. +SECURITY_SALT_SIZE=16 \ No newline at end of file diff --git a/Backend.sln b/Backend.sln index 95ebfb7..5790acf 100644 --- a/Backend.sln +++ b/Backend.sln @@ -25,6 +25,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Persistence", "Persistence\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApiDto", "ApiDto\ApiDto.csproj", "{0335FA36-E137-453F-853B-916674C168FE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Security", "Security\Security.csproj", "{47A3C065-4E1D-4B1E-AAB4-2BB8F40E56B4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -51,6 +53,10 @@ Global {0335FA36-E137-453F-853B-916674C168FE}.Debug|Any CPU.Build.0 = Debug|Any CPU {0335FA36-E137-453F-853B-916674C168FE}.Release|Any CPU.ActiveCfg = Release|Any CPU {0335FA36-E137-453F-853B-916674C168FE}.Release|Any CPU.Build.0 = Release|Any CPU + {47A3C065-4E1D-4B1E-AAB4-2BB8F40E56B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {47A3C065-4E1D-4B1E-AAB4-2BB8F40E56B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {47A3C065-4E1D-4B1E-AAB4-2BB8F40E56B4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {47A3C065-4E1D-4B1E-AAB4-2BB8F40E56B4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Endpoint/Common/Services/Security/DistributedCacheService.cs b/Endpoint/Common/Services/Security/DistributedCacheService.cs new file mode 100644 index 0000000..bf3dc39 --- /dev/null +++ b/Endpoint/Common/Services/Security/DistributedCacheService.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Caching.Distributed; +using Mirea.Api.Security.Common.Interfaces; +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Mirea.Api.Endpoint.Common.Services.Security; + +public class DistributedCacheService(IDistributedCache cache) : ICacheService +{ + public async Task SetAsync(string key, T value, TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default) + { + var options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow, + SlidingExpiration = slidingExpiration + }; + + var serializedValue = JsonSerializer.SerializeToUtf8Bytes(value); + await cache.SetAsync(key, serializedValue, options, cancellationToken); + } + + public async Task GetAsync(string key, CancellationToken cancellationToken = default) + { + var cachedValue = await cache.GetAsync(key, cancellationToken); + return cachedValue == null ? default : JsonSerializer.Deserialize(cachedValue); + } + + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) => + cache.RemoveAsync(key, cancellationToken); +} \ No newline at end of file diff --git a/Endpoint/Common/Services/Security/JwtTokenService.cs b/Endpoint/Common/Services/Security/JwtTokenService.cs new file mode 100644 index 0000000..7c3225f --- /dev/null +++ b/Endpoint/Common/Services/Security/JwtTokenService.cs @@ -0,0 +1,82 @@ +using Microsoft.IdentityModel.Tokens; +using Mirea.Api.Security.Common.Interfaces; +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; + +namespace Mirea.Api.Endpoint.Common.Services.Security; + +public class JwtTokenService : IAccessToken +{ + public required string Issuer { private get; init; } + public required string Audience { private get; init; } + public TimeSpan Lifetime { private get; init; } + + public ReadOnlyMemory EncryptionKey { get; init; } + public ReadOnlyMemory SigningKey { private get; init; } + + public (string Token, DateTime ExpireIn) GenerateToken(string userId) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var signingKey = new SymmetricSecurityKey(SigningKey.ToArray()); + var encryptionKey = new SymmetricSecurityKey(EncryptionKey.ToArray()); + var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha512); + + var expires = DateTime.UtcNow.Add(Lifetime); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Issuer = Issuer, + Audience = Audience, + Expires = expires, + SigningCredentials = signingCredentials, + Subject = new ClaimsIdentity( + [ + new Claim(ClaimTypes.Name, userId), + // todo: get role by userId + new Claim(ClaimTypes.Role, "") + ]), + EncryptingCredentials = new EncryptingCredentials(encryptionKey, SecurityAlgorithms.Aes256KW, SecurityAlgorithms.Aes256CbcHmacSha512) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + + return (tokenHandler.WriteToken(token), expires); + } + + public DateTimeOffset GetExpireDateTime(string token) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var signingKey = new SymmetricSecurityKey(SigningKey.ToArray()); + var encryptionKey = new SymmetricSecurityKey(EncryptionKey.ToArray()); + + var tokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = Issuer, + ValidAudience = Audience, + IssuerSigningKey = signingKey, + TokenDecryptionKey = encryptionKey, + ValidateIssuer = true, + ValidateAudience = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = false + }; + + try + { + var claimsPrincipal = tokenHandler.ValidateToken(token, tokenValidationParameters, out _); + + var expClaim = claimsPrincipal.Claims.FirstOrDefault(c => c.Type == "exp"); + + if (expClaim != null && long.TryParse(expClaim.Value, out var expUnix)) + return DateTimeOffset.FromUnixTimeSeconds(expUnix); + } + catch (SecurityTokenException) + { + return DateTimeOffset.MinValue; + } + + return DateTimeOffset.MinValue; + } +} \ No newline at end of file diff --git a/Endpoint/Common/Services/Security/MemoryCacheService.cs b/Endpoint/Common/Services/Security/MemoryCacheService.cs new file mode 100644 index 0000000..a428034 --- /dev/null +++ b/Endpoint/Common/Services/Security/MemoryCacheService.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Caching.Memory; +using Mirea.Api.Security.Common.Interfaces; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Mirea.Api.Endpoint.Common.Services.Security; + +public class MemoryCacheService(IMemoryCache cache) : ICacheService +{ + public Task SetAsync(string key, T value, TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default) + { + var options = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow, + SlidingExpiration = slidingExpiration + }; + + cache.Set(key, value, options); + return Task.CompletedTask; + } + + public Task GetAsync(string key, CancellationToken cancellationToken = default) + { + cache.TryGetValue(key, out T? value); + return Task.FromResult(value); + } + + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + cache.Remove(key); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Endpoint/Common/Services/Security/MemoryRevokedTokenService.cs b/Endpoint/Common/Services/Security/MemoryRevokedTokenService.cs new file mode 100644 index 0000000..94c2f75 --- /dev/null +++ b/Endpoint/Common/Services/Security/MemoryRevokedTokenService.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Caching.Memory; +using Mirea.Api.Security.Common.Interfaces; +using System; +using System.Threading.Tasks; + +namespace Mirea.Api.Endpoint.Common.Services.Security; + +public class MemoryRevokedTokenService(IMemoryCache cache) : IRevokedToken +{ + public Task AddTokenToRevokedAsync(string token, DateTimeOffset expiresIn) + { + cache.Set(token, true, expiresIn); + return Task.CompletedTask; + } + + public Task IsTokenRevokedAsync(string token) => Task.FromResult(cache.TryGetValue(token, out _)); +} \ No newline at end of file diff --git a/Endpoint/Endpoint.csproj b/Endpoint/Endpoint.csproj index a1e08aa..985a730 100644 --- a/Endpoint/Endpoint.csproj +++ b/Endpoint/Endpoint.csproj @@ -23,15 +23,17 @@ - + + + \ No newline at end of file diff --git a/Endpoint/Program.cs b/Endpoint/Program.cs index 89d98ab..840504f 100644 --- a/Endpoint/Program.cs +++ b/Endpoint/Program.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApiExplorer; @@ -6,20 +7,24 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; using Mirea.Api.DataAccess.Application; using Mirea.Api.DataAccess.Persistence; using Mirea.Api.Endpoint.Common.Interfaces; using Mirea.Api.Endpoint.Common.Services; +using Mirea.Api.Endpoint.Common.Services.Security; using Mirea.Api.Endpoint.Configuration; using Mirea.Api.Endpoint.Configuration.General; using Mirea.Api.Endpoint.Configuration.General.Validators; using Mirea.Api.Endpoint.Configuration.Swagger; using Mirea.Api.Endpoint.Middleware; +using Mirea.Api.Security.Common.Interfaces; using Swashbuckle.AspNetCore.SwaggerGen; using System; using System.Collections; using System.IO; using System.Linq; +using System.Text; namespace Mirea.Api.Endpoint; @@ -40,6 +45,66 @@ public class Program return result.Build(); } + private static IServiceCollection ConfigureJwtToken(IServiceCollection services, IConfiguration configuration) + { + var lifeTimeJwt = TimeSpan.FromMinutes(int.Parse(configuration["SECURITY_LIFE_TIME_JWT"]!)); + + var jwtDecrypt = Encoding.UTF8.GetBytes(configuration["SECURITY_ENCRYPTION_TOKEN"] ?? string.Empty); + + if (jwtDecrypt.Length != 32) + throw new InvalidOperationException("The secret token \"SECURITY_ENCRYPTION_TOKEN\" cannot be less than 32 characters long. Now the size is equal is " + jwtDecrypt.Length); + + var jwtKey = Encoding.UTF8.GetBytes(configuration["SECURITY_SIGNING_TOKEN"] ?? string.Empty); + + if (jwtKey.Length != 64) + throw new InvalidOperationException("The signature token \"SECURITY_SIGNING_TOKEN\" cannot be less than 64 characters. Now the size is " + jwtKey.Length); + + var jwtIssuer = configuration["SECURITY_JWT_ISSUER"]; + var jwtAudience = configuration["SECURITY_JWT_AUDIENCE"]; + + if (string.IsNullOrEmpty(jwtAudience) || string.IsNullOrEmpty(jwtIssuer)) + throw new InvalidOperationException("The \"SECURITY_JWT_ISSUER\" and \"SECURITY_JWT_AUDIENCE\" are not specified"); + + services.AddSingleton(_ => new JwtTokenService + { + Audience = jwtAudience, + Issuer = jwtIssuer, + Lifetime = lifeTimeJwt, + EncryptionKey = jwtDecrypt, + SigningKey = jwtKey + }); + + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }).AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtIssuer, + + ValidateAudience = true, + ValidAudience = jwtAudience, + + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(jwtKey), + TokenDecryptionKey = new SymmetricSecurityKey(jwtDecrypt) + }; + }); + + return services; + } + + private static IServiceCollection ConfigureSecurity(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + + return services; + } public static void Main(string[] args) { Directory.SetCurrentDirectory(AppDomain.CurrentDomain.BaseDirectory); diff --git a/Security/Common/Domain/AuthToken.cs b/Security/Common/Domain/AuthToken.cs new file mode 100644 index 0000000..4572e62 --- /dev/null +++ b/Security/Common/Domain/AuthToken.cs @@ -0,0 +1,13 @@ +using System; + +namespace Mirea.Api.Security.Common.Domain; + +public class AuthToken +{ + public required string RefreshToken { get; set; } + public required string UserAgent { get; set; } + public required string Ip { get; set; } + public required string UserId { get; set; } + public required string AccessToken { get; set; } + public DateTime CreatedAt { get; set; } +} \ No newline at end of file diff --git a/Security/Common/Domain/PreAuthToken.cs b/Security/Common/Domain/PreAuthToken.cs new file mode 100644 index 0000000..f1f9684 --- /dev/null +++ b/Security/Common/Domain/PreAuthToken.cs @@ -0,0 +1,10 @@ +namespace Mirea.Api.Security.Common.Domain; + +public class PreAuthToken +{ + public required string Fingerprint { get; set; } + public required string UserAgent { get; set; } + public required string UserId { get; set; } + public required string Ip { get; set; } + public required string Token { get; set; } +} \ No newline at end of file diff --git a/Security/Common/Dto/Requests/TokenRequest.cs b/Security/Common/Dto/Requests/TokenRequest.cs new file mode 100644 index 0000000..8be8038 --- /dev/null +++ b/Security/Common/Dto/Requests/TokenRequest.cs @@ -0,0 +1,8 @@ +namespace Mirea.Api.Security.Common.Dto.Requests; + +public class TokenRequest +{ + public required string Fingerprint { get; set; } + public required string UserAgent { get; set; } + public required string Ip { get; set; } +} \ No newline at end of file diff --git a/Security/Common/Dto/Responses/AuthTokenResponse.cs b/Security/Common/Dto/Responses/AuthTokenResponse.cs new file mode 100644 index 0000000..0c8a3d4 --- /dev/null +++ b/Security/Common/Dto/Responses/AuthTokenResponse.cs @@ -0,0 +1,10 @@ +using System; + +namespace Mirea.Api.Security.Common.Dto.Responses; + +public class AuthTokenResponse +{ + public required string AccessToken { get; set; } + public required string RefreshToken { get; set; } + public DateTime ExpiresIn { get; set; } +} \ No newline at end of file diff --git a/Security/Common/Dto/Responses/PreAuthTokenResponse.cs b/Security/Common/Dto/Responses/PreAuthTokenResponse.cs new file mode 100644 index 0000000..9a7238f --- /dev/null +++ b/Security/Common/Dto/Responses/PreAuthTokenResponse.cs @@ -0,0 +1,9 @@ +using System; + +namespace Mirea.Api.Security.Common.Dto.Responses; + +public class PreAuthTokenResponse +{ + public required string Token { get; set; } + public DateTime ExpiresIn { get; set; } +} \ No newline at end of file diff --git a/Security/Common/Interfaces/IAccessToken.cs b/Security/Common/Interfaces/IAccessToken.cs new file mode 100644 index 0000000..a2ebed2 --- /dev/null +++ b/Security/Common/Interfaces/IAccessToken.cs @@ -0,0 +1,9 @@ +using System; + +namespace Mirea.Api.Security.Common.Interfaces; + +public interface IAccessToken +{ + (string Token, DateTime ExpireIn) GenerateToken(string userId); + DateTimeOffset GetExpireDateTime(string token); +} \ No newline at end of file diff --git a/Security/Common/Interfaces/ICacheService.cs b/Security/Common/Interfaces/ICacheService.cs new file mode 100644 index 0000000..c2cb1e3 --- /dev/null +++ b/Security/Common/Interfaces/ICacheService.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Mirea.Api.Security.Common.Interfaces; + +public interface ICacheService +{ + Task SetAsync(string key, T value, + TimeSpan? absoluteExpirationRelativeToNow = null, + TimeSpan? slidingExpiration = null, + CancellationToken cancellationToken = default); + + Task GetAsync(string key, CancellationToken cancellationToken = default); + Task RemoveAsync(string key, CancellationToken cancellationToken = default); +} diff --git a/Security/Common/Interfaces/IRevokedToken.cs b/Security/Common/Interfaces/IRevokedToken.cs new file mode 100644 index 0000000..d8d9edc --- /dev/null +++ b/Security/Common/Interfaces/IRevokedToken.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +namespace Mirea.Api.Security.Common.Interfaces; + +public interface IRevokedToken +{ + Task AddTokenToRevokedAsync(string token, DateTimeOffset expiresIn); + Task IsTokenRevokedAsync(string token); +} \ No newline at end of file diff --git a/Security/DependencyInjection.cs b/Security/DependencyInjection.cs new file mode 100644 index 0000000..ed16c5e --- /dev/null +++ b/Security/DependencyInjection.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Mirea.Api.Security.Common.Interfaces; +using Mirea.Api.Security.Services; +using System; + +namespace Mirea.Api.Security; + +public static class DependencyInjection +{ + public static IServiceCollection AddSecurityServices(this IServiceCollection services, IConfiguration configuration) + { + var saltSize = int.Parse(configuration["SECURITY_SALT_SIZE"]!); + var hashSize = int.Parse(configuration["SECURITY_HASH_SIZE"]!); + var iteration = int.Parse(configuration["SECURITY_HASH_ITERATION"]!); + var memory = int.Parse(configuration["SECURITY_HASH_MEMORY"]!); + var parallelism = int.Parse(configuration["SECURITY_HASH_PARALLELISM"]!); + + services.AddSingleton(new PasswordHashService + { + SaltSize = saltSize, + HashSize = hashSize, + Iterations = iteration, + Memory = memory, + Parallelism = parallelism, + Secret = configuration["SECURITY_HASH_TOKEN"] + }); + + var lifeTimePreAuthToken = TimeSpan.FromMinutes(int.Parse(configuration["SECURITY_LIFE_TIME_1_FA"]!)); + + services.AddSingleton(provider => + { + var cache = provider.GetRequiredService(); + + return new PreAuthService(cache) + { + Lifetime = lifeTimePreAuthToken + }; + }); + + var lifeTimeRefreshToken = TimeSpan.FromMinutes(int.Parse(configuration["SECURITY_LIFE_TIME_RT"]!)); + + services.AddSingleton(provider => + { + var cacheService = provider.GetRequiredService(); + var accessTokenService = provider.GetRequiredService(); + var revokedTokenService = provider.GetRequiredService(); + + return new AuthService(cacheService, accessTokenService, revokedTokenService) + { + Lifetime = lifeTimeRefreshToken + }; + }); + + return services; + } +} \ No newline at end of file diff --git a/Security/Security.csproj b/Security/Security.csproj new file mode 100644 index 0000000..218d9f6 --- /dev/null +++ b/Security/Security.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + disable + enable + Winsomnia + 1.0.0-a0 + 1.0.0.0 + 1.0.0.0 + Mirea.Api.Security + $(AssemblyName) + + + + + + + + + diff --git a/Security/Services/AuthService.cs b/Security/Services/AuthService.cs new file mode 100644 index 0000000..5426532 --- /dev/null +++ b/Security/Services/AuthService.cs @@ -0,0 +1,103 @@ +using Mirea.Api.Security.Common.Domain; +using Mirea.Api.Security.Common.Dto.Requests; +using Mirea.Api.Security.Common.Dto.Responses; +using Mirea.Api.Security.Common.Interfaces; +using System; +using System.Security; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Mirea.Api.Security.Services; + +public class AuthService(ICacheService cache, IAccessToken accessTokenService, IRevokedToken revokedToken) +{ + public TimeSpan Lifetime { private get; init; } + + private static string GenerateRefreshToken() => Guid.NewGuid().ToString().Replace("-", "") + + GeneratorKey.GenerateString(32); + private (string Token, DateTime ExpireIn) GenerateAccessToken(string userId) => + accessTokenService.GenerateToken(userId); + + private static string GetAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token"; + + private Task SetAuthTokenDataToCache(string fingerprint, AuthToken data, CancellationToken cancellation) => + cache.SetAsync( + GetAuthCacheKey(fingerprint), + JsonSerializer.SerializeToUtf8Bytes(data), + slidingExpiration: Lifetime, + cancellationToken: cancellation); + + private Task RevokeAccessToken(string token) => + revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token)); + + public async Task GenerateAuthTokensAsync(TokenRequest request, string userId, CancellationToken cancellation = default) + { + var refreshToken = GenerateRefreshToken(); + var accessToken = GenerateAccessToken(userId); + + var authTokenStruct = new AuthToken + { + CreatedAt = DateTime.UtcNow, + Ip = request.Ip, + RefreshToken = refreshToken, + UserAgent = request.UserAgent, + UserId = userId, + AccessToken = accessToken.Token + }; + + await SetAuthTokenDataToCache(request.Fingerprint, authTokenStruct, cancellation); + + return new AuthTokenResponse + { + AccessToken = accessToken.Token, + ExpiresIn = accessToken.ExpireIn, + RefreshToken = authTokenStruct.RefreshToken + }; + } + + public async Task GenerateAuthTokensWithPreAuthAsync(TokenRequest request, string preAuthToken, + CancellationToken cancellation = default) => + await GenerateAuthTokensAsync(request, + await new PreAuthService(cache).MatchToken(request, preAuthToken, cancellation), + cancellation); + + public async Task RefreshTokenAsync(TokenRequest request, string refreshToken, CancellationToken cancellation = default) + { + var authToken = await cache.GetAsync(GetAuthCacheKey(request.Fingerprint), cancellation) + ?? throw new SecurityException(request.Fingerprint); + + if (authToken.RefreshToken != refreshToken || + authToken.UserAgent != request.UserAgent && + authToken.Ip != request.Ip) + { + await cache.RemoveAsync(request.Fingerprint, cancellation); + await RevokeAccessToken(authToken.AccessToken); + + throw new SecurityException(request.Fingerprint); + } + + var accessToken = GenerateAccessToken(authToken.UserId); + await RevokeAccessToken(authToken.AccessToken); + + authToken.AccessToken = accessToken.Token; + await SetAuthTokenDataToCache(request.Fingerprint, authToken, cancellation); + + return new AuthTokenResponse + { + AccessToken = accessToken.Token, + ExpiresIn = accessToken.ExpireIn, + RefreshToken = GenerateRefreshToken() + }; + } + + public async Task LogoutAsync(string fingerprint, CancellationToken cancellation = default) + { + var authTokenStruct = await cache.GetAsync(GetAuthCacheKey(fingerprint), cancellation); + if (authTokenStruct == null) return; + + await RevokeAccessToken(authTokenStruct.AccessToken); + + await cache.RemoveAsync(fingerprint, cancellation); + } +} \ No newline at end of file diff --git a/Security/Services/GeneratorKey.cs b/Security/Services/GeneratorKey.cs new file mode 100644 index 0000000..79a0430 --- /dev/null +++ b/Security/Services/GeneratorKey.cs @@ -0,0 +1,28 @@ +using System; +using System.Buffers.Text; +using System.Text; + +namespace Mirea.Api.Security.Services; + +public static class GeneratorKey +{ + public static ReadOnlySpan GenerateBytes(int size) + { + var key = new byte[size]; + using var rng = System.Security.Cryptography.RandomNumberGenerator.Create(); + rng.GetNonZeroBytes(key); + return key; + } + + public static string GenerateBase64(int size) => + Convert.ToBase64String(GenerateBytes(size)); + + public static string GenerateString(int size) + { + var randomBytes = GenerateBytes(size); + Span utf8Bytes = new byte[Base64.GetMaxEncodedToUtf8Length(randomBytes.Length)]; + + Base64.EncodeToUtf8(randomBytes, utf8Bytes, out _, out _); + return Encoding.UTF8.GetString(utf8Bytes); + } +} \ No newline at end of file diff --git a/Security/Services/PasswordHashService.cs b/Security/Services/PasswordHashService.cs new file mode 100644 index 0000000..8673222 --- /dev/null +++ b/Security/Services/PasswordHashService.cs @@ -0,0 +1,56 @@ +using Konscious.Security.Cryptography; +using System; +using System.Text; + +namespace Mirea.Api.Security.Services; + +public class PasswordHashService +{ + public int SaltSize { private get; init; } + public int HashSize { private get; init; } + public int Iterations { private get; init; } + public int Memory { private get; init; } + public int Parallelism { private get; init; } + public string? Secret { private get; init; } + + private ReadOnlySpan HashPassword(string password, ReadOnlySpan salt) + { + var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password)) + { + Iterations = Iterations, + MemorySize = Memory, + DegreeOfParallelism = Parallelism, + Salt = salt.ToArray() + }; + + if (!string.IsNullOrEmpty(Secret)) + argon2.KnownSecret = Convert.FromBase64String(Secret); + + return argon2.GetBytes(HashSize); + } + + private static bool ConstantTimeComparison(ReadOnlySpan a, ReadOnlySpan b) + { + if (a.Length != b.Length) + return false; + + int result = 0; + for (int i = 0; i < a.Length; i++) + result |= a[i] ^ b[i]; + return result == 0; + } + + public (string Salt, string Hash) HashPassword(string password) + { + var salt = GeneratorKey.GenerateBytes(SaltSize); + var hash = HashPassword(password, salt); + + return (Convert.ToBase64String(salt), Convert.ToBase64String(hash)); + } + + public bool VerifyPassword(string password, ReadOnlySpan salt, ReadOnlySpan hash) => + ConstantTimeComparison(HashPassword(password, salt), hash); + + public bool VerifyPassword(string password, string saltBase64, string hashBase64) => + VerifyPassword(password, Convert.FromBase64String(saltBase64), Convert.FromBase64String(hashBase64)); +} \ No newline at end of file diff --git a/Security/Services/PreAuthService.cs b/Security/Services/PreAuthService.cs new file mode 100644 index 0000000..948b997 --- /dev/null +++ b/Security/Services/PreAuthService.cs @@ -0,0 +1,64 @@ +using Mirea.Api.Security.Common.Domain; +using Mirea.Api.Security.Common.Dto.Requests; +using Mirea.Api.Security.Common.Dto.Responses; +using Mirea.Api.Security.Common.Interfaces; +using System; +using System.Security; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Mirea.Api.Security.Services; + +public class PreAuthService(ICacheService cache) +{ + public TimeSpan Lifetime { private get; init; } + + private static string GeneratePreAuthToken() => Guid.NewGuid().ToString().Replace("-", "") + + GeneratorKey.GenerateString(16); + + private static string GetPreAuthCacheKey(string fingerprint) => $"{fingerprint}_pre_auth_token"; + + public async Task GeneratePreAuthTokenAsync(TokenRequest request, string userId, CancellationToken cancellation = default) + { + var preAuthToken = GeneratePreAuthToken(); + + var preAuthTokenStruct = new PreAuthToken + { + Fingerprint = request.Fingerprint, + UserId = userId, + UserAgent = request.UserAgent, + Token = preAuthToken, + Ip = request.Ip + }; + + await cache.SetAsync( + GetPreAuthCacheKey(request.Fingerprint), + JsonSerializer.SerializeToUtf8Bytes(preAuthTokenStruct), + absoluteExpirationRelativeToNow: Lifetime, + cancellationToken: cancellation); + + return new PreAuthTokenResponse + { + Token = preAuthToken, + ExpiresIn = DateTime.UtcNow.Add(Lifetime) + }; + } + public async Task MatchToken(TokenRequest request, string preAuthToken, CancellationToken cancellation = default) + { + var preAuthTokenStruct = await cache.GetAsync(GetPreAuthCacheKey(request.Fingerprint), cancellation) + ?? throw new SecurityException($"The token was not found using fingerprint \"{request.Fingerprint}\""); + + if (preAuthTokenStruct == null || + preAuthTokenStruct.Token != preAuthToken || + (preAuthTokenStruct.UserAgent != request.UserAgent && + preAuthTokenStruct.Ip != request.Ip)) + { + throw new SecurityException("It was not possible to verify the authenticity of the token"); + } + + await cache.RemoveAsync(GetPreAuthCacheKey(request.Fingerprint), cancellation); + + return preAuthTokenStruct.UserId; + } +} \ No newline at end of file