using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Mirea.Api.Security.Common; using Mirea.Api.Security.Common.Domain; using Mirea.Api.Security.Common.Domain.Caching; using Mirea.Api.Security.Common.Interfaces; using Mirea.Api.Security.Common.Model; using System; using System.Security; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using CookieOptions = Mirea.Api.Security.Common.Model.CookieOptions; 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 static string GetAttemptFailedCountKey(string fingerprint) => $"{fingerprint}_login_failed"; private Task StoreAuthTokenInCache(AuthToken data, CancellationToken cancellation) => cache.SetAsync( GetAuthCacheKey(data.Fingerprint), JsonSerializer.SerializeToUtf8Bytes(data), slidingExpiration: Lifetime, cancellationToken: cancellation); private Task StoreFirstAuthTokenInCache(User data, RequestContextInfo requestContext, CancellationToken cancellation) => cache.SetAsync( GetFirstAuthCacheKey(requestContext.Fingerprint), JsonSerializer.SerializeToUtf8Bytes(new FirstAuthToken(requestContext) { UserId = data.Id, Secret = data.SecondFactorToken, TwoFactorAuthenticator = data.TwoFactorAuthenticator }), slidingExpiration: LifetimeFirstAuth, cancellationToken: cancellation); 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(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); throw new SecurityException("Too many unsuccessful login attempts. Please try again later."); } logger.LogInformation( "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); throw new SecurityException("Authentication failed. Please check your credentials."); } private async Task GenerateAuthTokensAsync(CookieOptions 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 StoreAuthTokenInCache(authToken, cancellation); cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn); cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime)); logger.LogInformation( "Login successful for user ID {UserId}. Fingerprint: {Fingerprint}.", authToken.UserId, authToken.Fingerprint); } public async Task LoginAsync(CookieOptions cookieOptions, HttpContext context, TwoFactorAuthenticator authenticator, string code, CancellationToken cancellation = default) { var requestContext = new RequestContextInfo(context, cookieOptions); var firstTokenAuth = await cache.GetAsync(GetFirstAuthCacheKey(requestContext.Fingerprint), cancellationToken: cancellation); if (firstTokenAuth == null || authenticator != firstTokenAuth.TwoFactorAuthenticator) throw new SecurityException("Session expired. Please log in again."); switch (firstTokenAuth.TwoFactorAuthenticator) { case TwoFactorAuthenticator.Totp: { if (string.IsNullOrEmpty(firstTokenAuth.Secret)) { logger.LogWarning("The user {Fingerprint} for {UserId} tried to pass the 2FA even though the secret is empty", requestContext.Fingerprint, firstTokenAuth.UserId); throw new InvalidOperationException("Required authentication data is missing."); } var totp = new TotpService(firstTokenAuth.Secret); if (!totp.VerifyToken(code)) { await RecordFailedLoginAttempt(requestContext.Fingerprint, firstTokenAuth.UserId, cancellation); throw new SecurityException("Invalid verification code. Please try again."); } await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellation); } break; default: throw new InvalidOperationException("Unsupported authorization method."); } await GenerateAuthTokensAsync(cookieOptions, context, requestContext, firstTokenAuth.UserId, cancellation); return true; } private async Task LoginAsync(CookieOptions cookieOptions, HttpContext context, User user, CancellationToken cancellation = default) { 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); return user.TwoFactorAuthenticator; } public Task LoginOAuthAsync(CookieOptions cookieOptions, HttpContext context, User user, OAuthUser oAuthUser, OAuthProvider provider, CancellationToken cancellation = default) { if (user.OAuthProviders == null || !user.OAuthProviders.TryGetValue(provider, out var value)) throw new SecurityException($"This provider '{Enum.GetName(provider)}' is not linked to the account."); if (value.Id != oAuthUser.Id) throw new SecurityException("This account was not linked"); return LoginAsync(cookieOptions, context, user, cancellation); } public async Task LoginAsync(CookieOptions cookieOptions, HttpContext context, User user, string password, string username, CancellationToken cancellation = default) { var requestContext = new RequestContextInfo(context, cookieOptions); username = username.Trim(); await VerifyUserOrThrowError(requestContext, user, password, username, cancellation); return await LoginAsync(cookieOptions, context, user, cancellation); } public async Task RefreshTokenAsync(CookieOptions cookieOptions, HttpContext context, CancellationToken cancellation = default) { const string defaultMessageError = "The session time has expired"; var requestContext = new RequestContextInfo(context, cookieOptions); var authToken = await cache.GetAsync(GetAuthCacheKey(requestContext.Fingerprint), cancellation) ?? throw new SecurityException(defaultMessageError); 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); 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}", authToken.UserId, authToken.Fingerprint, 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); } 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); authToken.Ip = requestContext.Ip; } var (token, expireIn) = GenerateAccessToken(authToken.UserId); await RevokeAccessToken(authToken.AccessToken); var newRefreshToken = GenerateRefreshToken(); 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)); } public async Task LogoutAsync(CookieOptions cookieOptions, HttpContext context, CancellationToken cancellation = default) { var requestContext = new RequestContextInfo(context, cookieOptions); cookieOptions.DropCookie(context, CookieNames.AccessToken); cookieOptions.DropCookie(context, CookieNames.RefreshToken); var authTokenStruct = await cache.GetAsync(GetAuthCacheKey(requestContext.Fingerprint), cancellation); if (authTokenStruct == null) return; await RevokeAccessToken(authTokenStruct.AccessToken); await cache.RemoveAsync(requestContext.Fingerprint, cancellation); } }