refactor: transfer logic
All logic related to token manipulation has been transferred to the AuthService. Also added TOTP 2FA and rethought the logic of logging into the application
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
using Mirea.Api.Security.Common.Domain;
|
||||
using Mirea.Api.Security.Common.Dto.Requests;
|
||||
using Mirea.Api.Security.Common.Dto.Responses;
|
||||
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;
|
||||
@ -10,9 +11,10 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace Mirea.Api.Security.Services;
|
||||
|
||||
public class AuthService(ICacheService cache, IAccessToken accessTokenService, IRevokedToken revokedToken)
|
||||
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);
|
||||
@ -20,10 +22,11 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
||||
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(string fingerprint, AuthToken data, CancellationToken cancellation) =>
|
||||
private Task SetAuthTokenDataToCache(AuthToken data, CancellationToken cancellation) =>
|
||||
cache.SetAsync(
|
||||
GetAuthCacheKey(fingerprint),
|
||||
GetAuthCacheKey(data.Fingerprint),
|
||||
JsonSerializer.SerializeToUtf8Bytes(data),
|
||||
slidingExpiration: Lifetime,
|
||||
cancellationToken: cancellation);
|
||||
@ -31,51 +34,141 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
||||
private Task RevokeAccessToken(string token) =>
|
||||
revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token));
|
||||
|
||||
public async Task<AuthTokenResponse> GenerateAuthTokensAsync(TokenRequest request, string userId, CancellationToken cancellation = default)
|
||||
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 authTokenStruct = new AuthToken
|
||||
var authToken = new AuthToken(requestContext)
|
||||
{
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Ip = request.Ip,
|
||||
RefreshToken = refreshToken,
|
||||
UserAgent = request.UserAgent,
|
||||
UserId = userId,
|
||||
AccessToken = token
|
||||
};
|
||||
|
||||
await SetAuthTokenDataToCache(request.Fingerprint, authTokenStruct, cancellation);
|
||||
|
||||
return new AuthTokenResponse
|
||||
{
|
||||
AccessToken = token,
|
||||
AccessExpiresIn = expireIn,
|
||||
RefreshToken = authTokenStruct.RefreshToken,
|
||||
RefreshExpiresIn = DateTime.UtcNow.Add(Lifetime),
|
||||
};
|
||||
|
||||
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<AuthTokenResponse> GenerateAuthTokensWithPreAuthAsync(TokenRequest request, string preAuthToken,
|
||||
CancellationToken cancellation = default) =>
|
||||
await GenerateAuthTokensAsync(request,
|
||||
await new PreAuthService(cache).MatchToken(request, preAuthToken, cancellation),
|
||||
cancellation);
|
||||
|
||||
public async Task<AuthTokenResponse> RefreshTokenAsync(TokenRequest request, string refreshToken, CancellationToken cancellation = default)
|
||||
public async Task<bool> LoginAsync(CookieOptionsParameters cookieOptions, HttpContext context, string code, CancellationToken cancellation = default)
|
||||
{
|
||||
var authToken = await cache.GetAsync<AuthToken>(GetAuthCacheKey(request.Fingerprint), cancellation)
|
||||
?? throw new SecurityException(request.Fingerprint);
|
||||
var requestContext = new RequestContextInfo(context, cookieOptions);
|
||||
|
||||
if (authToken.RefreshToken != refreshToken ||
|
||||
authToken.UserAgent != request.UserAgent &&
|
||||
authToken.Ip != request.Ip)
|
||||
var firstTokenAuth = await cache.GetAsync<FirstAuthToken?>(GetFirstAuthCacheKey(requestContext.Fingerprint), cancellationToken: cancellation)
|
||||
?? throw new SecurityException("The session time has expired");
|
||||
|
||||
switch (firstTokenAuth.SecondFactor)
|
||||
{
|
||||
await cache.RemoveAsync(GetAuthCacheKey(request.Fingerprint), cancellation);
|
||||
await RevokeAccessToken(authToken.AccessToken);
|
||||
case SecondFactor.Totp:
|
||||
{
|
||||
if (string.IsNullOrEmpty(firstTokenAuth.Secret))
|
||||
throw new InvalidOperationException("The user's secrets for data processing were not transferred.");
|
||||
|
||||
throw new SecurityException(request.Fingerprint);
|
||||
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);
|
||||
@ -86,24 +179,22 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
||||
authToken.AccessToken = token;
|
||||
authToken.RefreshToken = newRefreshToken;
|
||||
|
||||
await SetAuthTokenDataToCache(request.Fingerprint, authToken, cancellation);
|
||||
|
||||
return new AuthTokenResponse
|
||||
{
|
||||
AccessToken = token,
|
||||
AccessExpiresIn = expireIn,
|
||||
RefreshToken = newRefreshToken,
|
||||
RefreshExpiresIn = DateTime.UtcNow.Add(Lifetime)
|
||||
};
|
||||
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(string fingerprint, CancellationToken cancellation = default)
|
||||
public async Task LogoutAsync(CookieOptionsParameters cookieOptions, HttpContext context, CancellationToken cancellation = default)
|
||||
{
|
||||
var authTokenStruct = await cache.GetAsync<AuthToken>(GetAuthCacheKey(fingerprint), cancellation);
|
||||
if (authTokenStruct == null) return;
|
||||
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(fingerprint, cancellation);
|
||||
await cache.RemoveAsync(requestContext.Fingerprint, cancellation);
|
||||
cookieOptions.DropCookie(context, CookieNames.AccessToken);
|
||||
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user