Compare commits

..

5 Commits

Author SHA1 Message Date
a0ff624481 fix: add forgotten changes
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m39s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 3m11s
2024-10-31 04:12:22 +03:00
cd6f25deba refactor: transfer logic
All logic related to token manipulation has been transferred to the AuthService. Also added TOTP 2FA and rethought the logic of logging into the application
2024-10-31 04:12:02 +03:00
0f47a98ad9 feat: return security exception 2024-10-31 04:07:35 +03:00
3279ef594b fix: change current culture to russian for import 2024-10-31 04:06:58 +03:00
5bc729eb66 fix: add an implementation for saving primitive data 2024-10-31 04:05:40 +03:00
23 changed files with 438 additions and 283 deletions

View File

@ -0,0 +1,17 @@
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents the steps required after a login attempt.
/// </summary>
public enum AuthenticationStep
{
/// <summary>
/// No additional steps required; the user is successfully logged in.
/// </summary>
None,
/// <summary>
/// TOTP (Time-based One-Time Password) is required for additional verification.
/// </summary>
TotpRequired,
}

View File

@ -17,12 +17,43 @@ public class DistributedCacheService(IDistributedCache cache) : ICacheService
SlidingExpiration = slidingExpiration SlidingExpiration = slidingExpiration
}; };
var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
if (type.IsPrimitive || type == typeof(string) || type == typeof(DateTime))
{
await cache.SetStringAsync(key, value?.ToString() ?? string.Empty, options, cancellationToken);
return;
}
var serializedValue = value as byte[] ?? JsonSerializer.SerializeToUtf8Bytes(value); var serializedValue = value as byte[] ?? JsonSerializer.SerializeToUtf8Bytes(value);
await cache.SetAsync(key, serializedValue, options, cancellationToken); await cache.SetAsync(key, serializedValue, options, cancellationToken);
} }
public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default) public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
{ {
var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
if (type.IsPrimitive || type == typeof(string) || type == typeof(DateTime))
{
var primitiveValue = await cache.GetStringAsync(key, cancellationToken);
if (string.IsNullOrEmpty(primitiveValue))
return default;
if (type == typeof(string))
return (T?)(object?)primitiveValue;
var tryParseMethod = type.GetMethod("TryParse", [typeof(string), type.MakeByRefType()])
?? throw new NotSupportedException($"Type {type.Name} does not support TryParse.");
var parameters = new[] { primitiveValue, Activator.CreateInstance(type) };
var success = (bool)tryParseMethod.Invoke(null, parameters)!;
if (success)
return (T)parameters[1]!;
return default;
}
var cachedValue = await cache.GetAsync(key, cancellationToken); var cachedValue = await cache.GetAsync(key, cancellationToken);
return cachedValue == null ? default : JsonSerializer.Deserialize<T>(cachedValue); return cachedValue == null ? default : JsonSerializer.Deserialize<T>(cachedValue);
} }

View File

@ -17,17 +17,41 @@ public class MemoryCacheService(IMemoryCache cache) : ICacheService
SlidingExpiration = slidingExpiration SlidingExpiration = slidingExpiration
}; };
var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
if (type.IsPrimitive || type == typeof(string) || type == typeof(DateTime))
{
cache.Set(key, value?.ToString() ?? string.Empty, options);
return Task.CompletedTask;
}
cache.Set(key, value as byte[] ?? JsonSerializer.SerializeToUtf8Bytes(value), options); cache.Set(key, value as byte[] ?? JsonSerializer.SerializeToUtf8Bytes(value), options);
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default) public Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
{ {
return Task.FromResult( var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
cache.TryGetValue(key, out byte[]? value) ?
JsonSerializer.Deserialize<T>(value) : if (!type.IsPrimitive && type != typeof(string) && type != typeof(DateTime))
default return Task.FromResult(
); cache.TryGetValue(key, out byte[]? value) ? JsonSerializer.Deserialize<T>(value) : default
);
var primitiveValue = cache.Get(key);
if (string.IsNullOrEmpty(primitiveValue?.ToString()))
return Task.FromResult<T?>(default);
if (type == typeof(string))
return Task.FromResult((T?)primitiveValue);
var tryParseMethod = type.GetMethod("TryParse", [typeof(string), type.MakeByRefType()])
?? throw new NotSupportedException($"Type {type.Name} does not support TryParse.");
var parameters = new[] { primitiveValue, Activator.CreateInstance(type) };
var success = (bool)tryParseMethod.Invoke(null, parameters)!;
return success ? Task.FromResult((T?)parameters[1]) : Task.FromResult<T?>(default);
} }
public Task RemoveAsync(string key, CancellationToken cancellationToken = default) public Task RemoveAsync(string key, CancellationToken cancellationToken = default)

View File

@ -5,6 +5,7 @@ using Mirea.Api.DataAccess.Application.Common.Exceptions;
using Mirea.Api.Dto.Responses; using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Exceptions; using Mirea.Api.Endpoint.Common.Exceptions;
using System; using System;
using System.Security;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -44,6 +45,9 @@ public class CustomExceptionHandlerMiddleware(RequestDelegate next, ILogger<Cust
case ControllerArgumentException: case ControllerArgumentException:
code = StatusCodes.Status400BadRequest; code = StatusCodes.Status400BadRequest;
break; break;
case SecurityException:
code = StatusCodes.Status401Unauthorized;
break;
} }
context.Response.ContentType = "application/json"; context.Response.ContentType = "application/json";

View File

@ -1,4 +1,5 @@
using Mirea.Api.Endpoint.Common.Services; using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Security.Common.Domain;
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
@ -16,7 +17,8 @@ public class Admin : ISaveSettings
public required string Email { get; set; } public required string Email { get; set; }
public required string PasswordHash { get; set; } public required string PasswordHash { get; set; }
public required string Salt { get; set; } public required string Salt { get; set; }
public SecondFactor SecondFactor { get; set; } = SecondFactor.None;
public string? Secret { get; set; }
public void SaveSetting() public void SaveSetting()
{ {

View File

@ -2,109 +2,64 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Mirea.Api.Dto.Common; using Mirea.Api.Dto.Common;
using Mirea.Api.Dto.Requests; using Mirea.Api.Dto.Requests;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes; using Mirea.Api.Endpoint.Common.Attributes;
using Mirea.Api.Endpoint.Common.Exceptions; using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.Services; using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Core.Middleware;
using Mirea.Api.Endpoint.Configuration.Model; using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Security.Common.Dto.Requests; using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Services; using Mirea.Api.Security.Services;
using System; using System;
using System.Security;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1; namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")] [ApiVersion("1.0")]
public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, PasswordHashService passwordService) : BaseController, IActionFilter public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, PasswordHashService passwordService) : BaseController
{ {
private string Fingerprint { get; set; } = string.Empty; private CookieOptionsParameters GetCookieParams() =>
private string Ip { get; set; } = string.Empty; new()
private string UserAgent { get; set; } = string.Empty;
private string RefreshToken { get; set; } = string.Empty;
private void SetCookie(string name, string value, DateTimeOffset? expires = null)
{
var cookieOptions = new CookieOptions
{ {
Expires = expires,
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api",
Domain = HttpContext.GetCurrentDomain(), Domain = HttpContext.GetCurrentDomain(),
HttpOnly = true, Path = UrlHelper.GetSubPathWithoutFirstApiName + "api"
#if !DEBUG
Secure = true
#endif
}; };
Response.Cookies.Append(name, value, cookieOptions);
}
private void SetRefreshToken(string value, DateTimeOffset? expires = null)
{
SetCookie("refresh_token", value, expires);
SetCookie("user_key", Fingerprint);
}
private void SetFirstToken(string value, DateTimeOffset? expires = null)
{
SetCookie("authentication_token", value, expires);
SetCookie("user_key", Fingerprint);
}
private void SetAuthToken(string value, DateTimeOffset? expires = null)
{
SetCookie(CookieAuthorizationMiddleware.JwtAuthorizationName, value, expires);
SetCookie("user_key", Fingerprint);
}
[ApiExplorerSettings(IgnoreApi = true)]
public void OnActionExecuting(ActionExecutingContext context)
{
Ip = HttpContext.Connection.RemoteIpAddress?.ToString()!;
UserAgent = Request.Headers.UserAgent.ToString();
Fingerprint = Request.Cookies["user_key"] ?? string.Empty;
RefreshToken = Request.Cookies["refresh_token"] ?? string.Empty;
if (!string.IsNullOrWhiteSpace(Fingerprint)) return;
Fingerprint = Guid.NewGuid().ToString().Replace("-", "");
}
[ApiExplorerSettings(IgnoreApi = true)]
public void OnActionExecuted(ActionExecutedContext context) { }
/// <summary>
/// Handles user authentication by verifying the username/email and password,
/// then generating and returning an authentication token if successful.
/// </summary>
/// <param name="request">The login request containing the username/email and password.</param>
/// <returns>User's AuthRoles.</returns>
[HttpPost("Login")] [HttpPost("Login")]
[BadRequestResponse] [BadRequestResponse]
public async Task<ActionResult<AuthRoles>> Login([FromBody] LoginRequest request) public async Task<ActionResult<AuthenticationStep>> Login([FromBody] LoginRequest request)
{ {
var userEntity = user.Value; var userEntity = user.Value;
if (!userEntity.Username.Equals(request.Username, StringComparison.OrdinalIgnoreCase) && if (!userEntity.Username.Equals(request.Username, StringComparison.OrdinalIgnoreCase) &&
!userEntity.Email.Equals(request.Username, StringComparison.OrdinalIgnoreCase) || !userEntity.Email.Equals(request.Username, StringComparison.OrdinalIgnoreCase))
!passwordService.VerifyPassword(request.Password, userEntity.Salt, userEntity.PasswordHash))
return BadRequest("Invalid username/email or password"); return BadRequest("Invalid username/email or password");
var token = await auth.GenerateAuthTokensAsync(new TokenRequest var tokenResult = await auth.LoginAsync(
{ GetCookieParams(),
Fingerprint = Fingerprint, new User
Ip = Ip, {
UserAgent = UserAgent Id = 1,
}, "1"); Username = userEntity.Username,
Email = userEntity.Email,
PasswordHash = userEntity.PasswordHash,
Salt = userEntity.Salt,
SecondFactor = userEntity.SecondFactor,
SecondFactorToken = userEntity.Secret
},
HttpContext, request.Password);
SetRefreshToken(token.RefreshToken, token.RefreshExpiresIn); return Ok(tokenResult ? AuthenticationStep.None : AuthenticationStep.TotpRequired);
SetAuthToken(token.AccessToken, token.AccessExpiresIn); }
return Ok(AuthRoles.Admin); [HttpGet("Login")]
[BadRequestResponse]
public async Task<ActionResult<AuthenticationStep>> Login([FromQuery] string code)
{
var tokenResult = await auth.LoginAsync(GetCookieParams(), HttpContext, code);
return Ok(tokenResult ? AuthenticationStep.None : AuthenticationStep.TotpRequired);
} }
/// <summary> /// <summary>
@ -116,30 +71,8 @@ public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, Pass
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AuthRoles>> ReLogin() public async Task<ActionResult<AuthRoles>> ReLogin()
{ {
if (string.IsNullOrEmpty(RefreshToken)) await auth.RefreshTokenAsync(GetCookieParams(), HttpContext);
return Unauthorized(); return Ok(AuthRoles.Admin);
try
{
var token = await auth.RefreshTokenAsync(
new TokenRequest
{
Ip = Ip,
UserAgent = UserAgent,
Fingerprint = Fingerprint
},
RefreshToken
);
SetRefreshToken(token.RefreshToken, token.RefreshExpiresIn);
SetAuthToken(token.AccessToken, token.AccessExpiresIn);
return Ok(AuthRoles.Admin);
}
catch (SecurityException)
{
return Forbid();
}
} }
/// <summary> /// <summary>
@ -151,11 +84,7 @@ public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, Pass
[Authorize] [Authorize]
public async Task<ActionResult> Logout() public async Task<ActionResult> Logout()
{ {
SetRefreshToken("", DateTimeOffset.MinValue); await auth.LogoutAsync(GetCookieParams(), HttpContext);
SetFirstToken("", DateTimeOffset.MinValue);
SetAuthToken("", DateTimeOffset.MinValue);
await auth.LogoutAsync(Fingerprint);
return Ok(); return Ok();
} }

View File

@ -79,6 +79,8 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
var pairsDictionary = config.Value.ScheduleSettings!.PairPeriod; var pairsDictionary = config.Value.ScheduleSettings!.PairPeriod;
var ruCulture = new CultureInfo("ru-RU");
foreach (var dto in result.GroupBy(s => new foreach (var dto in result.GroupBy(s => new
{ {
s.DayOfWeek, s.DayOfWeek,
@ -109,7 +111,7 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
{ {
// День // День
worksheet.Cells[row, col++].Value = worksheet.Cells[row, col++].Value =
$"{(int)dto.DayOfWeek} [{CultureInfo.CurrentCulture.DateTimeFormat.GetAbbreviatedDayName(dto.DayOfWeek).ToUpper()}]"; $"{(int)dto.DayOfWeek} [{ruCulture.DateTimeFormat.GetAbbreviatedDayName(dto.DayOfWeek).ToUpper()}]";
// Пара // Пара
worksheet.Cells[row, col++].Value = dto.PairNumber + " п"; worksheet.Cells[row, col++].Value = dto.PairNumber + " п";
@ -118,7 +120,7 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
worksheet.Cells[row, col++].Value = $"[{(dto.IsEven ? 2 : 1)}] {(dto.IsEven ? "Четная" : "Нечетная")}"; worksheet.Cells[row, col++].Value = $"[{(dto.IsEven ? 2 : 1)}] {(dto.IsEven ? "Четная" : "Нечетная")}";
// Время // Время
worksheet.Cells[row, col++].Value = pairsDictionary[dto.PairNumber].Start.ToString(CultureInfo.CurrentCulture); worksheet.Cells[row, col++].Value = pairsDictionary[dto.PairNumber].Start.ToString(ruCulture);
// Группа // Группа
worksheet.Cells[row, col].Style.WrapText = true; worksheet.Cells[row, col].Style.WrapText = true;

View File

@ -0,0 +1,8 @@
namespace Mirea.Api.Security.Common;
public class CookieNames
{
public const string AccessToken = "access_token";
public const string RefreshToken = "refresh_token";
public const string FingerprintToken = "fingerprint";
}

View File

@ -2,11 +2,25 @@
namespace Mirea.Api.Security.Common.Domain; namespace Mirea.Api.Security.Common.Domain;
public class AuthToken internal class AuthToken
{ {
public required string RefreshToken { get; set; } public AuthToken(RequestContextInfo context)
public required string UserAgent { get; set; } {
public required string Ip { get; set; } UserAgent = context.UserAgent;
Ip = context.Ip;
Fingerprint = context.Fingerprint;
RefreshToken = context.RefreshToken;
}
public AuthToken()
{
}
public string UserAgent { get; set; } = null!;
public string Ip { get; set; } = null!;
public string Fingerprint { get; set; } = null!;
public string RefreshToken { get; set; } = null!;
public required string UserId { get; set; } public required string UserId { get; set; }
public required string AccessToken { get; set; } public required string AccessToken { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }

View File

@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Http;
using System;
namespace Mirea.Api.Security.Common.Domain;
public class CookieOptionsParameters
{
public required string Domain { get; set; }
public required string Path { get; set; }
internal void SetCookie(HttpContext context, string name, string value, DateTimeOffset? expires = null)
{
var cookieOptions = new CookieOptions
{
Expires = expires,
Path = Path,
Domain = Domain,
HttpOnly = true,
#if !DEBUG
Secure = true
#endif
};
context.Response.Cookies.Append(name, value, cookieOptions);
}
internal void DropCookie(HttpContext context, string name) =>
SetCookie(context, name, "", DateTimeOffset.MinValue);
}

View File

@ -0,0 +1,22 @@
namespace Mirea.Api.Security.Common.Domain;
internal class FirstAuthToken
{
public FirstAuthToken(RequestContextInfo context)
{
UserAgent = context.UserAgent;
Ip = context.Ip;
Fingerprint = context.Fingerprint;
}
public FirstAuthToken()
{
}
public string UserAgent { get; set; } = null!;
public string Ip { get; set; } = null!;
public string Fingerprint { get; set; } = null!;
public required string UserId { get; set; }
public required SecondFactor SecondFactor { get; set; }
public string? Secret { get; set; }
}

View File

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

View File

@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Http;
using Mirea.Api.Security.Services;
using System;
using System.Security;
namespace Mirea.Api.Security.Common.Domain;
internal class RequestContextInfo
{
public RequestContextInfo(HttpContext context, CookieOptionsParameters cookieOptions)
{
var ipEntity = context.Connection.RemoteIpAddress;
if (string.IsNullOrEmpty(ipEntity?.ToString()))
throw new SecurityException("Ip is required for authorization.");
var ip = ipEntity.MapToIPv4().ToString();
var userAgent = context.Request.Headers.UserAgent.ToString();
var fingerprint = context.Request.Cookies[CookieNames.FingerprintToken];
if (string.IsNullOrEmpty(fingerprint))
{
fingerprint = Guid.NewGuid().ToString().Replace("-", "") + GeneratorKey.GenerateString(32);
cookieOptions.SetCookie(context, CookieNames.FingerprintToken, fingerprint);
}
UserAgent = userAgent;
Fingerprint = fingerprint;
Ip = ip;
RefreshToken = context.Request.Cookies["refresh_token"] ?? string.Empty;
}
public string UserAgent { get; private set; }
public string Ip { get; private set; }
public string Fingerprint { get; private set; }
public string RefreshToken { get; private set; }
}

View File

@ -0,0 +1,18 @@
namespace Mirea.Api.Security.Common.Domain;
public enum SecondFactor
{
None,
Totp
}
public class User
{
public required int Id { get; set; }
public required string Username { get; set; }
public required string Email { get; set; }
public required string PasswordHash { get; set; }
public required string Salt { get; set; }
public required SecondFactor SecondFactor { get; set; }
public string? SecondFactorToken { get; set; }
}

View File

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

View File

@ -1,11 +0,0 @@
using System;
namespace Mirea.Api.Security.Common.Dto.Responses;
public class AuthTokenResponse
{
public required string AccessToken { get; set; }
public DateTime AccessExpiresIn { get; set; }
public required string RefreshToken { get; set; }
public DateTime RefreshExpiresIn { get; set; }
}

View File

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

View File

@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Mirea.Api.Security.Common.Interfaces; using Mirea.Api.Security.Common.Interfaces;
using Mirea.Api.Security.Services; using Mirea.Api.Security.Services;
using System; using System;
@ -26,29 +27,21 @@ public static class DependencyInjection
Secret = configuration["SECURITY_HASH_TOKEN"] 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"]!)); var lifeTimeRefreshToken = TimeSpan.FromMinutes(int.Parse(configuration["SECURITY_LIFE_TIME_RT"]!));
var lifeTimeFirstAuthToken = TimeSpan.FromMinutes(int.Parse(configuration["SECURITY_LIFE_TIME_1_FA"]!));
services.AddSingleton(provider => services.AddSingleton(provider =>
{ {
var cacheService = provider.GetRequiredService<ICacheService>(); var cacheService = provider.GetRequiredService<ICacheService>();
var accessTokenService = provider.GetRequiredService<IAccessToken>(); var accessTokenService = provider.GetRequiredService<IAccessToken>();
var revokedTokenService = provider.GetRequiredService<IRevokedToken>(); var revokedTokenService = provider.GetRequiredService<IRevokedToken>();
var logger = provider.GetRequiredService<ILogger<AuthService>>();
var passwordService = provider.GetRequiredService<PasswordHashService>();
return new AuthService(cacheService, accessTokenService, revokedTokenService) return new AuthService(cacheService, accessTokenService, revokedTokenService, logger, passwordService)
{ {
Lifetime = lifeTimeRefreshToken Lifetime = lifeTimeRefreshToken,
LifetimeFirstAuth = lifeTimeFirstAuthToken
}; };
}); });

View File

@ -0,0 +1,12 @@
{
"profiles": {
"Security": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:58871;http://localhost:58872"
}
}
}

View File

@ -1,21 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Company>Winsomnia</Company> <Company>Winsomnia</Company>
<Version>1.0.0</Version> <Version>1.1.0</Version>
<AssemblyVersion>1.0.3.0</AssemblyVersion> <AssemblyVersion>1.1.3.0</AssemblyVersion>
<FileVersion>1.0.3.0</FileVersion> <FileVersion>1.1.3.0</FileVersion>
<AssemblyName>Mirea.Api.Security</AssemblyName> <AssemblyName>Mirea.Api.Security</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace> <RootNamespace>$(AssemblyName)</RootNamespace>
<OutputType>Library</OutputType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" /> <PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -1,6 +1,7 @@
using Mirea.Api.Security.Common.Domain; using Microsoft.AspNetCore.Http;
using Mirea.Api.Security.Common.Dto.Requests; using Microsoft.Extensions.Logging;
using Mirea.Api.Security.Common.Dto.Responses; using Mirea.Api.Security.Common;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Interfaces; using Mirea.Api.Security.Common.Interfaces;
using System; using System;
using System.Security; using System.Security;
@ -10,9 +11,10 @@ using System.Threading.Tasks;
namespace Mirea.Api.Security.Services; namespace Mirea.Api.Security.Services;
public class AuthService(ICacheService cache, IAccessToken accessTokenService, IRevokedToken revokedToken) public class AuthService(ICacheService cache, IAccessToken accessTokenService, IRevokedToken revokedToken, ILogger<AuthService> logger, PasswordHashService passwordService)
{ {
public TimeSpan Lifetime { private get; init; } public TimeSpan Lifetime { private get; init; }
public TimeSpan LifetimeFirstAuth { private get; init; }
private static string GenerateRefreshToken() => Guid.NewGuid().ToString().Replace("-", "") + private static string GenerateRefreshToken() => Guid.NewGuid().ToString().Replace("-", "") +
GeneratorKey.GenerateString(32); GeneratorKey.GenerateString(32);
@ -20,10 +22,11 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
accessTokenService.GenerateToken(userId); accessTokenService.GenerateToken(userId);
private static string GetAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token"; private static string GetAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token";
private static string GetFirstAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token_first";
private Task SetAuthTokenDataToCache(string fingerprint, AuthToken data, CancellationToken cancellation) => private Task SetAuthTokenDataToCache(AuthToken data, CancellationToken cancellation) =>
cache.SetAsync( cache.SetAsync(
GetAuthCacheKey(fingerprint), GetAuthCacheKey(data.Fingerprint),
JsonSerializer.SerializeToUtf8Bytes(data), JsonSerializer.SerializeToUtf8Bytes(data),
slidingExpiration: Lifetime, slidingExpiration: Lifetime,
cancellationToken: cancellation); cancellationToken: cancellation);
@ -31,51 +34,142 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
private Task RevokeAccessToken(string token) => private Task RevokeAccessToken(string token) =>
revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token)); revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token));
public async Task<AuthTokenResponse> GenerateAuthTokensAsync(TokenRequest request, string userId, CancellationToken cancellation = default) private async Task VerifyUserOrThrowError(RequestContextInfo requestContext, User user, string password,
CancellationToken cancellation = default)
{
if (passwordService.VerifyPassword(password, user.Salt, user.PasswordHash))
return;
var failedLoginCacheName = $"{requestContext.Fingerprint}_login_failed";
var countFailedLogin = await cache.GetAsync<int?>(failedLoginCacheName, cancellation) ?? 1;
var cacheSaveTime = TimeSpan.FromHours(1);
await cache.SetAsync(failedLoginCacheName, countFailedLogin + 1, slidingExpiration: cacheSaveTime, cancellationToken: cancellation);
if (countFailedLogin > 5)
{
logger.LogWarning(
"Multiple failed login attempts detected for user ID {UserId} from IP {UserIp}. Attempt: #{AttemptNumber}. Possible account compromise.",
user.Id,
requestContext.Ip,
countFailedLogin);
throw new SecurityException($"There are many incorrect attempts to access the account. Try again after {(int)cacheSaveTime.TotalMinutes} minutes.");
}
logger.LogInformation(
"Failed login attempt for user ID {UserId} from IP {UserIp} with User-Agent {UserAgent} and Fingerprint {Fingerprint} Attempt: #{AttemptNumber}.",
user.Id,
requestContext.Ip,
requestContext.UserAgent,
requestContext.Fingerprint,
countFailedLogin);
throw new SecurityException("Invalid username/email or password");
}
private async Task GenerateAuthTokensAsync(CookieOptionsParameters cookieOptions, HttpContext context, RequestContextInfo requestContext, string userId, CancellationToken cancellation = default)
{ {
var refreshToken = GenerateRefreshToken(); var refreshToken = GenerateRefreshToken();
var (token, expireIn) = GenerateAccessToken(userId); var (token, expireIn) = GenerateAccessToken(userId);
var authTokenStruct = new AuthToken var authToken = new AuthToken(requestContext)
{ {
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
Ip = request.Ip,
RefreshToken = refreshToken, RefreshToken = refreshToken,
UserAgent = request.UserAgent,
UserId = userId, UserId = userId,
AccessToken = token AccessToken = token
}; };
await SetAuthTokenDataToCache(request.Fingerprint, authTokenStruct, cancellation); await SetAuthTokenDataToCache(authToken, cancellation);
cookieOptions.SetCookie(context, CookieNames.AccessToken, token, expireIn);
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
return new AuthTokenResponse logger.LogInformation(
{ "Successful login attempt for user ID {UserId} from IP {UserIp} with User-Agent {UserAgent} and Fingerprint {Fingerprint}.",
AccessToken = token, authToken.UserId,
AccessExpiresIn = expireIn, authToken.Ip,
RefreshToken = authTokenStruct.RefreshToken, authToken.UserAgent,
RefreshExpiresIn = DateTime.UtcNow.Add(Lifetime), authToken.Fingerprint);
};
} }
public async Task<AuthTokenResponse> GenerateAuthTokensWithPreAuthAsync(TokenRequest request, string preAuthToken, public async Task<bool> LoginAsync(CookieOptionsParameters cookieOptions, HttpContext context, string code, CancellationToken cancellation = default)
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) var requestContext = new RequestContextInfo(context, cookieOptions);
?? throw new SecurityException(request.Fingerprint);
if (authToken.RefreshToken != refreshToken || var firstTokenAuth = await cache.GetAsync<FirstAuthToken?>(GetFirstAuthCacheKey(requestContext.Fingerprint), cancellationToken: cancellation)
authToken.UserAgent != request.UserAgent && ?? throw new SecurityException("The session time has expired");
authToken.Ip != request.Ip)
switch (firstTokenAuth.SecondFactor)
{ {
await cache.RemoveAsync(GetAuthCacheKey(request.Fingerprint), cancellation); case SecondFactor.Totp:
await RevokeAccessToken(authToken.AccessToken); {
if (string.IsNullOrEmpty(firstTokenAuth.Secret))
throw new InvalidOperationException("The user's secrets for data processing were not transferred.");
throw new SecurityException(request.Fingerprint); var totp = new TotpService(firstTokenAuth.Secret);
if (!totp.VerifyToken(code))
throw new SecurityException("The entered code is incorrect.");
}
break;
default:
throw new InvalidOperationException("The system failed to understand the authorization method.");
}
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, firstTokenAuth.UserId, cancellation);
return true;
}
public async Task<bool> LoginAsync(CookieOptionsParameters cookieOptions, User user, HttpContext context, string password, CancellationToken cancellation = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
await VerifyUserOrThrowError(requestContext, user, password, cancellation);
if (user.SecondFactor == SecondFactor.None)
{
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id.ToString(), cancellation);
return true;
}
var firstAuthToken = new FirstAuthToken(requestContext)
{
UserId = user.Id.ToString(),
Secret = user.SecondFactorToken,
SecondFactor = user.SecondFactor
};
await cache.SetAsync(GetFirstAuthCacheKey(requestContext.Fingerprint), firstAuthToken, absoluteExpirationRelativeToNow: LifetimeFirstAuth, cancellationToken: cancellation);
return false;
}
public async Task RefreshTokenAsync(CookieOptionsParameters cookieOptions, HttpContext context, CancellationToken cancellation = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
var authToken = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation)
?? throw new SecurityException("The session time has expired");
if (authToken.RefreshToken != requestContext.RefreshToken ||
authToken.UserAgent != requestContext.UserAgent &&
authToken.Ip != requestContext.Ip)
{
await RevokeAccessToken(authToken.AccessToken);
await cache.RemoveAsync(GetAuthCacheKey(requestContext.Fingerprint), cancellation);
cookieOptions.DropCookie(context, CookieNames.AccessToken);
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
logger.LogWarning("Token validation failed for user ID {UserId}. Invalid token used from IP {UserIp} with User-Agent {UserAgent} and Fingerprint {Fingerprint}. Possible account compromise. Reason: {Reason}.",
authToken.UserId,
authToken.Ip,
authToken.UserAgent,
authToken.Fingerprint,
authToken.RefreshToken != requestContext.RefreshToken ?
$"Cached refresh token '{authToken.RefreshToken}' does not match the provided refresh token '{requestContext.RefreshToken}'" :
$"User-Agent '{authToken.UserAgent}' and IP '{authToken.Ip}' in cache do not match the provided User-Agent '{requestContext.UserAgent}' and IP '{requestContext.Ip}'");
throw new SecurityException("The session time has expired");
} }
var (token, expireIn) = GenerateAccessToken(authToken.UserId); var (token, expireIn) = GenerateAccessToken(authToken.UserId);
@ -86,24 +180,22 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
authToken.AccessToken = token; authToken.AccessToken = token;
authToken.RefreshToken = newRefreshToken; authToken.RefreshToken = newRefreshToken;
await SetAuthTokenDataToCache(request.Fingerprint, authToken, cancellation); await SetAuthTokenDataToCache(authToken, cancellation);
cookieOptions.SetCookie(context, CookieNames.AccessToken, token, expireIn);
return new AuthTokenResponse cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
{
AccessToken = token,
AccessExpiresIn = expireIn,
RefreshToken = newRefreshToken,
RefreshExpiresIn = DateTime.UtcNow.Add(Lifetime)
};
} }
public async Task LogoutAsync(string fingerprint, CancellationToken cancellation = default) public async Task LogoutAsync(CookieOptionsParameters cookieOptions, HttpContext context, CancellationToken cancellation = default)
{ {
var authTokenStruct = await cache.GetAsync<AuthToken>(GetAuthCacheKey(fingerprint), cancellation); var requestContext = new RequestContextInfo(context, cookieOptions);
if (authTokenStruct == null) return; var authTokenStruct = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation);
if (authTokenStruct == null)
return;
await RevokeAccessToken(authTokenStruct.AccessToken); await RevokeAccessToken(authTokenStruct.AccessToken);
await cache.RemoveAsync(requestContext.Fingerprint, cancellation);
await cache.RemoveAsync(fingerprint, cancellation); cookieOptions.DropCookie(context, CookieNames.AccessToken);
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
} }
} }

View File

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

View File

@ -0,0 +1,20 @@
using OtpNet;
namespace Mirea.Api.Security.Services;
public class TotpService
{
private readonly Totp _totp;
public TotpService(string secret)
{
var secretBytes = Base32Encoding.ToBytes(secret);
_totp = new Totp(secretBytes);
}
public string GenerateToken() =>
_totp.ComputeTotp();
public bool VerifyToken(string token) =>
_totp.VerifyTotp(token, out _, new VerificationWindow(2, 2));
}