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
This commit is contained in:
Polianin Nikita 2024-10-31 04:12:02 +03:00
parent 0f47a98ad9
commit cd6f25deba
19 changed files with 371 additions and 278 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

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

@ -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,141 @@ 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,
UserAgent = request.UserAgent,
UserId = userId, UserId = userId,
AccessToken = token
};
await SetAuthTokenDataToCache(request.Fingerprint, authTokenStruct, cancellation);
return new AuthTokenResponse
{
AccessToken = token, AccessToken = token,
AccessExpiresIn = expireIn,
RefreshToken = authTokenStruct.RefreshToken,
RefreshExpiresIn = DateTime.UtcNow.Add(Lifetime),
}; };
await SetAuthTokenDataToCache(authToken, cancellation);
cookieOptions.SetCookie(context, CookieNames.AccessToken, token, expireIn);
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
logger.LogInformation(
"Successful login attempt for user ID {UserId} from IP {UserIp} with User-Agent {UserAgent} and Fingerprint {Fingerprint}.",
authToken.UserId,
authToken.Ip,
authToken.UserAgent,
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 +179,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));
}