diff --git a/Streetwriters.Identity/Validation/MFAGrantValidator.cs b/Streetwriters.Identity/Validation/MFAGrantValidator.cs index 24f7ad1..9d893f4 100644 --- a/Streetwriters.Identity/Validation/MFAGrantValidator.cs +++ b/Streetwriters.Identity/Validation/MFAGrantValidator.cs @@ -1,147 +1,143 @@ -/* -This file is part of the Notesnook Sync Server project (https://notesnook.com/) - -Copyright (C) 2023 Streetwriters (Private) Limited - -This program is free software: you can redistribute it and/or modify -it under the terms of the Affero GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -Affero GNU General Public License for more details. - -You should have received a copy of the Affero GNU General Public License -along with this program. If not, see . -*/ - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using IdentityModel; -using IdentityServer4.Models; -using IdentityServer4.Stores; -using IdentityServer4.Validation; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; -using Ng.Services; -using Streetwriters.Common; -using Streetwriters.Common.Enums; -using Streetwriters.Common.Models; -using Streetwriters.Identity.Interfaces; -using Streetwriters.Identity.Models; -using static IdentityModel.OidcConstants; - -namespace Streetwriters.Identity.Validation -{ - public class MFAGrantValidator : IExtensionGrantValidator - { - private UserManager UserManager { get; set; } - private SignInManager SignInManager { get; set; } - private IMFAService MFAService { get; set; } - private IHttpContextAccessor HttpContextAccessor { get; set; } - private ITokenValidator TokenValidator { get; set; } - private ITokenGenerationService TokenGenerationService { get; set; } - private IEmailSender EmailSender { get; set; } - public MFAGrantValidator(UserManager userManager, SignInManager signInManager, IMFAService mfaService, IHttpContextAccessor httpContextAccessor, ITokenValidator tokenValidator, ITokenGenerationService tokenGenerationService, IEmailSender emailSender) - { - UserManager = userManager; - SignInManager = signInManager; - MFAService = mfaService; - HttpContextAccessor = httpContextAccessor; - TokenValidator = tokenValidator; - TokenGenerationService = tokenGenerationService; - EmailSender = emailSender; - } - - public string GrantType => Config.MFA_GRANT_TYPE; - - public async Task ValidateAsync(ExtensionGrantValidationContext context) - { - context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant); - - var httpContext = HttpContextAccessor.HttpContext; - var tokenResult = BearerTokenValidator.ValidateAuthorizationHeader(httpContext); - if (!tokenResult.TokenFound) return; - - var tokenValidationResult = await TokenValidator.ValidateAccessTokenAsync(tokenResult.Token, Config.MFA_GRANT_TYPE_SCOPE); - if (tokenValidationResult.IsError) return; - - var client = Clients.FindClientById(tokenValidationResult.Claims.GetClaimValue("client_id")); - if (client == null) - { - context.Result = new GrantValidationResult(TokenRequestErrors.InvalidClient); - return; - } - - var userId = tokenValidationResult.Claims.GetClaimValue("sub"); - var mfaCode = context.Request.Raw["mfa:code"]; - var mfaMethod = context.Request.Raw["mfa:method"]; - - if (string.IsNullOrEmpty(userId)) return; - - var user = await UserManager.FindByIdAsync(userId); - if (user == null) return; - - var isLockedOut = await UserManager.IsLockedOutAsync(user); - if (isLockedOut) - { - var timeLeft = user.LockoutEnd - DateTimeOffset.Now; - context.Result = new LockedOutValidationResult(timeLeft); - return; - } - - context.Result.Error = "invalid_mfa"; - context.Result.ErrorDescription = "Please provide a valid multi-factor authentication code."; - - if (!await UserManager.GetTwoFactorEnabledAsync(user)) - await MFAService.EnableMFAAsync(user, MFAMethods.Email); - - if (string.IsNullOrEmpty(mfaCode)) return; - if (string.IsNullOrEmpty(mfaMethod) || !MFAService.IsValidMFAMethod(mfaMethod)) - { - context.Result.ErrorDescription = "Please provide a valid multi-factor authentication method."; - return; - } - - if (mfaMethod == MFAMethods.RecoveryCode) - { - context.Result.ErrorDescription = "Please provide a valid multi-factor authentication recovery code."; - - var result = await UserManager.RedeemTwoFactorRecoveryCodeAsync(user, mfaCode); - if (!result.Succeeded) - { - await UserManager.AccessFailedAsync(user); - await EmailSender.SendFailedLoginAlertAsync(user.Email, httpContext.GetClientInfo(), client).ConfigureAwait(false); - return; - } - } - else - { - if (!await MFAService.VerifyOTPAsync(user, mfaCode, mfaMethod)) - { - await UserManager.AccessFailedAsync(user); - await EmailSender.SendFailedLoginAlertAsync(user.Email, httpContext.GetClientInfo(), client).ConfigureAwait(false); - return; - } - } - - await UserManager.ResetAccessFailedCountAsync(user); - context.Result.IsError = false; - context.Result.Subject = await TokenGenerationService.TransformTokenRequestAsync(context.Request, user, GrantType, [Config.MFA_PASSWORD_GRANT_TYPE_SCOPE]); - } - - - string Pluralize(int? value, string singular, string plural) - { - if (value == null) return $"0 {plural}"; - return value == 1 ? $"{value} {singular}" : $"{value} {plural}"; - } - } +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the Affero GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +Affero GNU General Public License for more details. + +You should have received a copy of the Affero GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using IdentityModel; +using IdentityServer4.Models; +using IdentityServer4.Stores; +using IdentityServer4.Validation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Ng.Services; +using Streetwriters.Common; +using Streetwriters.Common.Enums; +using Streetwriters.Common.Models; +using Streetwriters.Identity.Interfaces; +using Streetwriters.Identity.Models; +using static IdentityModel.OidcConstants; + +namespace Streetwriters.Identity.Validation +{ + public class MFAGrantValidator : IExtensionGrantValidator + { + private UserManager UserManager { get; set; } + private SignInManager SignInManager { get; set; } + private IMFAService MFAService { get; set; } + private IHttpContextAccessor HttpContextAccessor { get; set; } + private ITokenValidator TokenValidator { get; set; } + private ITokenGenerationService TokenGenerationService { get; set; } + private IEmailSender EmailSender { get; set; } + public MFAGrantValidator(UserManager userManager, SignInManager signInManager, IMFAService mfaService, IHttpContextAccessor httpContextAccessor, ITokenValidator tokenValidator, ITokenGenerationService tokenGenerationService, IEmailSender emailSender) + { + UserManager = userManager; + SignInManager = signInManager; + MFAService = mfaService; + HttpContextAccessor = httpContextAccessor; + TokenValidator = tokenValidator; + TokenGenerationService = tokenGenerationService; + EmailSender = emailSender; + } + + public string GrantType => Config.MFA_GRANT_TYPE; + + public async Task ValidateAsync(ExtensionGrantValidationContext context) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant); + + var httpContext = HttpContextAccessor.HttpContext; + var tokenResult = BearerTokenValidator.ValidateAuthorizationHeader(httpContext); + if (!tokenResult.TokenFound) return; + + var tokenValidationResult = await TokenValidator.ValidateAccessTokenAsync(tokenResult.Token, Config.MFA_GRANT_TYPE_SCOPE); + if (tokenValidationResult.IsError) return; + + var client = Clients.FindClientById(tokenValidationResult.Claims.GetClaimValue("client_id")); + if (client == null) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidClient); + return; + } + + var userId = tokenValidationResult.Claims.GetClaimValue("sub"); + var mfaCode = context.Request.Raw["mfa:code"]; + var mfaMethod = context.Request.Raw["mfa:method"]; + + if (string.IsNullOrEmpty(userId)) return; + + var user = await UserManager.FindByIdAsync(userId); + if (user == null) return; + + var isLockedOut = await UserManager.IsLockedOutAsync(user); + if (isLockedOut) + { + var timeLeft = user.LockoutEnd - DateTimeOffset.Now; + context.Result = new LockedOutValidationResult(timeLeft); + return; + } + + context.Result.Error = "invalid_mfa"; + context.Result.ErrorDescription = "Please provide a valid multi-factor authentication code."; + + if (string.IsNullOrEmpty(mfaCode)) return; + if (string.IsNullOrEmpty(mfaMethod) || !MFAService.IsValidMFAMethod(mfaMethod)) + { + context.Result.ErrorDescription = "Please provide a valid multi-factor authentication method."; + return; + } + + if (mfaMethod == MFAMethods.RecoveryCode) + { + context.Result.ErrorDescription = "Please provide a valid multi-factor authentication recovery code."; + + var result = await UserManager.RedeemTwoFactorRecoveryCodeAsync(user, mfaCode); + if (!result.Succeeded) + { + await UserManager.AccessFailedAsync(user); + await EmailSender.SendFailedLoginAlertAsync(user.Email, httpContext.GetClientInfo(), client).ConfigureAwait(false); + return; + } + } + else + { + if (!await MFAService.VerifyOTPAsync(user, mfaCode, mfaMethod)) + { + await UserManager.AccessFailedAsync(user); + await EmailSender.SendFailedLoginAlertAsync(user.Email, httpContext.GetClientInfo(), client).ConfigureAwait(false); + return; + } + } + + // This must be done after the MFA code is verified + // otherwise the security stamp will be updated + // making all the 2FA code invalid + if (!await UserManager.GetTwoFactorEnabledAsync(user)) + await MFAService.EnableMFAAsync(user, MFAMethods.Email); + + await UserManager.ResetAccessFailedCountAsync(user); + context.Result.IsError = false; + context.Result.Subject = await TokenGenerationService.TransformTokenRequestAsync(context.Request, user, GrantType, [Config.MFA_PASSWORD_GRANT_TYPE_SCOPE]); + } + } } \ No newline at end of file