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:
parent
0f47a98ad9
commit
cd6f25deba
17
ApiDto/Responses/AuthenticationStep.cs
Normal file
17
ApiDto/Responses/AuthenticationStep.cs
Normal 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,
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using Mirea.Api.Endpoint.Common.Services;
|
||||
using Mirea.Api.Security.Common.Domain;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
@ -16,7 +17,8 @@ public class Admin : ISaveSettings
|
||||
public required string Email { get; set; }
|
||||
public required string PasswordHash { get; set; }
|
||||
public required string Salt { get; set; }
|
||||
|
||||
public SecondFactor SecondFactor { get; set; } = SecondFactor.None;
|
||||
public string? Secret { get; set; }
|
||||
|
||||
public void SaveSetting()
|
||||
{
|
||||
|
@ -2,109 +2,64 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mirea.Api.Dto.Common;
|
||||
using Mirea.Api.Dto.Requests;
|
||||
using Mirea.Api.Dto.Responses;
|
||||
using Mirea.Api.Endpoint.Common.Attributes;
|
||||
using Mirea.Api.Endpoint.Common.Exceptions;
|
||||
using Mirea.Api.Endpoint.Common.Services;
|
||||
using Mirea.Api.Endpoint.Configuration.Core.Middleware;
|
||||
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 System;
|
||||
using System.Security;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Mirea.Api.Endpoint.Controllers.V1;
|
||||
|
||||
[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 string Ip { get; set; } = string.Empty;
|
||||
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
|
||||
private CookieOptionsParameters GetCookieParams() =>
|
||||
new()
|
||||
{
|
||||
Expires = expires,
|
||||
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api",
|
||||
Domain = HttpContext.GetCurrentDomain(),
|
||||
HttpOnly = true,
|
||||
#if !DEBUG
|
||||
Secure = true
|
||||
#endif
|
||||
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api"
|
||||
};
|
||||
|
||||
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")]
|
||||
[BadRequestResponse]
|
||||
public async Task<ActionResult<AuthRoles>> Login([FromBody] LoginRequest request)
|
||||
public async Task<ActionResult<AuthenticationStep>> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
var userEntity = user.Value;
|
||||
|
||||
if (!userEntity.Username.Equals(request.Username, StringComparison.OrdinalIgnoreCase) &&
|
||||
!userEntity.Email.Equals(request.Username, StringComparison.OrdinalIgnoreCase) ||
|
||||
!passwordService.VerifyPassword(request.Password, userEntity.Salt, userEntity.PasswordHash))
|
||||
!userEntity.Email.Equals(request.Username, StringComparison.OrdinalIgnoreCase))
|
||||
return BadRequest("Invalid username/email or password");
|
||||
|
||||
var token = await auth.GenerateAuthTokensAsync(new TokenRequest
|
||||
{
|
||||
Fingerprint = Fingerprint,
|
||||
Ip = Ip,
|
||||
UserAgent = UserAgent
|
||||
}, "1");
|
||||
var tokenResult = await auth.LoginAsync(
|
||||
GetCookieParams(),
|
||||
new User
|
||||
{
|
||||
Id = 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);
|
||||
SetAuthToken(token.AccessToken, token.AccessExpiresIn);
|
||||
return Ok(tokenResult ? AuthenticationStep.None : AuthenticationStep.TotpRequired);
|
||||
}
|
||||
|
||||
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>
|
||||
@ -116,30 +71,8 @@ public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, Pass
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<AuthRoles>> ReLogin()
|
||||
{
|
||||
if (string.IsNullOrEmpty(RefreshToken))
|
||||
return Unauthorized();
|
||||
|
||||
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();
|
||||
}
|
||||
await auth.RefreshTokenAsync(GetCookieParams(), HttpContext);
|
||||
return Ok(AuthRoles.Admin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -151,11 +84,7 @@ public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, Pass
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Logout()
|
||||
{
|
||||
SetRefreshToken("", DateTimeOffset.MinValue);
|
||||
SetFirstToken("", DateTimeOffset.MinValue);
|
||||
SetAuthToken("", DateTimeOffset.MinValue);
|
||||
|
||||
await auth.LogoutAsync(Fingerprint);
|
||||
await auth.LogoutAsync(GetCookieParams(), HttpContext);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
8
Security/Common/CookieNames.cs
Normal file
8
Security/Common/CookieNames.cs
Normal 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";
|
||||
}
|
@ -2,11 +2,25 @@
|
||||
|
||||
namespace Mirea.Api.Security.Common.Domain;
|
||||
|
||||
public class AuthToken
|
||||
internal class AuthToken
|
||||
{
|
||||
public required string RefreshToken { get; set; }
|
||||
public required string UserAgent { get; set; }
|
||||
public required string Ip { get; set; }
|
||||
public AuthToken(RequestContextInfo context)
|
||||
{
|
||||
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 AccessToken { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
29
Security/Common/Domain/CookieOptionsParameters.cs
Normal file
29
Security/Common/Domain/CookieOptionsParameters.cs
Normal 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);
|
||||
}
|
22
Security/Common/Domain/FirstAuthToken.cs
Normal file
22
Security/Common/Domain/FirstAuthToken.cs
Normal 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; }
|
||||
}
|
@ -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; }
|
||||
}
|
38
Security/Common/Domain/RequestContextInfo.cs
Normal file
38
Security/Common/Domain/RequestContextInfo.cs
Normal 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; }
|
||||
}
|
18
Security/Common/Domain/User.cs
Normal file
18
Security/Common/Domain/User.cs
Normal 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; }
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
namespace Mirea.Api.Security.Common.Dto.Requests;
|
||||
|
||||
public class TokenRequest
|
||||
{
|
||||
public required string Fingerprint { get; set; }
|
||||
public required string UserAgent { get; set; }
|
||||
public required string Ip { get; set; }
|
||||
}
|
@ -1,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; }
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Mirea.Api.Security.Common.Dto.Responses;
|
||||
|
||||
public class PreAuthTokenResponse
|
||||
{
|
||||
public required string Token { get; set; }
|
||||
public DateTime ExpiresIn { get; set; }
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Mirea.Api.Security.Common.Interfaces;
|
||||
using Mirea.Api.Security.Services;
|
||||
using System;
|
||||
@ -26,29 +27,21 @@ public static class DependencyInjection
|
||||
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 lifeTimeFirstAuthToken = TimeSpan.FromMinutes(int.Parse(configuration["SECURITY_LIFE_TIME_1_FA"]!));
|
||||
|
||||
services.AddSingleton(provider =>
|
||||
{
|
||||
var cacheService = provider.GetRequiredService<ICacheService>();
|
||||
var accessTokenService = provider.GetRequiredService<IAccessToken>();
|
||||
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
|
||||
};
|
||||
});
|
||||
|
||||
|
12
Security/Properties/launchSettings.json
Normal file
12
Security/Properties/launchSettings.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Security": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:58871;http://localhost:58872"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,21 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Company>Winsomnia</Company>
|
||||
<Version>1.0.0</Version>
|
||||
<AssemblyVersion>1.0.3.0</AssemblyVersion>
|
||||
<FileVersion>1.0.3.0</FileVersion>
|
||||
<Version>1.1.0</Version>
|
||||
<AssemblyVersion>1.1.3.0</AssemblyVersion>
|
||||
<FileVersion>1.1.3.0</FileVersion>
|
||||
<AssemblyName>Mirea.Api.Security</AssemblyName>
|
||||
<RootNamespace>$(AssemblyName)</RootNamespace>
|
||||
<OutputType>Library</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
|
||||
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -1,6 +1,7 @@
|
||||
using Mirea.Api.Security.Common.Domain;
|
||||
using Mirea.Api.Security.Common.Dto.Requests;
|
||||
using Mirea.Api.Security.Common.Dto.Responses;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Mirea.Api.Security.Common;
|
||||
using Mirea.Api.Security.Common.Domain;
|
||||
using Mirea.Api.Security.Common.Interfaces;
|
||||
using System;
|
||||
using System.Security;
|
||||
@ -10,9 +11,10 @@ using System.Threading.Tasks;
|
||||
|
||||
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 LifetimeFirstAuth { private get; init; }
|
||||
|
||||
private static string GenerateRefreshToken() => Guid.NewGuid().ToString().Replace("-", "") +
|
||||
GeneratorKey.GenerateString(32);
|
||||
@ -20,10 +22,11 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
||||
accessTokenService.GenerateToken(userId);
|
||||
|
||||
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(
|
||||
GetAuthCacheKey(fingerprint),
|
||||
GetAuthCacheKey(data.Fingerprint),
|
||||
JsonSerializer.SerializeToUtf8Bytes(data),
|
||||
slidingExpiration: Lifetime,
|
||||
cancellationToken: cancellation);
|
||||
@ -31,51 +34,141 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
||||
private Task RevokeAccessToken(string 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 (token, expireIn) = GenerateAccessToken(userId);
|
||||
|
||||
var authTokenStruct = new AuthToken
|
||||
var authToken = new AuthToken(requestContext)
|
||||
{
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Ip = request.Ip,
|
||||
RefreshToken = refreshToken,
|
||||
UserAgent = request.UserAgent,
|
||||
UserId = userId,
|
||||
AccessToken = token
|
||||
};
|
||||
|
||||
await SetAuthTokenDataToCache(request.Fingerprint, authTokenStruct, cancellation);
|
||||
|
||||
return new AuthTokenResponse
|
||||
{
|
||||
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,
|
||||
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)
|
||||
public async Task<bool> LoginAsync(CookieOptionsParameters cookieOptions, HttpContext context, string code, CancellationToken cancellation = default)
|
||||
{
|
||||
var authToken = await cache.GetAsync<AuthToken>(GetAuthCacheKey(request.Fingerprint), cancellation)
|
||||
?? throw new SecurityException(request.Fingerprint);
|
||||
var requestContext = new RequestContextInfo(context, cookieOptions);
|
||||
|
||||
if (authToken.RefreshToken != refreshToken ||
|
||||
authToken.UserAgent != request.UserAgent &&
|
||||
authToken.Ip != request.Ip)
|
||||
var firstTokenAuth = await cache.GetAsync<FirstAuthToken?>(GetFirstAuthCacheKey(requestContext.Fingerprint), cancellationToken: cancellation)
|
||||
?? throw new SecurityException("The session time has expired");
|
||||
|
||||
switch (firstTokenAuth.SecondFactor)
|
||||
{
|
||||
await cache.RemoveAsync(GetAuthCacheKey(request.Fingerprint), cancellation);
|
||||
await RevokeAccessToken(authToken.AccessToken);
|
||||
case SecondFactor.Totp:
|
||||
{
|
||||
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);
|
||||
@ -86,24 +179,22 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
||||
authToken.AccessToken = token;
|
||||
authToken.RefreshToken = newRefreshToken;
|
||||
|
||||
await SetAuthTokenDataToCache(request.Fingerprint, authToken, cancellation);
|
||||
|
||||
return new AuthTokenResponse
|
||||
{
|
||||
AccessToken = token,
|
||||
AccessExpiresIn = expireIn,
|
||||
RefreshToken = newRefreshToken,
|
||||
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));
|
||||
}
|
||||
|
||||
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);
|
||||
if (authTokenStruct == null) return;
|
||||
var requestContext = new RequestContextInfo(context, cookieOptions);
|
||||
var authTokenStruct = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation);
|
||||
|
||||
if (authTokenStruct == null)
|
||||
return;
|
||||
|
||||
await RevokeAccessToken(authTokenStruct.AccessToken);
|
||||
|
||||
await cache.RemoveAsync(fingerprint, cancellation);
|
||||
await cache.RemoveAsync(requestContext.Fingerprint, cancellation);
|
||||
cookieOptions.DropCookie(context, CookieNames.AccessToken);
|
||||
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
20
Security/Services/TotpService.cs
Normal file
20
Security/Services/TotpService.cs
Normal 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));
|
||||
}
|
Loading…
Reference in New Issue
Block a user