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 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(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 LoginAsync(CookieOptionsParameters cookieOptions, HttpContext context, string code, CancellationToken cancellation = default) { var requestContext = new RequestContextInfo(context, cookieOptions); var firstTokenAuth = await cache.GetAsync(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 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(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(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); } }