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
|
||||
};
|
||||
|
||||
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);
|
||||
await cache.SetAsync(key, serializedValue, options, cancellationToken);
|
||||
}
|
||||
|
||||
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);
|
||||
return cachedValue == null ? default : JsonSerializer.Deserialize<T>(cachedValue);
|
||||
}
|
||||
|
@ -17,17 +17,41 @@ public class MemoryCacheService(IMemoryCache cache) : ICacheService
|
||||
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);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
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(
|
||||
cache.TryGetValue(key, out byte[]? value) ?
|
||||
JsonSerializer.Deserialize<T>(value) :
|
||||
default
|
||||
cache.TryGetValue(key, out byte[]? value) ? JsonSerializer.Deserialize<T>(value) : default
|
||||
);
|
||||
|
||||
var primitiveValue = cache.Get(key);
|
||||
|
||||
if (string.IsNullOrEmpty(primitiveValue?.ToString()))
|
||||
return Task.FromResult<T?>(default);
|
||||
|
||||
if (type == typeof(string))
|
||||
return Task.FromResult((T?)primitiveValue);
|
||||
|
||||
var tryParseMethod = type.GetMethod("TryParse", [typeof(string), type.MakeByRefType()])
|
||||
?? throw new NotSupportedException($"Type {type.Name} does not support TryParse.");
|
||||
|
||||
var parameters = new[] { primitiveValue, Activator.CreateInstance(type) };
|
||||
var success = (bool)tryParseMethod.Invoke(null, parameters)!;
|
||||
|
||||
return success ? Task.FromResult((T?)parameters[1]) : Task.FromResult<T?>(default);
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
|
||||
|
@ -5,6 +5,7 @@ using Mirea.Api.DataAccess.Application.Common.Exceptions;
|
||||
using Mirea.Api.Dto.Responses;
|
||||
using Mirea.Api.Endpoint.Common.Exceptions;
|
||||
using System;
|
||||
using System.Security;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@ -44,6 +45,9 @@ public class CustomExceptionHandlerMiddleware(RequestDelegate next, ILogger<Cust
|
||||
case ControllerArgumentException:
|
||||
code = StatusCodes.Status400BadRequest;
|
||||
break;
|
||||
case SecurityException:
|
||||
code = StatusCodes.Status401Unauthorized;
|
||||
break;
|
||||
}
|
||||
|
||||
context.Response.ContentType = "application/json";
|
||||
|
@ -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)
|
||||
private CookieOptionsParameters GetCookieParams() =>
|
||||
new()
|
||||
{
|
||||
var cookieOptions = new CookieOptions
|
||||
{
|
||||
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
|
||||
var tokenResult = await auth.LoginAsync(
|
||||
GetCookieParams(),
|
||||
new User
|
||||
{
|
||||
Fingerprint = Fingerprint,
|
||||
Ip = Ip,
|
||||
UserAgent = UserAgent
|
||||
}, "1");
|
||||
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,31 +71,9 @@ 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);
|
||||
|
||||
await auth.RefreshTokenAsync(GetCookieParams(), HttpContext);
|
||||
return Ok(AuthRoles.Admin);
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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]
|
||||
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();
|
||||
}
|
||||
|
@ -79,6 +79,8 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
|
||||
|
||||
var pairsDictionary = config.Value.ScheduleSettings!.PairPeriod;
|
||||
|
||||
var ruCulture = new CultureInfo("ru-RU");
|
||||
|
||||
foreach (var dto in result.GroupBy(s => new
|
||||
{
|
||||
s.DayOfWeek,
|
||||
@ -109,7 +111,7 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
|
||||
{
|
||||
// День
|
||||
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 + " п";
|
||||
@ -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 = 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;
|
||||
|
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,142 @@ 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);
|
||||
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
|
||||
{
|
||||
AccessToken = token,
|
||||
AccessExpiresIn = expireIn,
|
||||
RefreshToken = authTokenStruct.RefreshToken,
|
||||
RefreshExpiresIn = 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)
|
||||
{
|
||||
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 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);
|
||||
@ -86,24 +180,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