MireaBackend/Security/Services/AuthService.cs
Polianin Nikita a0ff624481
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m39s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 3m11s
fix: add forgotten changes
2024-10-31 04:12:22 +03:00

201 lines
9.4 KiB
C#

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;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Mirea.Api.Security.Services;
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);
private (string Token, DateTime ExpireIn) GenerateAccessToken(string userId) =>
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(AuthToken data, CancellationToken cancellation) =>
cache.SetAsync(
GetAuthCacheKey(data.Fingerprint),
JsonSerializer.SerializeToUtf8Bytes(data),
slidingExpiration: Lifetime,
cancellationToken: cancellation);
private Task RevokeAccessToken(string token) =>
revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token));
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 authToken = new AuthToken(requestContext)
{
CreatedAt = DateTime.UtcNow,
RefreshToken = refreshToken,
UserId = userId,
AccessToken = token
};
await SetAuthTokenDataToCache(authToken, cancellation);
cookieOptions.SetCookie(context, CookieNames.AccessToken, token, expireIn);
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
logger.LogInformation(
"Successful login attempt for user ID {UserId} from IP {UserIp} with User-Agent {UserAgent} and Fingerprint {Fingerprint}.",
authToken.UserId,
authToken.Ip,
authToken.UserAgent,
authToken.Fingerprint);
}
public async Task<bool> LoginAsync(CookieOptionsParameters cookieOptions, HttpContext context, string code, CancellationToken cancellation = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
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 RevokeAccessToken(authToken.AccessToken);
await cache.RemoveAsync(GetAuthCacheKey(requestContext.Fingerprint), cancellation);
cookieOptions.DropCookie(context, CookieNames.AccessToken);
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
logger.LogWarning("Token validation failed for user ID {UserId}. Invalid token used from IP {UserIp} with User-Agent {UserAgent} and Fingerprint {Fingerprint}. Possible account compromise. Reason: {Reason}.",
authToken.UserId,
authToken.Ip,
authToken.UserAgent,
authToken.Fingerprint,
authToken.RefreshToken != requestContext.RefreshToken ?
$"Cached refresh token '{authToken.RefreshToken}' does not match the provided refresh token '{requestContext.RefreshToken}'" :
$"User-Agent '{authToken.UserAgent}' and IP '{authToken.Ip}' in cache do not match the provided User-Agent '{requestContext.UserAgent}' and IP '{requestContext.Ip}'");
throw new SecurityException("The session time has expired");
}
var (token, expireIn) = GenerateAccessToken(authToken.UserId);
await RevokeAccessToken(authToken.AccessToken);
var newRefreshToken = GenerateRefreshToken();
authToken.AccessToken = token;
authToken.RefreshToken = newRefreshToken;
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(CookieOptionsParameters cookieOptions, HttpContext context, CancellationToken cancellation = default)
{
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(requestContext.Fingerprint, cancellation);
cookieOptions.DropCookie(context, CookieNames.AccessToken);
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
}
}