Compare commits

...

46 Commits

Author SHA1 Message Date
f6f7ed6c86 Add hashing and other security features (#12)
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m50s
Reviewed-on: #12
2024-05-29 06:42:46 +03:00
d2ef99d0b2 feat: add security configure 2024-05-29 06:42:14 +03:00
38ec80a566 feat: add configuration for jwt token 2024-05-29 06:30:01 +03:00
85802aa514 feat: add jwt token service 2024-05-29 06:28:42 +03:00
f2aa274d0a build: add jwt ref 2024-05-29 06:28:21 +03:00
62a859b44c style: clean code 2024-05-29 06:11:29 +03:00
6f02021fe7 feat: add revoked token service 2024-05-29 06:11:18 +03:00
526bf5682b build: add security ref 2024-05-29 06:08:41 +03:00
9287acf7d2 feat: add cache implementations depending on the type 2024-05-29 06:08:14 +03:00
2efdc6dbfe feat: add auth service to DI 2024-05-29 06:04:09 +03:00
25b6c7d691 feat: add method if there is no pre-auth token 2024-05-29 06:00:15 +03:00
61218c38a0 feat: add logout 2024-05-29 05:56:27 +03:00
d84011cd71 feat: add refresh token 2024-05-29 05:55:57 +03:00
4138c70007 feat: add wrap for revoke access token 2024-05-29 05:55:31 +03:00
9dd505a608 feat: add auth token response 2024-05-29 05:55:13 +03:00
79fb05d428 feat: add token revocation 2024-05-29 05:54:45 +03:00
81f2f995b0 feat: add generate auth token 2024-05-29 05:51:32 +03:00
f3063c5322 feat: add generate access token 2024-05-29 05:51:03 +03:00
43011457d6 feat: add wrap for save to cache 2024-05-29 05:50:47 +03:00
4240ad8110 feat: add auth key for cache 2024-05-29 05:36:26 +03:00
a3a42dd5c2 feat: add generate refresh token 2024-05-29 05:35:44 +03:00
b25be758ad feat: add auth token 2024-05-29 05:33:55 +03:00
7df4c8e4b6 feat: add auth service 2024-05-29 05:32:22 +03:00
f55d701ff3 feat: add sliding expiration for cache 2024-05-29 05:30:00 +03:00
d3a60d2a30 feat: add interface for gen access token 2024-05-29 05:29:25 +03:00
470031af39 feat: add match token 2024-05-29 05:27:49 +03:00
916b3795ed feat: add ip to struct 2024-05-29 05:27:27 +03:00
f4ad1518ef style: rename variables 2024-05-29 04:58:21 +03:00
ac7bbde75e fix: add key for save pre auth token 2024-05-29 04:57:44 +03:00
47a57693f8 sec: complicate the token 2024-05-29 04:55:34 +03:00
d05ba5349f refactor: isolate key generation 2024-05-29 04:48:37 +03:00
5fde5bd396 build: add security to sln 2024-05-29 04:34:39 +03:00
8408b80c35 feat: add pre-auth to DI 2024-05-29 04:34:00 +03:00
b14ae26a48 feat: add pre-auth service 2024-05-29 04:31:47 +03:00
3c9694de08 feat: add request for get token 2024-05-29 04:31:19 +03:00
e3db6b73e0 feat: add pre-auth response 2024-05-29 04:30:55 +03:00
58ceca5313 feat: add pre-auth token structure 2024-05-29 04:30:32 +03:00
f749ed42f5 feat: add interface for save to cache 2024-05-29 04:29:50 +03:00
6029ea3c2c refactor: move hashing to services 2024-05-29 04:11:04 +03:00
656d7dca0b feat: add DI 2024-05-29 04:09:10 +03:00
e3dd0a8419 build: add ref for DI 2024-05-29 04:08:51 +03:00
3149f50586 refactor: move class to correct namespace 2024-05-29 04:05:18 +03:00
e1123cf36b feat: add password hashing 2024-05-29 04:04:02 +03:00
930edd4c2c build: add ref 2024-05-29 04:03:47 +03:00
7e283fe643 feat: add security layer 2024-05-29 04:03:20 +03:00
c427006283 docs: add env data 2024-05-29 03:52:31 +03:00
22 changed files with 736 additions and 2 deletions

82
.env
View File

@ -17,3 +17,85 @@
# 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

View File

@ -25,6 +25,8 @@ 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
@ -51,6 +53,10 @@ 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

View File

@ -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<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);
}

View File

@ -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<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;
}
}

View File

@ -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<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;
}
}

View File

@ -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<bool> IsTokenRevokedAsync(string token) => Task.FromResult(cache.TryGetValue(token, out _));
}

View File

@ -23,15 +23,17 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Cronos" Version="0.8.4" /> <PackageReference Include="Cronos" Version="0.8.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
<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>

View File

@ -1,3 +1,4 @@
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;
@ -6,20 +7,24 @@ 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;
@ -40,6 +45,66 @@ 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);

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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);
}

View File

@ -0,0 +1,16 @@
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);
}

View File

@ -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<bool> IsTokenRevokedAsync(string token);
}

View File

@ -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<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;
}
}

21
Security/Security.csproj Normal file
View File

@ -0,0 +1,21 @@
<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>

View File

@ -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<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);
}
}

View File

@ -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<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);
}
}

View File

@ -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<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));
}

View File

@ -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<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;
}
}