MireaBackend/Security/Services/AuthService.cs

245 lines
11 KiB
C#
Raw Normal View History

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;
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; }
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";
private static string GetAttemptFailedCountKey(string fingerprint) => $"{fingerprint}_login_failed";
2024-05-29 05:50:47 +03:00
private Task StoreAuthTokenInCache(AuthToken data, CancellationToken cancellation) =>
2024-05-29 05:50:47 +03:00
cache.SetAsync(
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
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));
private async Task RecordFailedLoginAttempt(string fingerprint, string userId, CancellationToken cancellation)
{
var failedLoginAttemptsCount = await cache.GetAsync<int?>(GetAttemptFailedCountKey(fingerprint), cancellation) ?? 1;
var failedLoginCacheExpiration = TimeSpan.FromHours(1);
if (failedLoginAttemptsCount > 5)
{
logger.LogWarning(
"Multiple unsuccessful login attempts for user ID {UserId}. Fingerprint: {Fingerprint}. Attempt count: {AttemptNumber}.",
userId,
fingerprint,
failedLoginAttemptsCount);
2024-11-02 22:10:46 +03:00
throw new SecurityException("Too many unsuccessful login attempts. Please try again later.");
}
logger.LogInformation(
2024-12-23 07:48:28 +03:00
"Login attempt failed for user ID {UserId}. Fingerprint: {Fingerprint}. Attempt count: {AttemptNumber}.",
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-11-02 22:10:46 +03:00
throw new SecurityException("Authentication failed. Please check your credentials.");
}
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
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
};
await StoreAuthTokenInCache(authToken, cancellation);
cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
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}.",
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;
}
await StoreFirstAuthTokenInCache(user, requestContext, cancellation);
2024-11-04 02:39:10 +03:00
return user.TwoFactorAuthenticator;
}
public async Task<bool> LoginAsync(CookieOptionsParameters cookieOptions, HttpContext context, TwoFactorAuthenticator authenticator, string code, CancellationToken cancellation = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
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
switch (firstTokenAuth.TwoFactorAuthenticator)
2024-05-29 05:51:32 +03:00
{
case TwoFactorAuthenticator.Totp:
{
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
}
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-12-25 05:49:13 +03:00
await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellation);
}
break;
default:
2024-11-02 22:10:46 +03:00
throw new InvalidOperationException("Unsupported authorization method.");
}
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
public async Task<TwoFactorAuthenticator> LoginAsync(CookieOptionsParameters cookieOptions, User user, HttpContext context, string password, string username, CancellationToken cancellation = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
await VerifyUserOrThrowError(requestContext, user, password, username, cancellation);
if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None)
{
2024-11-02 20:21:46 +03:00
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id, cancellation);
return TwoFactorAuthenticator.None;
}
await StoreFirstAuthTokenInCache(user, requestContext, cancellation);
return user.TwoFactorAuthenticator;
}
public async Task RefreshTokenAsync(CookieOptionsParameters cookieOptions, HttpContext context, CancellationToken cancellation = default)
2024-05-29 05:55:57 +03:00
{
var requestContext = new RequestContextInfo(context, cookieOptions);
var authToken = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation)
?? throw new SecurityException("The session time has expired");
2024-05-29 05:55:57 +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);
await cache.RemoveAsync(GetAuthCacheKey(requestContext.Fingerprint), cancellation);
cookieOptions.DropCookie(context, CookieNames.AccessToken);
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
2024-12-23 07:48:28 +03:00
logger.LogWarning("Token validation failed for user ID {UserId}. Fingerprint: {Fingerprint}. Reason: {Reason}.",
authToken.UserId,
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");
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);
var newRefreshToken = GenerateRefreshToken();
2024-06-21 21:36:11 +03:00
authToken.AccessToken = token;
authToken.RefreshToken = newRefreshToken;
await StoreAuthTokenInCache(authToken, cancellation);
cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
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
public async Task LogoutAsync(CookieOptionsParameters cookieOptions, HttpContext context, CancellationToken cancellation = default)
2024-05-29 05:56:27 +03:00
{
var requestContext = new RequestContextInfo(context, cookieOptions);
cookieOptions.DropCookie(context, CookieNames.AccessToken);
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
var authTokenStruct = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation);
2024-05-29 05:56:27 +03:00
if (authTokenStruct == null)
return;
2024-05-29 05:56:27 +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
}