From cd6f25debaafa60daec2769cf12c84ea2102e617 Mon Sep 17 00:00:00 2001 From: Polianin Nikita Date: Thu, 31 Oct 2024 04:12:02 +0300 Subject: [PATCH] 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 --- ApiDto/Responses/AuthenticationStep.cs | 17 ++ Endpoint/Configuration/Model/Admin.cs | 4 +- Endpoint/Controllers/V1/AuthController.cs | 135 +++---------- Security/Common/CookieNames.cs | 8 + Security/Common/Domain/AuthToken.cs | 22 +- .../Common/Domain/CookieOptionsParameters.cs | 29 +++ Security/Common/Domain/FirstAuthToken.cs | 22 ++ Security/Common/Domain/PreAuthToken.cs | 10 - Security/Common/Domain/RequestContextInfo.cs | 38 ++++ Security/Common/Domain/User.cs | 18 ++ Security/Common/Dto/Requests/TokenRequest.cs | 8 - .../Common/Dto/Responses/AuthTokenResponse.cs | 11 - .../Dto/Responses/PreAuthTokenResponse.cs | 9 - Security/DependencyInjection.cs | 21 +- Security/Properties/launchSettings.json | 12 ++ Security/Security.csproj | 10 +- Security/Services/AuthService.cs | 191 +++++++++++++----- Security/Services/PreAuthService.cs | 64 ------ Security/Services/TotpService.cs | 20 ++ 19 files changed, 371 insertions(+), 278 deletions(-) create mode 100644 ApiDto/Responses/AuthenticationStep.cs create mode 100644 Security/Common/CookieNames.cs create mode 100644 Security/Common/Domain/CookieOptionsParameters.cs create mode 100644 Security/Common/Domain/FirstAuthToken.cs delete mode 100644 Security/Common/Domain/PreAuthToken.cs create mode 100644 Security/Common/Domain/RequestContextInfo.cs create mode 100644 Security/Common/Domain/User.cs delete mode 100644 Security/Common/Dto/Requests/TokenRequest.cs delete mode 100644 Security/Common/Dto/Responses/AuthTokenResponse.cs delete mode 100644 Security/Common/Dto/Responses/PreAuthTokenResponse.cs create mode 100644 Security/Properties/launchSettings.json delete mode 100644 Security/Services/PreAuthService.cs create mode 100644 Security/Services/TotpService.cs diff --git a/ApiDto/Responses/AuthenticationStep.cs b/ApiDto/Responses/AuthenticationStep.cs new file mode 100644 index 0000000..8cab1ab --- /dev/null +++ b/ApiDto/Responses/AuthenticationStep.cs @@ -0,0 +1,17 @@ +namespace Mirea.Api.Dto.Responses; + +/// +/// Represents the steps required after a login attempt. +/// +public enum AuthenticationStep +{ + /// + /// No additional steps required; the user is successfully logged in. + /// + None, + + /// + /// TOTP (Time-based One-Time Password) is required for additional verification. + /// + TotpRequired, +} diff --git a/Endpoint/Configuration/Model/Admin.cs b/Endpoint/Configuration/Model/Admin.cs index 73db8d7..377b179 100644 --- a/Endpoint/Configuration/Model/Admin.cs +++ b/Endpoint/Configuration/Model/Admin.cs @@ -1,4 +1,5 @@ using Mirea.Api.Endpoint.Common.Services; +using Mirea.Api.Security.Common.Domain; using System.IO; using System.Text.Json; using System.Text.Json.Serialization; @@ -16,7 +17,8 @@ public class Admin : ISaveSettings public required string Email { get; set; } public required string PasswordHash { get; set; } public required string Salt { get; set; } - + public SecondFactor SecondFactor { get; set; } = SecondFactor.None; + public string? Secret { get; set; } public void SaveSetting() { diff --git a/Endpoint/Controllers/V1/AuthController.cs b/Endpoint/Controllers/V1/AuthController.cs index 2954985..737d9c5 100644 --- a/Endpoint/Controllers/V1/AuthController.cs +++ b/Endpoint/Controllers/V1/AuthController.cs @@ -2,109 +2,64 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; using Mirea.Api.Dto.Common; using Mirea.Api.Dto.Requests; +using Mirea.Api.Dto.Responses; using Mirea.Api.Endpoint.Common.Attributes; using Mirea.Api.Endpoint.Common.Exceptions; using Mirea.Api.Endpoint.Common.Services; -using Mirea.Api.Endpoint.Configuration.Core.Middleware; using Mirea.Api.Endpoint.Configuration.Model; -using Mirea.Api.Security.Common.Dto.Requests; +using Mirea.Api.Security.Common.Domain; using Mirea.Api.Security.Services; using System; -using System.Security; using System.Threading.Tasks; namespace Mirea.Api.Endpoint.Controllers.V1; [ApiVersion("1.0")] -public class AuthController(IOptionsSnapshot user, AuthService auth, PasswordHashService passwordService) : BaseController, IActionFilter +public class AuthController(IOptionsSnapshot user, AuthService auth, PasswordHashService passwordService) : BaseController { - private string Fingerprint { get; set; } = string.Empty; - private string Ip { get; set; } = string.Empty; - private string UserAgent { get; set; } = string.Empty; - private string RefreshToken { get; set; } = string.Empty; - - private void SetCookie(string name, string value, DateTimeOffset? expires = null) - { - var cookieOptions = new CookieOptions + private CookieOptionsParameters GetCookieParams() => + new() { - Expires = expires, - Path = UrlHelper.GetSubPathWithoutFirstApiName + "api", Domain = HttpContext.GetCurrentDomain(), - HttpOnly = true, -#if !DEBUG - Secure = true -#endif + Path = UrlHelper.GetSubPathWithoutFirstApiName + "api" }; - Response.Cookies.Append(name, value, cookieOptions); - } - - private void SetRefreshToken(string value, DateTimeOffset? expires = null) - { - SetCookie("refresh_token", value, expires); - SetCookie("user_key", Fingerprint); - } - - private void SetFirstToken(string value, DateTimeOffset? expires = null) - { - SetCookie("authentication_token", value, expires); - SetCookie("user_key", Fingerprint); - } - - private void SetAuthToken(string value, DateTimeOffset? expires = null) - { - SetCookie(CookieAuthorizationMiddleware.JwtAuthorizationName, value, expires); - SetCookie("user_key", Fingerprint); - } - - [ApiExplorerSettings(IgnoreApi = true)] - public void OnActionExecuting(ActionExecutingContext context) - { - Ip = HttpContext.Connection.RemoteIpAddress?.ToString()!; - UserAgent = Request.Headers.UserAgent.ToString(); - Fingerprint = Request.Cookies["user_key"] ?? string.Empty; - RefreshToken = Request.Cookies["refresh_token"] ?? string.Empty; - - if (!string.IsNullOrWhiteSpace(Fingerprint)) return; - - Fingerprint = Guid.NewGuid().ToString().Replace("-", ""); - } - - [ApiExplorerSettings(IgnoreApi = true)] - public void OnActionExecuted(ActionExecutedContext context) { } - - /// - /// Handles user authentication by verifying the username/email and password, - /// then generating and returning an authentication token if successful. - /// - /// The login request containing the username/email and password. - /// User's AuthRoles. [HttpPost("Login")] [BadRequestResponse] - public async Task> Login([FromBody] LoginRequest request) + public async Task> Login([FromBody] LoginRequest request) { var userEntity = user.Value; if (!userEntity.Username.Equals(request.Username, StringComparison.OrdinalIgnoreCase) && - !userEntity.Email.Equals(request.Username, StringComparison.OrdinalIgnoreCase) || - !passwordService.VerifyPassword(request.Password, userEntity.Salt, userEntity.PasswordHash)) + !userEntity.Email.Equals(request.Username, StringComparison.OrdinalIgnoreCase)) return BadRequest("Invalid username/email or password"); - var token = await auth.GenerateAuthTokensAsync(new TokenRequest - { - Fingerprint = Fingerprint, - Ip = Ip, - UserAgent = UserAgent - }, "1"); + var tokenResult = await auth.LoginAsync( + GetCookieParams(), + new User + { + Id = 1, + Username = userEntity.Username, + Email = userEntity.Email, + PasswordHash = userEntity.PasswordHash, + Salt = userEntity.Salt, + SecondFactor = userEntity.SecondFactor, + SecondFactorToken = userEntity.Secret + }, + HttpContext, request.Password); - SetRefreshToken(token.RefreshToken, token.RefreshExpiresIn); - SetAuthToken(token.AccessToken, token.AccessExpiresIn); + return Ok(tokenResult ? AuthenticationStep.None : AuthenticationStep.TotpRequired); + } - return Ok(AuthRoles.Admin); + [HttpGet("Login")] + [BadRequestResponse] + public async Task> Login([FromQuery] string code) + { + var tokenResult = await auth.LoginAsync(GetCookieParams(), HttpContext, code); + return Ok(tokenResult ? AuthenticationStep.None : AuthenticationStep.TotpRequired); } /// @@ -116,30 +71,8 @@ public class AuthController(IOptionsSnapshot user, AuthService auth, Pass [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task> ReLogin() { - if (string.IsNullOrEmpty(RefreshToken)) - return Unauthorized(); - - try - { - var token = await auth.RefreshTokenAsync( - new TokenRequest - { - Ip = Ip, - UserAgent = UserAgent, - Fingerprint = Fingerprint - }, - RefreshToken - ); - - SetRefreshToken(token.RefreshToken, token.RefreshExpiresIn); - SetAuthToken(token.AccessToken, token.AccessExpiresIn); - - return Ok(AuthRoles.Admin); - } - catch (SecurityException) - { - return Forbid(); - } + await auth.RefreshTokenAsync(GetCookieParams(), HttpContext); + return Ok(AuthRoles.Admin); } /// @@ -151,11 +84,7 @@ public class AuthController(IOptionsSnapshot user, AuthService auth, Pass [Authorize] public async Task Logout() { - SetRefreshToken("", DateTimeOffset.MinValue); - SetFirstToken("", DateTimeOffset.MinValue); - SetAuthToken("", DateTimeOffset.MinValue); - - await auth.LogoutAsync(Fingerprint); + await auth.LogoutAsync(GetCookieParams(), HttpContext); return Ok(); } diff --git a/Security/Common/CookieNames.cs b/Security/Common/CookieNames.cs new file mode 100644 index 0000000..7edb38b --- /dev/null +++ b/Security/Common/CookieNames.cs @@ -0,0 +1,8 @@ +namespace Mirea.Api.Security.Common; + +public class CookieNames +{ + public const string AccessToken = "access_token"; + public const string RefreshToken = "refresh_token"; + public const string FingerprintToken = "fingerprint"; +} \ No newline at end of file diff --git a/Security/Common/Domain/AuthToken.cs b/Security/Common/Domain/AuthToken.cs index 4572e62..ff78786 100644 --- a/Security/Common/Domain/AuthToken.cs +++ b/Security/Common/Domain/AuthToken.cs @@ -2,11 +2,25 @@ namespace Mirea.Api.Security.Common.Domain; -public class AuthToken +internal class AuthToken { - public required string RefreshToken { get; set; } - public required string UserAgent { get; set; } - public required string Ip { get; set; } + public AuthToken(RequestContextInfo context) + { + UserAgent = context.UserAgent; + Ip = context.Ip; + Fingerprint = context.Fingerprint; + RefreshToken = context.RefreshToken; + } + + public AuthToken() + { + } + + public string UserAgent { get; set; } = null!; + public string Ip { get; set; } = null!; + public string Fingerprint { get; set; } = null!; + public string RefreshToken { get; set; } = null!; + public required string UserId { get; set; } public required string AccessToken { get; set; } public DateTime CreatedAt { get; set; } diff --git a/Security/Common/Domain/CookieOptionsParameters.cs b/Security/Common/Domain/CookieOptionsParameters.cs new file mode 100644 index 0000000..5845b53 --- /dev/null +++ b/Security/Common/Domain/CookieOptionsParameters.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Http; +using System; + +namespace Mirea.Api.Security.Common.Domain; + +public class CookieOptionsParameters +{ + public required string Domain { get; set; } + public required string Path { get; set; } + + internal void SetCookie(HttpContext context, string name, string value, DateTimeOffset? expires = null) + { + var cookieOptions = new CookieOptions + { + Expires = expires, + Path = Path, + Domain = Domain, + HttpOnly = true, +#if !DEBUG + Secure = true +#endif + }; + + context.Response.Cookies.Append(name, value, cookieOptions); + } + + internal void DropCookie(HttpContext context, string name) => + SetCookie(context, name, "", DateTimeOffset.MinValue); +} diff --git a/Security/Common/Domain/FirstAuthToken.cs b/Security/Common/Domain/FirstAuthToken.cs new file mode 100644 index 0000000..a3a37e5 --- /dev/null +++ b/Security/Common/Domain/FirstAuthToken.cs @@ -0,0 +1,22 @@ +namespace Mirea.Api.Security.Common.Domain; + +internal class FirstAuthToken +{ + public FirstAuthToken(RequestContextInfo context) + { + UserAgent = context.UserAgent; + Ip = context.Ip; + Fingerprint = context.Fingerprint; + } + + public FirstAuthToken() + { + } + + public string UserAgent { get; set; } = null!; + public string Ip { get; set; } = null!; + public string Fingerprint { get; set; } = null!; + public required string UserId { get; set; } + public required SecondFactor SecondFactor { get; set; } + public string? Secret { get; set; } +} \ No newline at end of file diff --git a/Security/Common/Domain/PreAuthToken.cs b/Security/Common/Domain/PreAuthToken.cs deleted file mode 100644 index f1f9684..0000000 --- a/Security/Common/Domain/PreAuthToken.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Mirea.Api.Security.Common.Domain; - -public class PreAuthToken -{ - public required string Fingerprint { get; set; } - public required string UserAgent { get; set; } - public required string UserId { get; set; } - public required string Ip { get; set; } - public required string Token { get; set; } -} \ No newline at end of file diff --git a/Security/Common/Domain/RequestContextInfo.cs b/Security/Common/Domain/RequestContextInfo.cs new file mode 100644 index 0000000..7eee08d --- /dev/null +++ b/Security/Common/Domain/RequestContextInfo.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Http; +using Mirea.Api.Security.Services; +using System; +using System.Security; + +namespace Mirea.Api.Security.Common.Domain; + +internal class RequestContextInfo +{ + public RequestContextInfo(HttpContext context, CookieOptionsParameters cookieOptions) + { + var ipEntity = context.Connection.RemoteIpAddress; + + if (string.IsNullOrEmpty(ipEntity?.ToString())) + throw new SecurityException("Ip is required for authorization."); + + var ip = ipEntity.MapToIPv4().ToString(); + + var userAgent = context.Request.Headers.UserAgent.ToString(); + var fingerprint = context.Request.Cookies[CookieNames.FingerprintToken]; + + if (string.IsNullOrEmpty(fingerprint)) + { + fingerprint = Guid.NewGuid().ToString().Replace("-", "") + GeneratorKey.GenerateString(32); + cookieOptions.SetCookie(context, CookieNames.FingerprintToken, fingerprint); + } + + UserAgent = userAgent; + Fingerprint = fingerprint; + Ip = ip; + RefreshToken = context.Request.Cookies["refresh_token"] ?? string.Empty; + } + + public string UserAgent { get; private set; } + public string Ip { get; private set; } + public string Fingerprint { get; private set; } + public string RefreshToken { get; private set; } +} \ No newline at end of file diff --git a/Security/Common/Domain/User.cs b/Security/Common/Domain/User.cs new file mode 100644 index 0000000..3465de2 --- /dev/null +++ b/Security/Common/Domain/User.cs @@ -0,0 +1,18 @@ +namespace Mirea.Api.Security.Common.Domain; + +public enum SecondFactor +{ + None, + Totp +} + +public class User +{ + public required int Id { get; set; } + public required string Username { get; set; } + public required string Email { get; set; } + public required string PasswordHash { get; set; } + public required string Salt { get; set; } + public required SecondFactor SecondFactor { get; set; } + public string? SecondFactorToken { get; set; } +} \ No newline at end of file diff --git a/Security/Common/Dto/Requests/TokenRequest.cs b/Security/Common/Dto/Requests/TokenRequest.cs deleted file mode 100644 index 8be8038..0000000 --- a/Security/Common/Dto/Requests/TokenRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Mirea.Api.Security.Common.Dto.Requests; - -public class TokenRequest -{ - public required string Fingerprint { get; set; } - public required string UserAgent { get; set; } - public required string Ip { get; set; } -} \ No newline at end of file diff --git a/Security/Common/Dto/Responses/AuthTokenResponse.cs b/Security/Common/Dto/Responses/AuthTokenResponse.cs deleted file mode 100644 index 16aed38..0000000 --- a/Security/Common/Dto/Responses/AuthTokenResponse.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; - -namespace Mirea.Api.Security.Common.Dto.Responses; - -public class AuthTokenResponse -{ - public required string AccessToken { get; set; } - public DateTime AccessExpiresIn { get; set; } - public required string RefreshToken { get; set; } - public DateTime RefreshExpiresIn { get; set; } -} \ No newline at end of file diff --git a/Security/Common/Dto/Responses/PreAuthTokenResponse.cs b/Security/Common/Dto/Responses/PreAuthTokenResponse.cs deleted file mode 100644 index 9a7238f..0000000 --- a/Security/Common/Dto/Responses/PreAuthTokenResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; - -namespace Mirea.Api.Security.Common.Dto.Responses; - -public class PreAuthTokenResponse -{ - public required string Token { get; set; } - public DateTime ExpiresIn { get; set; } -} \ No newline at end of file diff --git a/Security/DependencyInjection.cs b/Security/DependencyInjection.cs index ed16c5e..0549ff4 100644 --- a/Security/DependencyInjection.cs +++ b/Security/DependencyInjection.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Mirea.Api.Security.Common.Interfaces; using Mirea.Api.Security.Services; using System; @@ -26,29 +27,21 @@ public static class DependencyInjection Secret = configuration["SECURITY_HASH_TOKEN"] }); - var lifeTimePreAuthToken = TimeSpan.FromMinutes(int.Parse(configuration["SECURITY_LIFE_TIME_1_FA"]!)); - - services.AddSingleton(provider => - { - var cache = provider.GetRequiredService(); - - return new PreAuthService(cache) - { - Lifetime = lifeTimePreAuthToken - }; - }); - var lifeTimeRefreshToken = TimeSpan.FromMinutes(int.Parse(configuration["SECURITY_LIFE_TIME_RT"]!)); + var lifeTimeFirstAuthToken = TimeSpan.FromMinutes(int.Parse(configuration["SECURITY_LIFE_TIME_1_FA"]!)); services.AddSingleton(provider => { var cacheService = provider.GetRequiredService(); var accessTokenService = provider.GetRequiredService(); var revokedTokenService = provider.GetRequiredService(); + var logger = provider.GetRequiredService>(); + var passwordService = provider.GetRequiredService(); - return new AuthService(cacheService, accessTokenService, revokedTokenService) + return new AuthService(cacheService, accessTokenService, revokedTokenService, logger, passwordService) { - Lifetime = lifeTimeRefreshToken + Lifetime = lifeTimeRefreshToken, + LifetimeFirstAuth = lifeTimeFirstAuthToken }; }); diff --git a/Security/Properties/launchSettings.json b/Security/Properties/launchSettings.json new file mode 100644 index 0000000..faa4f46 --- /dev/null +++ b/Security/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Security": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:58871;http://localhost:58872" + } + } +} \ No newline at end of file diff --git a/Security/Security.csproj b/Security/Security.csproj index f8dcc16..8e35bb3 100644 --- a/Security/Security.csproj +++ b/Security/Security.csproj @@ -1,21 +1,23 @@ - + net8.0 disable enable Winsomnia - 1.0.0 - 1.0.3.0 - 1.0.3.0 + 1.1.0 + 1.1.3.0 + 1.1.3.0 Mirea.Api.Security $(AssemblyName) + Library + diff --git a/Security/Services/AuthService.cs b/Security/Services/AuthService.cs index 5b293b3..00fe9ca 100644 --- a/Security/Services/AuthService.cs +++ b/Security/Services/AuthService.cs @@ -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 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 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(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 GenerateAuthTokensWithPreAuthAsync(TokenRequest request, string preAuthToken, - CancellationToken cancellation = default) => - await GenerateAuthTokensAsync(request, - await new PreAuthService(cache).MatchToken(request, preAuthToken, cancellation), - cancellation); - - public async Task RefreshTokenAsync(TokenRequest request, string refreshToken, CancellationToken cancellation = default) + public async Task LoginAsync(CookieOptionsParameters cookieOptions, HttpContext context, string code, CancellationToken cancellation = default) { - var authToken = await cache.GetAsync(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(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 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); @@ -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(GetAuthCacheKey(fingerprint), cancellation); - if (authTokenStruct == null) return; + 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(fingerprint, cancellation); + await cache.RemoveAsync(requestContext.Fingerprint, cancellation); + cookieOptions.DropCookie(context, CookieNames.AccessToken); + cookieOptions.DropCookie(context, CookieNames.RefreshToken); } } \ No newline at end of file diff --git a/Security/Services/PreAuthService.cs b/Security/Services/PreAuthService.cs deleted file mode 100644 index 948b997..0000000 --- a/Security/Services/PreAuthService.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Mirea.Api.Security.Common.Domain; -using Mirea.Api.Security.Common.Dto.Requests; -using Mirea.Api.Security.Common.Dto.Responses; -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 PreAuthService(ICacheService cache) -{ - public TimeSpan Lifetime { private get; init; } - - private static string GeneratePreAuthToken() => Guid.NewGuid().ToString().Replace("-", "") + - GeneratorKey.GenerateString(16); - - private static string GetPreAuthCacheKey(string fingerprint) => $"{fingerprint}_pre_auth_token"; - - public async Task GeneratePreAuthTokenAsync(TokenRequest request, string userId, CancellationToken cancellation = default) - { - var preAuthToken = GeneratePreAuthToken(); - - var preAuthTokenStruct = new PreAuthToken - { - Fingerprint = request.Fingerprint, - UserId = userId, - UserAgent = request.UserAgent, - Token = preAuthToken, - Ip = request.Ip - }; - - await cache.SetAsync( - GetPreAuthCacheKey(request.Fingerprint), - JsonSerializer.SerializeToUtf8Bytes(preAuthTokenStruct), - absoluteExpirationRelativeToNow: Lifetime, - cancellationToken: cancellation); - - return new PreAuthTokenResponse - { - Token = preAuthToken, - ExpiresIn = DateTime.UtcNow.Add(Lifetime) - }; - } - public async Task MatchToken(TokenRequest request, string preAuthToken, CancellationToken cancellation = default) - { - var preAuthTokenStruct = await cache.GetAsync(GetPreAuthCacheKey(request.Fingerprint), cancellation) - ?? throw new SecurityException($"The token was not found using fingerprint \"{request.Fingerprint}\""); - - if (preAuthTokenStruct == null || - preAuthTokenStruct.Token != preAuthToken || - (preAuthTokenStruct.UserAgent != request.UserAgent && - preAuthTokenStruct.Ip != request.Ip)) - { - throw new SecurityException("It was not possible to verify the authenticity of the token"); - } - - await cache.RemoveAsync(GetPreAuthCacheKey(request.Fingerprint), cancellation); - - return preAuthTokenStruct.UserId; - } -} \ No newline at end of file diff --git a/Security/Services/TotpService.cs b/Security/Services/TotpService.cs new file mode 100644 index 0000000..49cfb73 --- /dev/null +++ b/Security/Services/TotpService.cs @@ -0,0 +1,20 @@ +using OtpNet; + +namespace Mirea.Api.Security.Services; + +public class TotpService +{ + private readonly Totp _totp; + + public TotpService(string secret) + { + var secretBytes = Base32Encoding.ToBytes(secret); + _totp = new Totp(secretBytes); + } + + public string GenerateToken() => + _totp.ComputeTotp(); + + public bool VerifyToken(string token) => + _totp.VerifyTotp(token, out _, new VerificationWindow(2, 2)); +} \ No newline at end of file