2024-10-31 04:12:02 +03:00
|
|
|
|
using Microsoft.AspNetCore.Http;
|
|
|
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
|
using Mirea.Api.Security.Common;
|
|
|
|
|
using Mirea.Api.Security.Common.Domain;
|
2024-11-02 23:34:23 +03:00
|
|
|
|
using Mirea.Api.Security.Common.Domain.Caching;
|
2024-05-29 05:50:47 +03:00
|
|
|
|
using Mirea.Api.Security.Common.Interfaces;
|
|
|
|
|
using System;
|
2024-05-29 05:51:32 +03:00
|
|
|
|
using System.Security;
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
using System.Threading;
|
|
|
|
|
using System.Threading.Tasks;
|
2024-05-29 05:32:22 +03:00
|
|
|
|
|
|
|
|
|
namespace Mirea.Api.Security.Services;
|
|
|
|
|
|
2024-10-31 04:12:02 +03:00
|
|
|
|
public class AuthService(ICacheService cache, IAccessToken accessTokenService, IRevokedToken revokedToken, ILogger<AuthService> logger, PasswordHashService passwordService)
|
2024-05-29 05:32:22 +03:00
|
|
|
|
{
|
|
|
|
|
public TimeSpan Lifetime { private get; init; }
|
2024-10-31 04:12:02 +03:00
|
|
|
|
public TimeSpan LifetimeFirstAuth { private get; init; }
|
2024-05-29 05:35:44 +03:00
|
|
|
|
|
|
|
|
|
private static string GenerateRefreshToken() => Guid.NewGuid().ToString().Replace("-", "") +
|
|
|
|
|
GeneratorKey.GenerateString(32);
|
2024-05-29 05:51:03 +03:00
|
|
|
|
private (string Token, DateTime ExpireIn) GenerateAccessToken(string userId) =>
|
|
|
|
|
accessTokenService.GenerateToken(userId);
|
2024-05-29 05:36:26 +03:00
|
|
|
|
|
|
|
|
|
private static string GetAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token";
|
2024-11-04 02:39:10 +03:00
|
|
|
|
private static string GetFirstAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token_first";
|
2024-12-25 05:46:27 +03:00
|
|
|
|
private static string GetAttemptFailedCountKey(string fingerprint) => $"{fingerprint}_login_failed";
|
2024-05-29 05:50:47 +03:00
|
|
|
|
|
2024-12-25 05:47:51 +03:00
|
|
|
|
private Task StoreAuthTokenInCache(AuthToken data, CancellationToken cancellation) =>
|
2024-05-29 05:50:47 +03:00
|
|
|
|
cache.SetAsync(
|
2024-10-31 04:12:02 +03:00
|
|
|
|
GetAuthCacheKey(data.Fingerprint),
|
2024-05-29 05:50:47 +03:00
|
|
|
|
JsonSerializer.SerializeToUtf8Bytes(data),
|
|
|
|
|
slidingExpiration: Lifetime,
|
|
|
|
|
cancellationToken: cancellation);
|
2024-05-29 05:51:32 +03:00
|
|
|
|
|
2024-12-25 05:47:51 +03:00
|
|
|
|
private Task StoreFirstAuthTokenInCache(User data, RequestContextInfo requestContext, CancellationToken cancellation) =>
|
2024-11-04 02:39:10 +03:00
|
|
|
|
cache.SetAsync(
|
|
|
|
|
GetFirstAuthCacheKey(requestContext.Fingerprint),
|
|
|
|
|
JsonSerializer.SerializeToUtf8Bytes(new FirstAuthToken(requestContext)
|
|
|
|
|
{
|
|
|
|
|
UserId = data.Id,
|
|
|
|
|
Secret = data.SecondFactorToken,
|
|
|
|
|
TwoFactorAuthenticator = data.TwoFactorAuthenticator
|
|
|
|
|
}),
|
|
|
|
|
slidingExpiration: LifetimeFirstAuth,
|
|
|
|
|
cancellationToken: cancellation);
|
|
|
|
|
|
2024-05-29 05:55:31 +03:00
|
|
|
|
private Task RevokeAccessToken(string token) =>
|
|
|
|
|
revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token));
|
|
|
|
|
|
2024-12-25 05:46:27 +03:00
|
|
|
|
private async Task RecordFailedLoginAttempt(string fingerprint, string userId, CancellationToken cancellation)
|
2024-10-31 04:12:02 +03:00
|
|
|
|
{
|
2024-12-25 05:46:27 +03:00
|
|
|
|
var failedLoginAttemptsCount = await cache.GetAsync<int?>(GetAttemptFailedCountKey(fingerprint), cancellation) ?? 1;
|
|
|
|
|
var failedLoginCacheExpiration = TimeSpan.FromHours(1);
|
2024-10-31 04:12:02 +03:00
|
|
|
|
|
2024-12-25 05:46:27 +03:00
|
|
|
|
if (failedLoginAttemptsCount > 5)
|
2024-10-31 04:12:02 +03:00
|
|
|
|
{
|
|
|
|
|
logger.LogWarning(
|
2024-12-25 05:46:27 +03:00
|
|
|
|
"Multiple unsuccessful login attempts for user ID {UserId}. Fingerprint: {Fingerprint}. Attempt count: {AttemptNumber}.",
|
|
|
|
|
userId,
|
|
|
|
|
fingerprint,
|
|
|
|
|
failedLoginAttemptsCount);
|
2024-10-31 04:12:02 +03:00
|
|
|
|
|
2024-11-02 22:10:46 +03:00
|
|
|
|
throw new SecurityException("Too many unsuccessful login attempts. Please try again later.");
|
2024-10-31 04:12:02 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.LogInformation(
|
2024-12-23 07:48:28 +03:00
|
|
|
|
"Login attempt failed for user ID {UserId}. Fingerprint: {Fingerprint}. Attempt count: {AttemptNumber}.",
|
2024-12-25 05:46:27 +03:00
|
|
|
|
userId,
|
|
|
|
|
fingerprint,
|
|
|
|
|
failedLoginAttemptsCount);
|
|
|
|
|
|
|
|
|
|
await cache.SetAsync(GetAttemptFailedCountKey(fingerprint), failedLoginAttemptsCount + 1,
|
|
|
|
|
slidingExpiration: failedLoginCacheExpiration, cancellationToken: cancellation);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Task ResetFailedLoginAttempts(string fingerprint, CancellationToken cancellation) =>
|
|
|
|
|
cache.RemoveAsync(GetAttemptFailedCountKey(fingerprint), cancellation);
|
|
|
|
|
|
|
|
|
|
private async Task VerifyUserOrThrowError(RequestContextInfo requestContext, User user, string password, string username,
|
|
|
|
|
CancellationToken cancellation = default)
|
|
|
|
|
{
|
|
|
|
|
if ((user.Email.Equals(username, StringComparison.OrdinalIgnoreCase) ||
|
|
|
|
|
user.Username.Equals(username, StringComparison.OrdinalIgnoreCase)) &&
|
|
|
|
|
passwordService.VerifyPassword(password, user.Salt, user.PasswordHash))
|
|
|
|
|
{
|
|
|
|
|
await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellation);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await RecordFailedLoginAttempt(requestContext.Fingerprint, user.Id, cancellation);
|
2024-10-31 04:12:02 +03:00
|
|
|
|
|
2024-11-02 22:10:46 +03:00
|
|
|
|
throw new SecurityException("Authentication failed. Please check your credentials.");
|
2024-10-31 04:12:02 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task GenerateAuthTokensAsync(CookieOptionsParameters cookieOptions, HttpContext context, RequestContextInfo requestContext, string userId, CancellationToken cancellation = default)
|
2024-05-29 05:51:32 +03:00
|
|
|
|
{
|
|
|
|
|
var refreshToken = GenerateRefreshToken();
|
2024-06-21 21:36:11 +03:00
|
|
|
|
var (token, expireIn) = GenerateAccessToken(userId);
|
2024-05-29 05:51:32 +03:00
|
|
|
|
|
2024-10-31 04:12:02 +03:00
|
|
|
|
var authToken = new AuthToken(requestContext)
|
2024-05-29 05:51:32 +03:00
|
|
|
|
{
|
|
|
|
|
CreatedAt = DateTime.UtcNow,
|
2024-10-31 04:12:22 +03:00
|
|
|
|
RefreshToken = refreshToken,
|
2024-05-29 05:51:32 +03:00
|
|
|
|
UserId = userId,
|
2024-10-31 04:12:22 +03:00
|
|
|
|
AccessToken = token
|
2024-05-29 05:51:32 +03:00
|
|
|
|
};
|
|
|
|
|
|
2024-12-25 05:47:51 +03:00
|
|
|
|
await StoreAuthTokenInCache(authToken, cancellation);
|
2024-11-02 00:51:27 +03:00
|
|
|
|
cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
|
2024-10-31 04:12:02 +03:00
|
|
|
|
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
|
|
|
|
|
|
|
|
|
|
logger.LogInformation(
|
2024-12-23 07:48:28 +03:00
|
|
|
|
"Login successful for user ID {UserId}. Fingerprint: {Fingerprint}.",
|
2024-10-31 04:12:02 +03:00
|
|
|
|
authToken.UserId,
|
|
|
|
|
authToken.Fingerprint);
|
|
|
|
|
}
|
|
|
|
|
|
2024-12-22 07:25:41 +03:00
|
|
|
|
public async Task<TwoFactorAuthenticator> LoginOAuthAsync(CookieOptionsParameters cookieOptions, HttpContext context, User user, CancellationToken cancellation = default)
|
2024-11-04 02:39:10 +03:00
|
|
|
|
{
|
|
|
|
|
var requestContext = new RequestContextInfo(context, cookieOptions);
|
|
|
|
|
|
|
|
|
|
if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None)
|
|
|
|
|
{
|
|
|
|
|
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id, cancellation);
|
|
|
|
|
return TwoFactorAuthenticator.None;
|
|
|
|
|
}
|
|
|
|
|
|
2024-12-25 05:47:51 +03:00
|
|
|
|
await StoreFirstAuthTokenInCache(user, requestContext, cancellation);
|
2024-11-04 02:39:10 +03:00
|
|
|
|
|
|
|
|
|
return user.TwoFactorAuthenticator;
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-02 01:09:15 +03:00
|
|
|
|
public async Task<bool> LoginAsync(CookieOptionsParameters cookieOptions, HttpContext context, TwoFactorAuthenticator authenticator, string code, CancellationToken cancellation = default)
|
2024-10-31 04:12:02 +03:00
|
|
|
|
{
|
|
|
|
|
var requestContext = new RequestContextInfo(context, cookieOptions);
|
|
|
|
|
|
2024-11-02 01:09:15 +03:00
|
|
|
|
var firstTokenAuth = await cache.GetAsync<FirstAuthToken?>(GetFirstAuthCacheKey(requestContext.Fingerprint), cancellationToken: cancellation);
|
|
|
|
|
|
|
|
|
|
if (firstTokenAuth == null || authenticator != firstTokenAuth.TwoFactorAuthenticator)
|
2024-11-02 22:10:46 +03:00
|
|
|
|
throw new SecurityException("Session expired. Please log in again.");
|
2024-05-29 05:51:32 +03:00
|
|
|
|
|
2024-11-02 00:51:27 +03:00
|
|
|
|
switch (firstTokenAuth.TwoFactorAuthenticator)
|
2024-05-29 05:51:32 +03:00
|
|
|
|
{
|
2024-11-02 00:51:27 +03:00
|
|
|
|
case TwoFactorAuthenticator.Totp:
|
2024-10-31 04:12:02 +03:00
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrEmpty(firstTokenAuth.Secret))
|
2024-12-25 05:48:22 +03:00
|
|
|
|
{
|
|
|
|
|
logger.LogWarning("The user {Fingerprint} for {UserId} tried to pass the 2FA even though the secret is empty",
|
|
|
|
|
requestContext.Fingerprint,
|
|
|
|
|
firstTokenAuth.UserId);
|
|
|
|
|
|
2024-11-02 22:10:46 +03:00
|
|
|
|
throw new InvalidOperationException("Required authentication data is missing.");
|
2024-12-25 05:48:22 +03:00
|
|
|
|
}
|
2024-10-31 04:12:02 +03:00
|
|
|
|
|
|
|
|
|
var totp = new TotpService(firstTokenAuth.Secret);
|
|
|
|
|
|
|
|
|
|
if (!totp.VerifyToken(code))
|
2024-12-25 05:49:13 +03:00
|
|
|
|
{
|
|
|
|
|
await RecordFailedLoginAttempt(requestContext.Fingerprint, firstTokenAuth.UserId, cancellation);
|
2024-11-02 22:10:46 +03:00
|
|
|
|
throw new SecurityException("Invalid verification code. Please try again.");
|
2024-10-31 04:12:02 +03:00
|
|
|
|
}
|
2024-12-25 05:49:13 +03:00
|
|
|
|
|
|
|
|
|
await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellation);
|
|
|
|
|
}
|
2024-10-31 04:12:02 +03:00
|
|
|
|
break;
|
|
|
|
|
default:
|
2024-11-02 22:10:46 +03:00
|
|
|
|
throw new InvalidOperationException("Unsupported authorization method.");
|
2024-10-31 04:12:02 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, firstTokenAuth.UserId, cancellation);
|
|
|
|
|
return true;
|
2024-05-29 05:51:32 +03:00
|
|
|
|
}
|
2024-05-29 05:55:57 +03:00
|
|
|
|
|
2024-12-22 05:13:46 +03:00
|
|
|
|
public async Task<TwoFactorAuthenticator> LoginAsync(CookieOptionsParameters cookieOptions, User user, HttpContext context, string password, string username, CancellationToken cancellation = default)
|
2024-10-31 04:12:02 +03:00
|
|
|
|
{
|
|
|
|
|
var requestContext = new RequestContextInfo(context, cookieOptions);
|
2024-12-25 05:53:59 +03:00
|
|
|
|
username = username.Trim();
|
2024-12-22 05:13:46 +03:00
|
|
|
|
await VerifyUserOrThrowError(requestContext, user, password, username, cancellation);
|
2024-10-31 04:12:02 +03:00
|
|
|
|
|
2024-11-02 00:51:27 +03:00
|
|
|
|
if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None)
|
2024-10-31 04:12:02 +03:00
|
|
|
|
{
|
2024-11-02 20:21:46 +03:00
|
|
|
|
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id, cancellation);
|
2024-11-02 01:06:58 +03:00
|
|
|
|
return TwoFactorAuthenticator.None;
|
2024-10-31 04:12:02 +03:00
|
|
|
|
}
|
|
|
|
|
|
2024-12-25 05:47:51 +03:00
|
|
|
|
await StoreFirstAuthTokenInCache(user, requestContext, cancellation);
|
2024-05-29 06:00:15 +03:00
|
|
|
|
|
2024-11-02 01:06:58 +03:00
|
|
|
|
return user.TwoFactorAuthenticator;
|
2024-10-31 04:12:02 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task RefreshTokenAsync(CookieOptionsParameters cookieOptions, HttpContext context, CancellationToken cancellation = default)
|
2024-05-29 05:55:57 +03:00
|
|
|
|
{
|
2024-12-25 05:51:54 +03:00
|
|
|
|
const string defaultMessageError = "The session time has expired";
|
2024-10-31 04:12:02 +03:00
|
|
|
|
var requestContext = new RequestContextInfo(context, cookieOptions);
|
2024-12-25 05:51:54 +03:00
|
|
|
|
var authToken = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation) ??
|
|
|
|
|
throw new SecurityException(defaultMessageError);
|
2024-05-29 05:55:57 +03:00
|
|
|
|
|
2024-10-31 04:12:02 +03:00
|
|
|
|
if (authToken.RefreshToken != requestContext.RefreshToken ||
|
|
|
|
|
authToken.UserAgent != requestContext.UserAgent &&
|
|
|
|
|
authToken.Ip != requestContext.Ip)
|
2024-05-29 05:55:57 +03:00
|
|
|
|
{
|
|
|
|
|
await RevokeAccessToken(authToken.AccessToken);
|
2024-10-31 04:12:02 +03:00
|
|
|
|
await cache.RemoveAsync(GetAuthCacheKey(requestContext.Fingerprint), cancellation);
|
|
|
|
|
cookieOptions.DropCookie(context, CookieNames.AccessToken);
|
|
|
|
|
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
|
|
|
|
|
|
2024-12-25 05:51:54 +03:00
|
|
|
|
const string error = "Token validation failed for user ID {UserId}. Fingerprint: {Fingerprint}. ";
|
|
|
|
|
if (authToken.RefreshToken != requestContext.RefreshToken)
|
|
|
|
|
logger.LogWarning(
|
|
|
|
|
error +
|
|
|
|
|
"Cached refresh token {ExpectedRefreshToken} does not match the provided refresh token {RefreshToken}",
|
2024-10-31 04:12:02 +03:00
|
|
|
|
authToken.UserId,
|
|
|
|
|
authToken.Fingerprint,
|
2024-12-25 05:51:54 +03:00
|
|
|
|
authToken.RefreshToken,
|
|
|
|
|
requestContext.RefreshToken);
|
|
|
|
|
else
|
|
|
|
|
logger.LogWarning(
|
|
|
|
|
error +
|
|
|
|
|
"User-Agent {ExpectedUserAgent} and IP {ExpectedUserIp} in cache do not match the provided " +
|
|
|
|
|
"User-Agent {ProvidedUserAgent} and IP {ProvidedIp}",
|
|
|
|
|
authToken.UserId,
|
|
|
|
|
authToken.Fingerprint,
|
|
|
|
|
authToken.UserAgent,
|
|
|
|
|
authToken.Ip,
|
|
|
|
|
requestContext.UserAgent,
|
|
|
|
|
requestContext.Ip);
|
|
|
|
|
|
|
|
|
|
throw new SecurityException(defaultMessageError);
|
2024-12-25 05:52:39 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (authToken.UserAgent != requestContext.UserAgent)
|
|
|
|
|
{
|
|
|
|
|
logger.LogInformation("The resulting User-Agent {ProvidedUserAgent} does not match the cached " +
|
|
|
|
|
"{ExpectedUserAgent} of the user {UserId} with the fingerprint {Fingerprint}.",
|
|
|
|
|
requestContext.UserAgent,
|
|
|
|
|
authToken.UserAgent,
|
|
|
|
|
authToken.UserId,
|
|
|
|
|
requestContext.Fingerprint);
|
|
|
|
|
|
|
|
|
|
authToken.UserAgent = requestContext.UserAgent;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (authToken.Ip != requestContext.Ip)
|
|
|
|
|
{
|
|
|
|
|
logger.LogInformation("The resulting Ip {ProvidedIp} does not match the cached " +
|
|
|
|
|
"{ExpectedIp} of the user {UserId} with the fingerprint {Fingerprint}.",
|
|
|
|
|
requestContext.Ip,
|
|
|
|
|
authToken.Ip,
|
|
|
|
|
authToken.UserId,
|
|
|
|
|
requestContext.Fingerprint);
|
2024-10-31 04:12:02 +03:00
|
|
|
|
|
2024-12-25 05:52:39 +03:00
|
|
|
|
authToken.Ip = requestContext.Ip;
|
2024-05-29 05:55:57 +03:00
|
|
|
|
}
|
|
|
|
|
|
2024-06-21 21:36:11 +03:00
|
|
|
|
var (token, expireIn) = GenerateAccessToken(authToken.UserId);
|
2024-05-29 05:55:57 +03:00
|
|
|
|
await RevokeAccessToken(authToken.AccessToken);
|
|
|
|
|
|
2024-06-28 22:52:05 +03:00
|
|
|
|
var newRefreshToken = GenerateRefreshToken();
|
|
|
|
|
|
2024-06-21 21:36:11 +03:00
|
|
|
|
authToken.AccessToken = token;
|
2024-06-28 22:52:05 +03:00
|
|
|
|
authToken.RefreshToken = newRefreshToken;
|
|
|
|
|
|
2024-12-25 05:47:51 +03:00
|
|
|
|
await StoreAuthTokenInCache(authToken, cancellation);
|
2024-11-02 00:51:27 +03:00
|
|
|
|
cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
|
2024-10-31 04:12:02 +03:00
|
|
|
|
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
|
2024-05-29 05:55:57 +03:00
|
|
|
|
}
|
2024-05-29 05:56:27 +03:00
|
|
|
|
|
2024-10-31 04:12:02 +03:00
|
|
|
|
public async Task LogoutAsync(CookieOptionsParameters cookieOptions, HttpContext context, CancellationToken cancellation = default)
|
2024-05-29 05:56:27 +03:00
|
|
|
|
{
|
2024-10-31 04:12:02 +03:00
|
|
|
|
var requestContext = new RequestContextInfo(context, cookieOptions);
|
2024-11-04 02:32:13 +03:00
|
|
|
|
|
|
|
|
|
cookieOptions.DropCookie(context, CookieNames.AccessToken);
|
|
|
|
|
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
|
|
|
|
|
|
2024-10-31 04:12:02 +03:00
|
|
|
|
var authTokenStruct = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation);
|
2024-05-29 05:56:27 +03:00
|
|
|
|
|
2024-10-31 04:12:02 +03:00
|
|
|
|
if (authTokenStruct == null)
|
|
|
|
|
return;
|
2024-05-29 05:56:27 +03:00
|
|
|
|
|
2024-10-31 04:12:02 +03:00
|
|
|
|
await RevokeAccessToken(authTokenStruct.AccessToken);
|
|
|
|
|
await cache.RemoveAsync(requestContext.Fingerprint, cancellation);
|
2024-05-29 05:56:27 +03:00
|
|
|
|
}
|
2024-05-29 05:32:22 +03:00
|
|
|
|
}
|