226 lines
10 KiB
C#
226 lines
10 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.Domain.Caching;
|
|
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 CreateFirstAuthTokenToCache(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 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 unsuccessful login attempts for user ID {UserId} from IP {UserIp}. Attempt count: {AttemptNumber}.",
|
|
user.Id,
|
|
requestContext.Ip,
|
|
countFailedLogin);
|
|
|
|
throw new SecurityException("Too many unsuccessful login attempts. Please try again later.");
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"Login attempt failed for user ID {UserId}. IP: {UserIp}, User-Agent: {UserAgent}, Fingerprint: {Fingerprint}. Attempt count: {AttemptNumber}.",
|
|
user.Id,
|
|
requestContext.Ip,
|
|
requestContext.UserAgent,
|
|
requestContext.Fingerprint,
|
|
countFailedLogin);
|
|
|
|
throw new SecurityException("Authentication failed. Please check your credentials.");
|
|
}
|
|
|
|
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, authToken.AccessToken, expireIn);
|
|
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
|
|
|
|
logger.LogInformation(
|
|
"Login successful for user ID {UserId}. IP: {UserIp}, User-Agent: {UserAgent}, Fingerprint: {Fingerprint}.",
|
|
authToken.UserId,
|
|
authToken.Ip,
|
|
authToken.UserAgent,
|
|
authToken.Fingerprint);
|
|
}
|
|
|
|
public async Task<TwoFactorAuthenticator> LoginOAuthAsync(CookieOptionsParameters cookieOptions, HttpContext context, User user, OAuthProvider provider, 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 CreateFirstAuthTokenToCache(user, requestContext, cancellation);
|
|
|
|
return user.TwoFactorAuthenticator;
|
|
}
|
|
|
|
public async Task<bool> LoginAsync(CookieOptionsParameters cookieOptions, HttpContext context, TwoFactorAuthenticator authenticator, string code, CancellationToken cancellation = default)
|
|
{
|
|
var requestContext = new RequestContextInfo(context, cookieOptions);
|
|
|
|
var firstTokenAuth = await cache.GetAsync<FirstAuthToken?>(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))
|
|
throw new InvalidOperationException("Required authentication data is missing.");
|
|
|
|
var totp = new TotpService(firstTokenAuth.Secret);
|
|
|
|
if (!totp.VerifyToken(code))
|
|
throw new SecurityException("Invalid verification code. Please try again.");
|
|
}
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException("Unsupported authorization method.");
|
|
}
|
|
|
|
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, firstTokenAuth.UserId, cancellation);
|
|
return true;
|
|
}
|
|
|
|
public async Task<TwoFactorAuthenticator> 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.TwoFactorAuthenticator == TwoFactorAuthenticator.None)
|
|
{
|
|
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id, cancellation);
|
|
return TwoFactorAuthenticator.None;
|
|
}
|
|
|
|
await CreateFirstAuthTokenToCache(user, requestContext, cancellation);
|
|
|
|
return user.TwoFactorAuthenticator;
|
|
}
|
|
|
|
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}. IP: {UserIp}, User-Agent: {UserAgent}, Fingerprint: {Fingerprint}. 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, authToken.AccessToken, 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);
|
|
|
|
cookieOptions.DropCookie(context, CookieNames.AccessToken);
|
|
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
|
|
|
|
var authTokenStruct = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation);
|
|
|
|
if (authTokenStruct == null)
|
|
return;
|
|
|
|
await RevokeAccessToken(authTokenStruct.AccessToken);
|
|
await cache.RemoveAsync(requestContext.Fingerprint, cancellation);
|
|
}
|
|
} |