Compare commits
No commits in common. "f6f7ed6c86adc86fee5f74794cef4c6c4ad58791" and "e1ad287da1131c89f3d6b169a8d768e304567f3c" have entirely different histories.
f6f7ed6c86
...
e1ad287da1
84
.env
84
.env
@ -16,86 +16,4 @@
|
|||||||
# databases (if Sqlite) and other data that should be saved in a place other than the place where the program is launched.
|
# 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
|
# 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.
|
# 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=
|
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
|
|
@ -25,8 +25,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Persistence", "Persistence\
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApiDto", "ApiDto\ApiDto.csproj", "{0335FA36-E137-453F-853B-916674C168FE}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApiDto", "ApiDto\ApiDto.csproj", "{0335FA36-E137-453F-853B-916674C168FE}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Security", "Security\Security.csproj", "{47A3C065-4E1D-4B1E-AAB4-2BB8F40E56B4}"
|
|
||||||
EndProject
|
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@ -53,10 +51,6 @@ Global
|
|||||||
{0335FA36-E137-453F-853B-916674C168FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{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.ActiveCfg = Release|Any CPU
|
||||||
{0335FA36-E137-453F-853B-916674C168FE}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
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<T>(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<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var cachedValue = await cache.GetAsync(key, cancellationToken);
|
|
||||||
return cachedValue == null ? default : JsonSerializer.Deserialize<T>(cachedValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task RemoveAsync(string key, CancellationToken cancellationToken = default) =>
|
|
||||||
cache.RemoveAsync(key, cancellationToken);
|
|
||||||
}
|
|
@ -1,82 +0,0 @@
|
|||||||
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<byte> EncryptionKey { get; init; }
|
|
||||||
public ReadOnlyMemory<byte> 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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,34 +0,0 @@
|
|||||||
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<T>(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<T?> GetAsync<T>(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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
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<bool> IsTokenRevokedAsync(string token) => Task.FromResult(cache.TryGetValue(token, out _));
|
|
||||||
}
|
|
@ -23,17 +23,15 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Cronos" Version="0.8.4" />
|
<PackageReference Include="Cronos" Version="0.8.4" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
<PackageReference Include="StackExchange.Redis" Version="2.7.33" />
|
<PackageReference Include="StackExchange.Redis" Version="2.7.33" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.Versioning" Version="2.0.0" />
|
<PackageReference Include="Swashbuckle.AspNetCore.Versioning" Version="2.0.0" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.6.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ApiDto\ApiDto.csproj" />
|
<ProjectReference Include="..\ApiDto\ApiDto.csproj" />
|
||||||
<ProjectReference Include="..\Domain\Domain.csproj" />
|
<ProjectReference Include="..\Domain\Domain.csproj" />
|
||||||
<ProjectReference Include="..\Persistence\Persistence.csproj" />
|
<ProjectReference Include="..\Persistence\Persistence.csproj" />
|
||||||
<ProjectReference Include="..\Security\Security.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
@ -1,4 +1,3 @@
|
|||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||||
@ -7,24 +6,20 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
using Mirea.Api.DataAccess.Application;
|
using Mirea.Api.DataAccess.Application;
|
||||||
using Mirea.Api.DataAccess.Persistence;
|
using Mirea.Api.DataAccess.Persistence;
|
||||||
using Mirea.Api.Endpoint.Common.Interfaces;
|
using Mirea.Api.Endpoint.Common.Interfaces;
|
||||||
using Mirea.Api.Endpoint.Common.Services;
|
using Mirea.Api.Endpoint.Common.Services;
|
||||||
using Mirea.Api.Endpoint.Common.Services.Security;
|
|
||||||
using Mirea.Api.Endpoint.Configuration;
|
using Mirea.Api.Endpoint.Configuration;
|
||||||
using Mirea.Api.Endpoint.Configuration.General;
|
using Mirea.Api.Endpoint.Configuration.General;
|
||||||
using Mirea.Api.Endpoint.Configuration.General.Validators;
|
using Mirea.Api.Endpoint.Configuration.General.Validators;
|
||||||
using Mirea.Api.Endpoint.Configuration.Swagger;
|
using Mirea.Api.Endpoint.Configuration.Swagger;
|
||||||
using Mirea.Api.Endpoint.Middleware;
|
using Mirea.Api.Endpoint.Middleware;
|
||||||
using Mirea.Api.Security.Common.Interfaces;
|
|
||||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Mirea.Api.Endpoint;
|
namespace Mirea.Api.Endpoint;
|
||||||
|
|
||||||
@ -45,66 +40,6 @@ public class Program
|
|||||||
return result.Build();
|
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<IAccessToken, JwtTokenService>(_ => 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<IAccessToken, JwtTokenService>();
|
|
||||||
services.AddSingleton<IRevokedToken, MemoryRevokedTokenService>();
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
Directory.SetCurrentDirectory(AppDomain.CurrentDomain.BaseDirectory);
|
Directory.SetCurrentDirectory(AppDomain.CurrentDomain.BaseDirectory);
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
@ -1,8 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace Mirea.Api.Security.Common.Dto.Responses;
|
|
||||||
|
|
||||||
public class PreAuthTokenResponse
|
|
||||||
{
|
|
||||||
public required string Token { get; set; }
|
|
||||||
public DateTime ExpiresIn { get; set; }
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace Mirea.Api.Security.Common.Interfaces;
|
|
||||||
|
|
||||||
public interface IAccessToken
|
|
||||||
{
|
|
||||||
(string Token, DateTime ExpireIn) GenerateToken(string userId);
|
|
||||||
DateTimeOffset GetExpireDateTime(string token);
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace Mirea.Api.Security.Common.Interfaces;
|
|
||||||
|
|
||||||
public interface ICacheService
|
|
||||||
{
|
|
||||||
Task SetAsync<T>(string key, T value,
|
|
||||||
TimeSpan? absoluteExpirationRelativeToNow = null,
|
|
||||||
TimeSpan? slidingExpiration = null,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default);
|
|
||||||
Task RemoveAsync(string key, CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace Mirea.Api.Security.Common.Interfaces;
|
|
||||||
|
|
||||||
public interface IRevokedToken
|
|
||||||
{
|
|
||||||
Task AddTokenToRevokedAsync(string token, DateTimeOffset expiresIn);
|
|
||||||
Task<bool> IsTokenRevokedAsync(string token);
|
|
||||||
}
|
|
@ -1,57 +0,0 @@
|
|||||||
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<ICacheService>();
|
|
||||||
|
|
||||||
return new PreAuthService(cache)
|
|
||||||
{
|
|
||||||
Lifetime = lifeTimePreAuthToken
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
var lifeTimeRefreshToken = TimeSpan.FromMinutes(int.Parse(configuration["SECURITY_LIFE_TIME_RT"]!));
|
|
||||||
|
|
||||||
services.AddSingleton(provider =>
|
|
||||||
{
|
|
||||||
var cacheService = provider.GetRequiredService<ICacheService>();
|
|
||||||
var accessTokenService = provider.GetRequiredService<IAccessToken>();
|
|
||||||
var revokedTokenService = provider.GetRequiredService<IRevokedToken>();
|
|
||||||
|
|
||||||
return new AuthService(cacheService, accessTokenService, revokedTokenService)
|
|
||||||
{
|
|
||||||
Lifetime = lifeTimeRefreshToken
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,21 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<ImplicitUsings>disable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<Company>Winsomnia</Company>
|
|
||||||
<Version>1.0.0-a0</Version>
|
|
||||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
|
||||||
<FileVersion>1.0.0.0</FileVersion>
|
|
||||||
<AssemblyName>Mirea.Api.Security</AssemblyName>
|
|
||||||
<RootNamespace>$(AssemblyName)</RootNamespace>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.0" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
@ -1,103 +0,0 @@
|
|||||||
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<AuthTokenResponse> 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<AuthTokenResponse> GenerateAuthTokensWithPreAuthAsync(TokenRequest request, string preAuthToken,
|
|
||||||
CancellationToken cancellation = default) =>
|
|
||||||
await GenerateAuthTokensAsync(request,
|
|
||||||
await new PreAuthService(cache).MatchToken(request, preAuthToken, cancellation),
|
|
||||||
cancellation);
|
|
||||||
|
|
||||||
public async Task<AuthTokenResponse> RefreshTokenAsync(TokenRequest request, string refreshToken, CancellationToken cancellation = default)
|
|
||||||
{
|
|
||||||
var authToken = await cache.GetAsync<AuthToken>(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<AuthToken>(GetAuthCacheKey(fingerprint), cancellation);
|
|
||||||
if (authTokenStruct == null) return;
|
|
||||||
|
|
||||||
await RevokeAccessToken(authTokenStruct.AccessToken);
|
|
||||||
|
|
||||||
await cache.RemoveAsync(fingerprint, cancellation);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,28 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Buffers.Text;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Mirea.Api.Security.Services;
|
|
||||||
|
|
||||||
public static class GeneratorKey
|
|
||||||
{
|
|
||||||
public static ReadOnlySpan<byte> 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<byte> utf8Bytes = new byte[Base64.GetMaxEncodedToUtf8Length(randomBytes.Length)];
|
|
||||||
|
|
||||||
Base64.EncodeToUtf8(randomBytes, utf8Bytes, out _, out _);
|
|
||||||
return Encoding.UTF8.GetString(utf8Bytes);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,56 +0,0 @@
|
|||||||
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<byte> HashPassword(string password, ReadOnlySpan<byte> 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<byte> a, ReadOnlySpan<byte> 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<byte> salt, ReadOnlySpan<byte> hash) =>
|
|
||||||
ConstantTimeComparison(HashPassword(password, salt), hash);
|
|
||||||
|
|
||||||
public bool VerifyPassword(string password, string saltBase64, string hashBase64) =>
|
|
||||||
VerifyPassword(password, Convert.FromBase64String(saltBase64), Convert.FromBase64String(hashBase64));
|
|
||||||
}
|
|
@ -1,64 +0,0 @@
|
|||||||
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<PreAuthTokenResponse> 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<string> MatchToken(TokenRequest request, string preAuthToken, CancellationToken cancellation = default)
|
|
||||||
{
|
|
||||||
var preAuthTokenStruct = await cache.GetAsync<PreAuthToken>(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;
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user