diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props index 89faa3f3c7..30ebb40fdd 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props @@ -9,6 +9,7 @@ + diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Boilerplate.Server.Api.csproj b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Boilerplate.Server.Api.csproj index 2762b17f1c..ce557d156f 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Boilerplate.Server.Api.csproj +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Boilerplate.Server.Api.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.PhoneConfirmation.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.PhoneConfirmation.cs index a888a3af38..eb8bdcff8a 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.PhoneConfirmation.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.PhoneConfirmation.cs @@ -8,11 +8,12 @@ namespace Boilerplate.Server.Api.Controllers.Identity; public partial class IdentityController { - [AutoInject] private SmsService smsService = default!; + [AutoInject] private PhoneService phoneService = default!; [HttpPost] public async Task SendConfirmPhoneToken(SendPhoneTokenRequestDto request, CancellationToken cancellationToken) { + request.PhoneNumber = phoneService.NormalizePhoneNumber(request.PhoneNumber); var user = await userManager.FindByPhoneNumber(request.PhoneNumber!) ?? throw new BadRequestException(Localizer[nameof(AppStrings.UserNotFound)]); @@ -25,6 +26,7 @@ public async Task SendConfirmPhoneToken(SendPhoneTokenRequestDto request, Cancel [HttpPost, Produces()] public async Task ConfirmPhone(ConfirmPhoneRequestDto request, CancellationToken cancellationToken) { + request.PhoneNumber = phoneService.NormalizePhoneNumber(request.PhoneNumber); var user = await userManager.FindByPhoneNumber(request.PhoneNumber!) ?? throw new BadRequestException(Localizer[nameof(AppStrings.UserNotFound)]); @@ -77,6 +79,6 @@ private async Task SendConfirmPhoneToken(User user, CancellationToken cancellati var token = await userManager.GenerateUserTokenAsync(user, TokenOptions.DefaultPhoneProvider, FormattableString.Invariant($"VerifyPhoneNumber:{phoneNumber},{user.PhoneNumberTokenRequestedOn?.ToUniversalTime()}")); var link = new Uri(HttpContext.Request.GetWebClientUrl(), $"{Urls.ConfirmPage}?phoneNumber={Uri.EscapeDataString(phoneNumber!)}&phoneToken={Uri.EscapeDataString(token)}&culture={CultureInfo.CurrentUICulture.Name}"); - await smsService.SendSms(Localizer[nameof(AppStrings.ConfirmPhoneTokenSmsText), token], phoneNumber, cancellationToken); + await phoneService.SendSms(Localizer[nameof(AppStrings.ConfirmPhoneTokenSmsText), token], phoneNumber, cancellationToken); } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.ResetPassword.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.ResetPassword.cs index 0224a83a05..24846da43f 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.ResetPassword.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.ResetPassword.cs @@ -13,6 +13,7 @@ public partial class IdentityController [HttpPost] public async Task SendResetPasswordToken(SendResetPasswordTokenRequestDto request, CancellationToken cancellationToken) { + request.PhoneNumber = phoneService.NormalizePhoneNumber(request.PhoneNumber); var user = await userManager.FindUserAsync(request) ?? throw new ResourceNotFoundException(Localizer[nameof(AppStrings.UserNotFound)]); @@ -48,7 +49,7 @@ public async Task SendResetPasswordToken(SendResetPasswordTokenRequestDto reques if (await userManager.IsPhoneNumberConfirmedAsync(user)) { - sendMessagesTasks.Add(smsService.SendSms(message, user.PhoneNumber!, cancellationToken)); + sendMessagesTasks.Add(phoneService.SendSms(message, user.PhoneNumber!, cancellationToken)); } //#if (signalr == true) @@ -65,6 +66,7 @@ public async Task SendResetPasswordToken(SendResetPasswordTokenRequestDto reques [HttpPost] public async Task ResetPassword(ResetPasswordRequestDto request, CancellationToken cancellationToken) { + request.PhoneNumber = phoneService.NormalizePhoneNumber(request.PhoneNumber); var user = await userManager.FindUserAsync(request) ?? throw new ResourceNotFoundException(Localizer[nameof(AppStrings.UserNotFound)]); var expired = (DateTimeOffset.Now - user.ResetPasswordTokenRequestedOn) > AppSettings.Identity.ResetPasswordTokenLifetime; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.SocialSignIn.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.SocialSignIn.cs index 5d570e3e7b..abbaa6f0a4 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.SocialSignIn.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.SocialSignIn.cs @@ -34,7 +34,7 @@ public async Task SocialSignInCallback(string? returnUrl = null, i try { var email = info.Principal.GetEmail(); - var phoneNumber = info.Principal.Claims.FirstOrDefault(c => c.Type is ClaimTypes.HomePhone or ClaimTypes.MobilePhone or ClaimTypes.OtherPhone)?.Value; + var phoneNumber = phoneService.NormalizePhoneNumber(info.Principal.Claims.FirstOrDefault(c => c.Type is ClaimTypes.HomePhone or ClaimTypes.MobilePhone or ClaimTypes.OtherPhone)?.Value); var user = await userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.cs index 190e8faf9f..5c86ee8042 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.cs @@ -9,6 +9,7 @@ using Boilerplate.Shared.Dtos.Identity; using Boilerplate.Server.Api.Models.Identity; using Boilerplate.Shared.Controllers.Identity; +using Boilerplate.Server.Api.Services.Identity; namespace Boilerplate.Server.Api.Controllers.Identity; @@ -43,6 +44,7 @@ public partial class IdentityController : AppControllerBase, IIdentityController [HttpPost] public async Task SignUp(SignUpRequestDto request, CancellationToken cancellationToken) { + request.PhoneNumber = phoneService.NormalizePhoneNumber(request.PhoneNumber); //#if (captcha == "reCaptcha") if (await googleRecaptchaHttpClient.Verify(request.GoogleRecaptchaResponse, cancellationToken) is false) throw new BadRequestException(Localizer[nameof(AppStrings.InvalidGoogleRecaptchaResponse)]); @@ -88,6 +90,7 @@ public async Task SignUp(SignUpRequestDto request, CancellationToken cancellatio [HttpPost, Produces()] public async Task SignIn(SignInRequestDto request, CancellationToken cancellationToken) { + request.PhoneNumber = phoneService.NormalizePhoneNumber(request.PhoneNumber); signInManager.AuthenticationScheme = IdentityConstants.BearerScheme; var user = await userManager.FindUserAsync(request) ?? throw new UnauthorizedException(Localizer[nameof(AppStrings.InvalidUserCredentials)]); @@ -239,6 +242,7 @@ await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not U [HttpPost] public async Task SendOtp(IdentityRequestDto request, string? returnUrl = null, CancellationToken cancellationToken = default) { + request.PhoneNumber = phoneService.NormalizePhoneNumber(request.PhoneNumber); var user = await userManager.FindUserAsync(request) ?? throw new ResourceNotFoundException(Localizer[nameof(AppStrings.UserNotFound)]); @@ -264,7 +268,7 @@ public async Task SendOtp(IdentityRequestDto request, string? returnUrl = null, if (await userManager.IsPhoneNumberConfirmedAsync(user)) { var smsMessage = Localizer[nameof(AppStrings.OtpShortText), await userManager.GenerateUserTokenAsync(user, TokenOptions.DefaultPhoneProvider, FormattableString.Invariant($"Otp_Sms,{user.OtpRequestedOn?.ToUniversalTime()}"))].ToString(); - sendMessagesTasks.Add(smsService.SendSms(smsMessage, user.PhoneNumber!, cancellationToken)); + sendMessagesTasks.Add(phoneService.SendSms(smsMessage, user.PhoneNumber!, cancellationToken)); } //#if (signalr == true) @@ -283,6 +287,7 @@ public async Task SendOtp(IdentityRequestDto request, string? returnUrl = null, [HttpPost] public async Task SendTwoFactorToken(SignInRequestDto request, CancellationToken cancellationToken) { + request.PhoneNumber = phoneService.NormalizePhoneNumber(request.PhoneNumber); var user = await userManager.FindUserAsync(request) ?? throw new ResourceNotFoundException(Localizer[nameof(AppStrings.UserNotFound)]); if (user.TwoFactorEnabled is false) @@ -320,7 +325,7 @@ public async Task SendTwoFactorToken(SignInRequestDto request, CancellationToken if (firstStepAuthenticationMethod != "Sms" && await userManager.IsPhoneNumberConfirmedAsync(user)) { - sendMessagesTasks.Add(smsService.SendSms(message, user.PhoneNumber!, cancellationToken)); + sendMessagesTasks.Add(phoneService.SendSms(message, user.PhoneNumber!, cancellationToken)); } //#if (signalr == true) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.cs index 9a4318feac..986753a196 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.cs @@ -1,7 +1,7 @@ using System.Text; using System.Text.Encodings.Web; -using Humanizer; using QRCoder; +using Humanizer; using Boilerplate.Server.Api.Services; using Boilerplate.Shared.Dtos.Identity; using Boilerplate.Server.Api.Models.Identity; @@ -18,7 +18,7 @@ public partial class UserController : AppControllerBase, IUserController [AutoInject] private IUserEmailStore userEmailStore = default!; - [AutoInject] private SmsService smsService = default!; + [AutoInject] private PhoneService phoneService = default!; [AutoInject] private EmailService emailService = default!; @@ -218,6 +218,7 @@ public async Task ChangeEmail(ChangeEmailRequestDto request, CancellationToken c [HttpPost] public async Task SendChangePhoneNumberToken(SendPhoneTokenRequestDto request, CancellationToken cancellationToken) { + request.PhoneNumber = phoneService.NormalizePhoneNumber(request.PhoneNumber); var user = await userManager.FindByIdAsync(User.GetUserId().ToString()); var resendDelay = (DateTimeOffset.Now - user!.PhoneNumberTokenRequestedOn) - AppSettings.Identity.PhoneNumberTokenLifetime; @@ -233,12 +234,13 @@ public async Task SendChangePhoneNumberToken(SendPhoneTokenRequestDto request, C var token = await userManager.GenerateChangePhoneNumberTokenAsync(user!, request.PhoneNumber!); - await smsService.SendSms(Localizer[nameof(AppStrings.ChangePhoneNumberTokenSmsText), token], request.PhoneNumber!, cancellationToken); + await phoneService.SendSms(Localizer[nameof(AppStrings.ChangePhoneNumberTokenSmsText), token], request.PhoneNumber!, cancellationToken); } [HttpPost] public async Task ChangePhoneNumber(ChangePhoneNumberRequestDto request, CancellationToken cancellationToken) { + request.PhoneNumber = phoneService.NormalizePhoneNumber(request.PhoneNumber); var user = await userManager.FindByIdAsync(User.GetUserId().ToString()); var expired = (DateTimeOffset.Now - user!.PhoneNumberTokenRequestedOn) > AppSettings.Identity.PhoneNumberTokenLifetime; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs index 29376e3c18..e33ac8a191 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.ResponseCompression; using Twilio; +using PhoneNumbers; using FluentStorage; using FluentStorage.Blobs; //#if (notification == true) @@ -19,6 +20,7 @@ using Boilerplate.Server.Api.Services; using Boilerplate.Server.Api.Controllers; using Boilerplate.Server.Api.Models.Identity; +using Boilerplate.Server.Api.Services.Identity; namespace Boilerplate.Server.Api; @@ -195,7 +197,7 @@ void AddDbContext(DbContextOptionsBuilder options) } services.AddTransient(); - services.AddTransient(); + services.AddTransient(); if (appSettings.Sms.Configured) { TwilioClient.Init(appSettings.Sms.TwilioAccountSid, appSettings.Sms.TwilioAutoToken); @@ -230,6 +232,8 @@ void AddDbContext(DbContextOptionsBuilder options) services.AddAdsPush(configuration); services.AddTransient(); //#endif + + services.AddSingleton(_ => PhoneNumberUtil.GetInstance()); } private static void AddIdentity(WebApplicationBuilder builder) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/AppIdentityErrorDescriber.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Identity/AppIdentityErrorDescriber.cs similarity index 98% rename from src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/AppIdentityErrorDescriber.cs rename to src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Identity/AppIdentityErrorDescriber.cs index 9792e886e1..653f1e4f7b 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/AppIdentityErrorDescriber.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Identity/AppIdentityErrorDescriber.cs @@ -1,6 +1,6 @@ using System.Data; -namespace Boilerplate.Server.Api.Services; +namespace Boilerplate.Server.Api.Services.Identity; public partial class AppIdentityErrorDescriber : IdentityErrorDescriber { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/AppSecureJwtDataFormat.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Identity/AppSecureJwtDataFormat.cs similarity index 97% rename from src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/AppSecureJwtDataFormat.cs rename to src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Identity/AppSecureJwtDataFormat.cs index 56fe5ecdd7..77f23f832e 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/AppSecureJwtDataFormat.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Identity/AppSecureJwtDataFormat.cs @@ -2,7 +2,7 @@ using Microsoft.IdentityModel.Tokens; using Microsoft.AspNetCore.Authentication; -namespace Boilerplate.Server.Api.Services; +namespace Boilerplate.Server.Api.Services.Identity; /// /// Stores bearer token in jwt format diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/AppUserClaimsPrincipalFactory.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Identity/AppUserClaimsPrincipalFactory.cs similarity index 93% rename from src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/AppUserClaimsPrincipalFactory.cs rename to src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Identity/AppUserClaimsPrincipalFactory.cs index fb8ddb7f98..1b9a6f5146 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/AppUserClaimsPrincipalFactory.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Identity/AppUserClaimsPrincipalFactory.cs @@ -1,6 +1,6 @@ using Boilerplate.Server.Api.Models.Identity; -namespace Boilerplate.Server.Api.Services; +namespace Boilerplate.Server.Api.Services.Identity; public partial class AppUserClaimsPrincipalFactory(UserManager userManager, IOptions optionsAccessor) : UserClaimsPrincipalFactory(userManager, optionsAccessor) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/AppUserConfirmation.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Identity/AppUserConfirmation.cs similarity index 84% rename from src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/AppUserConfirmation.cs rename to src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Identity/AppUserConfirmation.cs index 14a4f64f87..7113b30613 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/AppUserConfirmation.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Identity/AppUserConfirmation.cs @@ -1,6 +1,6 @@ using Boilerplate.Server.Api.Models.Identity; -namespace Boilerplate.Server.Api.Services; +namespace Boilerplate.Server.Api.Services.Identity; public partial class AppUserConfirmation : IUserConfirmation { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/SmsService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/PhoneService.cs similarity index 51% rename from src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/SmsService.cs rename to src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/PhoneService.cs index f992c9896c..52fe8e48c7 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/SmsService.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/PhoneService.cs @@ -1,12 +1,33 @@ -using Twilio.Rest.Api.V2010.Account; +using PhoneNumbers; +using Twilio.Rest.Api.V2010.Account; namespace Boilerplate.Server.Api.Services; -public partial class SmsService +public partial class PhoneService { [AutoInject] private readonly AppSettings appSettings = default!; - [AutoInject] private readonly ILogger logger = default!; + [AutoInject] private readonly ILogger logger = default!; [AutoInject] private readonly IHostEnvironment hostEnvironment = default!; + [AutoInject] private readonly IHttpContextAccessor httpContextAccessor = default!; + [AutoInject] private readonly PhoneNumberUtil phoneNumberUtil = default!; + private const string APP_DEFAULT_REGION = "US" /*Two letter ISO region name*/; + + public string? NormalizePhoneNumber(string? phoneNumber) + { + if (string.IsNullOrEmpty(phoneNumber)) + return null; + + // Get region from Cloudflare "CF-IPCountry" header if available, otherwise use UI culture's region if multilingual is enabled, or fallback to the default region. + var region = httpContextAccessor.HttpContext!.Request.Headers.TryGetValue("CF-IPCountry", out var value) + ? value.ToString() + : CultureInfoManager.MultilingualEnabled + ? new RegionInfo(CultureInfo.CurrentUICulture.Name).TwoLetterISORegionName + : APP_DEFAULT_REGION; + + var parsedPhoneNumber = phoneNumberUtil.Parse(phoneNumber, region); + + return phoneNumberUtil.Format(parsedPhoneNumber, PhoneNumberFormat.E164); + } public async Task SendSms(string messageText, string phoneNumber, CancellationToken cancellationToken) {