/* 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.Linq; using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using Streetwriters.Common.Enums; using Streetwriters.Common.Interfaces; using Streetwriters.Common.Models; using Streetwriters.Identity.Interfaces; using Streetwriters.Identity.Models; namespace Streetwriters.Identity.Services { internal class MFAService : IMFAService { const string PRIMARY_METHOD_CLAIM = "mfa:primary"; const string SECONDARY_METHOD_CLAIM = "mfa:secondary"; const string SMS_ID_CLAIM = "mfa:sms:id"; private UserManager UserManager { get; set; } private ITemplatedEmailSender EmailSender { get; set; } private ISMSSender SMSSender { get; set; } private readonly ILogger logger; public MFAService(UserManager _userManager, ITemplatedEmailSender emailSender, ISMSSender smsSender, ILogger logger) { UserManager = _userManager; EmailSender = emailSender; SMSSender = smsSender; this.logger = logger; } public async Task EnableMFAAsync(User user, string primaryMethod) { var result = await UserManager.SetTwoFactorEnabledAsync(user, true); if (!result.Succeeded) return; await this.RemovePrimaryMethodAsync(user); await this.RemoveSecondaryMethodAsync(user); await UserManager.AddClaimAsync(user, new Claim(MFAService.PRIMARY_METHOD_CLAIM, primaryMethod)); } public async Task DisableMFAAsync(User user) { var result = await UserManager.SetTwoFactorEnabledAsync(user, false); if (!result.Succeeded) return false; await this.RemovePrimaryMethodAsync(user); await this.RemoveSecondaryMethodAsync(user); await UserManager.ResetAuthenticatorKeyAsync(user); return true; } public async Task ResetMFAAsync(User user) { await UserManager.SetTwoFactorEnabledAsync(user, false); await UserManager.SetTwoFactorEnabledAsync(user, true); await this.RemovePrimaryMethodAsync(user); await this.RemoveSecondaryMethodAsync(user); await UserManager.AddClaimAsync(user, new Claim(MFAService.PRIMARY_METHOD_CLAIM, MFAMethods.Email)); await UserManager.ResetAuthenticatorKeyAsync(user); return true; } public async Task SetSecondaryMethodAsync(User user, string secondaryMethod) { await this.ReplaceClaimAsync(user, MFAService.SECONDARY_METHOD_CLAIM, secondaryMethod); } private async Task ReplaceClaimAsync(User user, string claimType, string claimValue) { await this.RemoveClaimAsync(user, claimType); await UserManager.AddClaimAsync(user, new Claim(claimType, claimValue)); } public string GetPrimaryMethod(User user) { return GetClaimValue(user, MFAService.PRIMARY_METHOD_CLAIM) ?? MFAMethods.Email; } public string? GetSecondaryMethod(User user) { return GetClaimValue(user, MFAService.SECONDARY_METHOD_CLAIM); } public static string? GetClaimValue(User user, string claimType) { var claim = user.Claims.FirstOrDefault((c) => c.ClaimType == claimType); return claim?.ClaimValue; } public Task GetRemainingValidCodesAsync(User user) { return UserManager.CountRecoveryCodesAsync(user); } public bool IsValidMFAMethod(string method) { return method == MFAMethods.App || method == MFAMethods.Email || method == MFAMethods.SMS || method == MFAMethods.RecoveryCode; } public bool IsValidMFAMethod(string method, User user) { var primaryMethod = GetPrimaryMethod(user); var secondaryMethod = GetSecondaryMethod(user); if (!IsValidMFAMethod(method)) return false; return method == primaryMethod || (!string.IsNullOrEmpty(secondaryMethod) && method == secondaryMethod); } private Task RemoveSecondaryMethodAsync(User user) { return this.RemoveClaimAsync(user, MFAService.SECONDARY_METHOD_CLAIM); } private Task RemovePrimaryMethodAsync(User user) { return this.RemoveClaimAsync(user, MFAService.PRIMARY_METHOD_CLAIM); } private async Task RemoveClaimAsync(User user, string claimType) { var claim = user.Claims.FirstOrDefault((c) => c.ClaimType == claimType); if (claim != null) await UserManager.RemoveClaimAsync(user, claim.ToClaim()); } public async Task GetAuthenticatorDetailsAsync(User user, IClient client) { // Load the authenticator key & QR code URI to display on the form var unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user); if (string.IsNullOrEmpty(unformattedKey)) { await UserManager.ResetAuthenticatorKeyAsync(user); unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user); } ArgumentNullException.ThrowIfNull(unformattedKey); ArgumentNullException.ThrowIfNull(user.Email); return new AuthenticatorDetails { SharedKey = FormatKey(unformattedKey), AuthenticatorUri = GenerateQrCodeUri(user.Email, unformattedKey, client.Name) }; } public async Task SendOTPAsync(User user, IClient client, MultiFactorSetupForm form) { var method = form.Type; if ((method != MFAMethods.Email && method != MFAMethods.SMS) || !IsValidMFAMethod(method)) throw new Exception("Invalid method."); await GetAuthenticatorDetailsAsync(user, client); switch (method) { case "email": ArgumentNullException.ThrowIfNull(user.Email); string emailOTP = await UserManager.GenerateTwoFactorTokenAsync(user, TokenOptions.DefaultPhoneProvider); await EmailSender.Send2FACodeEmailAsync(user.Email, emailOTP, client); break; case "sms": ArgumentNullException.ThrowIfNull(form.PhoneNumber); await UserManager.SetPhoneNumberAsync(user, form.PhoneNumber); var id = await SMSSender.SendOTPAsync(form.PhoneNumber, client); logger.LogInformation("SMS OTP sent for user: {UserId}, SMS ID: {SmsId}", user.Id, id); await this.ReplaceClaimAsync(user, MFAService.SMS_ID_CLAIM, id); break; } } public async Task VerifyOTPAsync(User user, string code, string method) { if (method == MFAMethods.SMS) { var id = GetClaimValue(user, MFAService.SMS_ID_CLAIM); if (string.IsNullOrEmpty(id)) throw new Exception("Could not find associated SMS verify id. Please try sending the code again."); if (await SMSSender.VerifyOTPAsync(id, code)) { // Auto confirm user phone number if not confirmed if (!await UserManager.IsPhoneNumberConfirmedAsync(user)) { ArgumentNullException.ThrowIfNull(user.PhoneNumber); var token = await UserManager.GenerateChangePhoneNumberTokenAsync(user, user.PhoneNumber); await UserManager.VerifyChangePhoneNumberTokenAsync(user, token, user.PhoneNumber); } await this.RemoveClaimAsync(user, MFAService.SMS_ID_CLAIM); return true; } return false; } else if (method == MFAMethods.Email) { if (await UserManager.VerifyTwoFactorTokenAsync(user, GetProvider(method), code)) { // Auto confirm user email if not confirmed if (!await UserManager.IsEmailConfirmedAsync(user)) { var token = await UserManager.GenerateEmailConfirmationTokenAsync(user); await UserManager.ConfirmEmailAsync(user, token); } return true; } return false; } else return await UserManager.VerifyTwoFactorTokenAsync(user, GetProvider(method), code); } private string GetProvider(string method) { return method == MFAMethods.Email || method == MFAMethods.SMS ? TokenOptions.DefaultPhoneProvider : UserManager.Options.Tokens.AuthenticatorTokenProvider; } private static string FormatKey(string unformattedKey) { var result = new StringBuilder(); int currentPosition = 0; while (currentPosition + 4 < unformattedKey.Length) { result.Append(unformattedKey.Substring(currentPosition, 4)).Append(" "); currentPosition += 4; } if (currentPosition < unformattedKey.Length) { result.Append(unformattedKey.Substring(currentPosition)); } return result.ToString().ToLowerInvariant(); } private static string GenerateQrCodeUri(string email, string unformattedKey, string issuer) { const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6"; return string.Format( AuthenticatorUriFormat, UrlEncoder.Default.Encode(issuer), UrlEncoder.Default.Encode(email), unformattedKey); } } }