Compare commits
5 Commits
5317b7b563
...
a0ff624481
Author | SHA1 | Date | |
---|---|---|---|
a0ff624481 | |||
cd6f25deba | |||
0f47a98ad9 | |||
3279ef594b | |||
5bc729eb66 |
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,
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
{
|
{
|
||||||
|
var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
|
||||||
|
|
||||||
|
if (!type.IsPrimitive && type != typeof(string) && type != typeof(DateTime))
|
||||||
return Task.FromResult(
|
return Task.FromResult(
|
||||||
cache.TryGetValue(key, out byte[]? value) ?
|
cache.TryGetValue(key, out byte[]? value) ? JsonSerializer.Deserialize<T>(value) : default
|
||||||
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)
|
||||||
|
@ -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";
|
||||||
|
@ -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()
|
||||||
{
|
{
|
||||||
|
@ -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(),
|
||||||
|
new User
|
||||||
{
|
{
|
||||||
Fingerprint = Fingerprint,
|
Id = 1,
|
||||||
Ip = Ip,
|
Username = userEntity.Username,
|
||||||
UserAgent = UserAgent
|
Email = userEntity.Email,
|
||||||
}, "1");
|
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,31 +71,9 @@ 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();
|
|
||||||
|
|
||||||
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);
|
return Ok(AuthRoles.Admin);
|
||||||
}
|
}
|
||||||
catch (SecurityException)
|
|
||||||
{
|
|
||||||
return Forbid();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Logs the user out by clearing the refresh token and performing any necessary cleanup.
|
/// Logs the user out by clearing the refresh token and performing any necessary cleanup.
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
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;
|
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; }
|
||||||
|
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.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
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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>
|
<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>
|
||||||
|
@ -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)
|
||||||
|
{
|
||||||
|
case SecondFactor.Totp:
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(firstTokenAuth.Secret))
|
||||||
|
throw new InvalidOperationException("The user's secrets for data processing were not transferred.");
|
||||||
|
|
||||||
|
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 cache.RemoveAsync(GetAuthCacheKey(request.Fingerprint), cancellation);
|
|
||||||
await RevokeAccessToken(authToken.AccessToken);
|
await RevokeAccessToken(authToken.AccessToken);
|
||||||
|
await cache.RemoveAsync(GetAuthCacheKey(requestContext.Fingerprint), cancellation);
|
||||||
|
cookieOptions.DropCookie(context, CookieNames.AccessToken);
|
||||||
|
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
|
||||||
|
|
||||||
throw new SecurityException(request.Fingerprint);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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