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-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-10-31 04:12:02 +03:00
private static string GetFirstAuthCacheKey ( string fingerprint ) = > $"{fingerprint}_auth_token_first" ;
2024-05-29 05:50:47 +03:00
2024-10-31 04:12:02 +03:00
private Task SetAuthTokenDataToCache ( 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-05-29 05:55:31 +03:00
private Task RevokeAccessToken ( string token ) = >
revokedToken . AddTokenToRevokedAsync ( token , accessTokenService . GetExpireDateTime ( token ) ) ;
2024-10-31 04:12:02 +03:00
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 )
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 ,
UserId = userId ,
2024-10-31 04:12:02 +03:00
AccessToken = token ,
2024-05-29 05:51:32 +03:00
} ;
2024-10-31 04:12:02 +03:00
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" ) ;
2024-05-29 05:51:32 +03:00
2024-10-31 04:12:02 +03:00
switch ( firstTokenAuth . SecondFactor )
2024-05-29 05:51:32 +03:00
{
2024-10-31 04:12:02 +03:00
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 ;
2024-05-29 05:51:32 +03:00
}
2024-05-29 05:55:57 +03:00
2024-10-31 04:12:02 +03:00
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 ) ;
2024-05-29 06:00:15 +03:00
2024-10-31 04:12:02 +03:00
return false ;
}
public async Task RefreshTokenAsync ( CookieOptionsParameters cookieOptions , HttpContext context , CancellationToken cancellation = default )
2024-05-29 05:55:57 +03:00
{
2024-10-31 04:12:02 +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
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 ) ;
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" ) ;
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-10-31 04:12:02 +03:00
await SetAuthTokenDataToCache ( authToken , cancellation ) ;
cookieOptions . SetCookie ( context , CookieNames . AccessToken , token , 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
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 ) ;
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 ) ;
cookieOptions . DropCookie ( context , CookieNames . AccessToken ) ;
cookieOptions . DropCookie ( context , CookieNames . RefreshToken ) ;
2024-05-29 05:56:27 +03:00
}
2024-05-29 05:32:22 +03:00
}