From 158f59947b09a1117e88dbd6eead215d2fc04d72 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Tue, 30 Apr 2024 01:18:38 -0400 Subject: [PATCH 1/4] Implementing profile phone. --- .../Accounts/Commands/ChangePhoneCommand.cs | 6 + .../Commands/ChangePhoneCommandHandler.cs | 113 ++++++++++++++++++ .../Accounts/Commands/SignInCommandHandler.cs | 15 ++- .../Accounts/IMessageService.cs | 1 + .../Accounts/IOneTimePasswordService.cs | 1 + .../Accounts/ITokenService.cs | 2 + .../Accounts/IUserService.cs | 1 + .../Accounts/OneTimePasswordExtensions.cs | 29 +++++ .../Logitar.Master.Application/Activity.cs | 14 +++ .../Constants/Claims.cs | 6 + .../Constants/TokenTypes.cs | 7 ++ .../Logitar.Master.Application.csproj | 2 + .../Accounts/ChangePhonePayload.cs | 31 +++++ .../Accounts/ChangePhoneResult.cs | 32 +++++ .../IdentityServices/MessageService.cs | 10 ++ .../OneTimePasswordService.cs | 4 + .../IdentityServices/TokenService.cs | 11 +- .../IdentityServices/UserService.cs | 10 ++ .../src/Logitar.Master/Logitar.Master.csproj | 1 - 19 files changed, 286 insertions(+), 10 deletions(-) create mode 100644 backend/src/Logitar.Master.Application/Accounts/Commands/ChangePhoneCommand.cs create mode 100644 backend/src/Logitar.Master.Application/Accounts/Commands/ChangePhoneCommandHandler.cs create mode 100644 backend/src/Logitar.Master.Application/Constants/Claims.cs create mode 100644 backend/src/Logitar.Master.Application/Constants/TokenTypes.cs create mode 100644 backend/src/Logitar.Master.Contracts/Accounts/ChangePhonePayload.cs create mode 100644 backend/src/Logitar.Master.Contracts/Accounts/ChangePhoneResult.cs diff --git a/backend/src/Logitar.Master.Application/Accounts/Commands/ChangePhoneCommand.cs b/backend/src/Logitar.Master.Application/Accounts/Commands/ChangePhoneCommand.cs new file mode 100644 index 0000000..1eae9bb --- /dev/null +++ b/backend/src/Logitar.Master.Application/Accounts/Commands/ChangePhoneCommand.cs @@ -0,0 +1,6 @@ +using Logitar.Master.Contracts.Accounts; +using MediatR; + +namespace Logitar.Master.Application.Accounts.Commands; + +public record ChangePhoneCommand(ChangePhonePayload Payload) : Activity, IRequest; diff --git a/backend/src/Logitar.Master.Application/Accounts/Commands/ChangePhoneCommandHandler.cs b/backend/src/Logitar.Master.Application/Accounts/Commands/ChangePhoneCommandHandler.cs new file mode 100644 index 0000000..57ba281 --- /dev/null +++ b/backend/src/Logitar.Master.Application/Accounts/Commands/ChangePhoneCommandHandler.cs @@ -0,0 +1,113 @@ +using Logitar.Master.Application.Constants; +using Logitar.Master.Contracts.Accounts; +using Logitar.Portal.Contracts.Messages; +using Logitar.Portal.Contracts.Passwords; +using Logitar.Portal.Contracts.Tokens; +using Logitar.Portal.Contracts.Users; +using Logitar.Security.Claims; +using MediatR; + +namespace Logitar.Master.Application.Accounts.Commands; + +internal class ChangePhoneCommandHandler : IRequestHandler +{ + private const string ContactVerificationPurpose = "ContactVerification"; + private const string ContactVerificationTemplate = "ContactVerification{ContactType}"; + + private readonly IMessageService _messageService; + private readonly IOneTimePasswordService _oneTimePasswordService; + private readonly ITokenService _tokenService; + private readonly IUserService _userService; + + public ChangePhoneCommandHandler(IMessageService messageService, IOneTimePasswordService oneTimePasswordService, + ITokenService tokenService, IUserService userService) + { + _messageService = messageService; + _oneTimePasswordService = oneTimePasswordService; + _tokenService = tokenService; + _userService = userService; + } + + public async Task Handle(ChangePhoneCommand command, CancellationToken cancellationToken) + { + ChangePhonePayload payload = command.Payload; // TODO(fpion): validate payload ; ProfileCompletionToken is required if command.User == null + + if (payload.Phone != null) + { + return await HandlePhoneAsync(payload.Phone, payload.Locale, payload.ProfileCompletionToken, command.User, cancellationToken); + } + else if (payload.OneTimePassword != null) + { + return await HandleOneTimePasswordAsync(payload.OneTimePassword, payload.ProfileCompletionToken, command.User, cancellationToken); + } + + throw new ArgumentException($"The '{nameof(command)}.{nameof(command.Payload)}' is not valid.", nameof(command)); + } + + private async Task HandlePhoneAsync(AccountPhone phone, string locale, string? profileCompletionToken, User? user, CancellationToken cancellationToken) + { + if (user == null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(profileCompletionToken, nameof(profileCompletionToken)); + user = await FindUserAsync(profileCompletionToken, cancellationToken); + } + + Phone contact = new(phone.CountryCode, phone.Number, extension: null, e164Formatted: "TODO"); + OneTimePassword oneTimePassword = await _oneTimePasswordService.CreateAsync(user, contact, ContactVerificationPurpose, cancellationToken); + if (oneTimePassword.Password == null) + { + throw new InvalidOperationException($"The One-Time Password (OTP) 'Id={oneTimePassword.Id}' has no password."); + } + Dictionary variables = new() + { + ["OneTimePassword"] = oneTimePassword.Password + }; + string template = ContactVerificationTemplate.Replace("{ContactType}", ContactType.Phone.ToString()); + SentMessages sentMessages = await _messageService.SendAsync(template, contact, locale, variables, cancellationToken); + SentMessage sentMessage = sentMessages.ToSentMessage(contact); + OneTimePasswordValidation oneTimePasswordValidation = new(oneTimePassword, sentMessage); + + return new ChangePhoneResult(oneTimePasswordValidation); + } + + private async Task HandleOneTimePasswordAsync(OneTimePasswordPayload payload, string? profileCompletionToken, User? user, CancellationToken cancellationToken) + { + if (user == null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(profileCompletionToken, nameof(profileCompletionToken)); + user = await FindUserAsync(profileCompletionToken, cancellationToken); + } + + OneTimePassword oneTimePassword = await _oneTimePasswordService.ValidateAsync(payload, ContactVerificationPurpose, cancellationToken); + Phone phone = oneTimePassword.GetPhone(); + + if (profileCompletionToken == null) + { + user = await _userService.UpdatePhoneAsync(user.Id, phone, cancellationToken); + return new ChangePhoneResult(user.ToUserProfile()); + } + + List claims = new(capacity: 3) + { + new(Rfc7519ClaimNames.PhoneNumber, phone.Number), + new(Rfc7519ClaimNames.IsPhoneVerified, true.ToString().ToLower(), ClaimValueTypes.Boolean) + }; + if (phone.CountryCode != null) + { + claims.Add(new(Claims.PhoneCountryCode, phone.CountryCode)); + } + CreatedToken createdToken = await _tokenService.CreateAsync(user.GetSubject(), claims, TokenTypes.Profile, cancellationToken); + return new ChangePhoneResult(createdToken); + } + + private async Task FindUserAsync(string profileCompletionToken, CancellationToken cancellationToken) + { + ValidatedToken validatedToken = await _tokenService.ValidateAsync(profileCompletionToken, consume: false, TokenTypes.Profile, cancellationToken); + if (validatedToken.Subject == null) + { + throw new InvalidOperationException($"The '{validatedToken.Subject}' claim is required."); + } + Guid userId = Guid.Parse(validatedToken.Subject); + return await _userService.FindAsync(userId, cancellationToken) ?? throw new InvalidOperationException($"The user 'Id={userId}' could not be found."); + } +} diff --git a/backend/src/Logitar.Master.Application/Accounts/Commands/SignInCommandHandler.cs b/backend/src/Logitar.Master.Application/Accounts/Commands/SignInCommandHandler.cs index fb3e311..c98f2fa 100644 --- a/backend/src/Logitar.Master.Application/Accounts/Commands/SignInCommandHandler.cs +++ b/backend/src/Logitar.Master.Application/Accounts/Commands/SignInCommandHandler.cs @@ -1,4 +1,5 @@ using Logitar.Master.Application.Accounts.Events; +using Logitar.Master.Application.Constants; using Logitar.Master.Contracts.Accounts; using Logitar.Portal.Contracts; using Logitar.Portal.Contracts.Messages; @@ -12,11 +13,9 @@ namespace Logitar.Master.Application.Accounts.Commands; internal class SignInCommandHandler : IRequestHandler { - private const string AuthenticationTokenType = "auth+jwt"; private const string MultiFactorAuthenticationPurpose = "MultiFactorAuthentication"; private const string MultiFactorAuthenticationTemplate = "MultiFactorAuthentication{ContactType}"; private const string PasswordlessTemplate = "AccountAuthentication"; - private const string ProfileTokenType = "profile+jwt"; private readonly IMessageService _messageService; private readonly IOneTimePasswordService _oneTimePasswordService; @@ -57,7 +56,7 @@ public async Task Handle(SignInCommand command, Cancellatio return await CompleteProfileAsync(payload.Profile, command.CustomAttributes, cancellationToken); } - throw new InvalidOperationException($"The {nameof(SignInPayload)} is not valid."); + throw new ArgumentException($"The '{nameof(command)}.{nameof(command.Payload)}' is not valid.", nameof(command)); } private async Task HandleCredentialsAsync(Credentials credentials, string locale, IEnumerable customAttributes, CancellationToken cancellationToken) @@ -66,7 +65,7 @@ private async Task HandleCredentialsAsync(Credentials crede if (user == null || !user.HasPassword) { Email email = user?.Email ?? new(credentials.EmailAddress); - CreatedToken token = await _tokenService.CreateAsync(user?.GetSubject(), email, AuthenticationTokenType, cancellationToken); + CreatedToken token = await _tokenService.CreateAsync(user?.GetSubject(), email, TokenTypes.Authentication, cancellationToken); Dictionary variables = new() { ["Token"] = token.Token @@ -126,7 +125,7 @@ private async Task SendMultiFactorAuthenticationMessageAsyn private async Task HandleAuthenticationTokenAsync(string token, IEnumerable customAttributes, CancellationToken cancellationToken) { - ValidatedToken validatedToken = await _tokenService.ValidateAsync(token, AuthenticationTokenType, cancellationToken); + ValidatedToken validatedToken = await _tokenService.ValidateAsync(token, TokenTypes.Authentication, cancellationToken); Email? email = validatedToken.Email == null ? null : new(validatedToken.Email.Address) { IsVerified = true @@ -167,10 +166,10 @@ private async Task HandleOneTimePasswordAsync(OneTimePasswo private async Task CompleteProfileAsync(CompleteProfilePayload payload, IEnumerable customAttributes, CancellationToken cancellationToken) { - ValidatedToken validatedToken = await _tokenService.ValidateAsync(payload.Token, ProfileTokenType, cancellationToken); + ValidatedToken validatedToken = await _tokenService.ValidateAsync(payload.Token, TokenTypes.Profile, cancellationToken); if (validatedToken.Subject == null) { - throw new InvalidOperationException($"The claim '{validatedToken.Subject}' is required."); + throw new InvalidOperationException($"The '{validatedToken.Subject}' claim is required."); } Guid userId = Guid.Parse(validatedToken.Subject); User user = await _userService.FindAsync(userId, cancellationToken) ?? throw new InvalidOperationException($"The user 'Id={userId}' could not be found."); @@ -183,7 +182,7 @@ private async Task EnsureProfileIsCompleted(User user, IEnu { if (!user.IsProfileCompleted()) { - CreatedToken token = await _tokenService.CreateAsync(user.GetSubject(), ProfileTokenType, cancellationToken); + CreatedToken token = await _tokenService.CreateAsync(user.GetSubject(), TokenTypes.Profile, cancellationToken); return SignInCommandResult.RequireProfileCompletion(token); } diff --git a/backend/src/Logitar.Master.Application/Accounts/IMessageService.cs b/backend/src/Logitar.Master.Application/Accounts/IMessageService.cs index 2813006..33353ae 100644 --- a/backend/src/Logitar.Master.Application/Accounts/IMessageService.cs +++ b/backend/src/Logitar.Master.Application/Accounts/IMessageService.cs @@ -6,5 +6,6 @@ namespace Logitar.Master.Application.Accounts; public interface IMessageService { Task SendAsync(string template, Email email, string? locale = null, Dictionary? variables = null, CancellationToken cancellationToken = default); + Task SendAsync(string template, Phone phone, string? locale = null, Dictionary? variables = null, CancellationToken cancellationToken = default); Task SendAsync(string template, User user, string? locale = null, Dictionary? variables = null, CancellationToken cancellationToken = default); } diff --git a/backend/src/Logitar.Master.Application/Accounts/IOneTimePasswordService.cs b/backend/src/Logitar.Master.Application/Accounts/IOneTimePasswordService.cs index caa51b4..c6cb28d 100644 --- a/backend/src/Logitar.Master.Application/Accounts/IOneTimePasswordService.cs +++ b/backend/src/Logitar.Master.Application/Accounts/IOneTimePasswordService.cs @@ -7,5 +7,6 @@ namespace Logitar.Master.Application.Accounts; public interface IOneTimePasswordService { Task CreateAsync(User user, string purpose, CancellationToken cancellationToken = default); + Task CreateAsync(User user, Phone phone, string purpose, CancellationToken cancellationToken = default); Task ValidateAsync(OneTimePasswordPayload payload, string purpose, CancellationToken cancellationToken = default); } diff --git a/backend/src/Logitar.Master.Application/Accounts/ITokenService.cs b/backend/src/Logitar.Master.Application/Accounts/ITokenService.cs index dbcb145..b59a82c 100644 --- a/backend/src/Logitar.Master.Application/Accounts/ITokenService.cs +++ b/backend/src/Logitar.Master.Application/Accounts/ITokenService.cs @@ -7,5 +7,7 @@ public interface ITokenService { Task CreateAsync(string? subject, string type, CancellationToken cancellationToken = default); Task CreateAsync(string? subject, Email? email, string type, CancellationToken cancellationToken = default); + Task CreateAsync(string? subject, IEnumerable claims, string type, CancellationToken cancellationToken = default); Task ValidateAsync(string token, string type, CancellationToken cancellationToken = default); + Task ValidateAsync(string token, bool consume, string type, CancellationToken cancellationToken = default); } diff --git a/backend/src/Logitar.Master.Application/Accounts/IUserService.cs b/backend/src/Logitar.Master.Application/Accounts/IUserService.cs index 31be19b..0b2f1c3 100644 --- a/backend/src/Logitar.Master.Application/Accounts/IUserService.cs +++ b/backend/src/Logitar.Master.Application/Accounts/IUserService.cs @@ -12,4 +12,5 @@ public interface IUserService Task FindAsync(Guid id, CancellationToken cancellationToken = default); Task SaveProfileAsync(Guid userId, SaveProfilePayload payload, CancellationToken cancellationToken = default); Task UpdateEmailAsync(Guid userId, Email email, CancellationToken cancellationToken = default); + Task UpdatePhoneAsync(Guid userId, Phone phone, CancellationToken cancellationToken = default); } diff --git a/backend/src/Logitar.Master.Application/Accounts/OneTimePasswordExtensions.cs b/backend/src/Logitar.Master.Application/Accounts/OneTimePasswordExtensions.cs index 3c2d1ec..0b75b87 100644 --- a/backend/src/Logitar.Master.Application/Accounts/OneTimePasswordExtensions.cs +++ b/backend/src/Logitar.Master.Application/Accounts/OneTimePasswordExtensions.cs @@ -6,6 +6,9 @@ namespace Logitar.Master.Application.Accounts; public static class OneTimePasswordExtensions { + private const string PhoneCountryCodeKey = "PhoneCountryCode"; + private const string PhoneE164FormattedKey = "PhoneE164Formatted"; + private const string PhoneNumberKey = "PhoneNumber"; private const string PurposeKey = "Purpose"; private const string UserIdKey = "UserId"; @@ -29,6 +32,32 @@ public static void SetUserId(this CreateOneTimePasswordPayload payload, User use } } + public static Phone GetPhone(this OneTimePassword oneTimePassword) + { + Phone? phone = oneTimePassword.TryGetPhone(); + return phone ?? throw new InvalidOperationException($"The One-Time Password (OTP) has no phone custom attributes."); + } + public static Phone? TryGetPhone(this OneTimePassword oneTimePassword) + { + Phone phone = new(); + foreach (CustomAttribute customAttribute in oneTimePassword.CustomAttributes) + { + switch (customAttribute.Key) + { + case PhoneCountryCodeKey: + phone.CountryCode = customAttribute.Value; + break; + case PhoneE164FormattedKey: + phone.E164Formatted = customAttribute.Value; + break; + case PhoneNumberKey: + phone.Number = customAttribute.Value; + break; + } + } + return (phone.Number == null || phone.E164Formatted == null) ? null : phone; + } + public static void EnsurePurpose(this OneTimePassword oneTimePassword, string purpose) { if (!oneTimePassword.HasPurpose(purpose)) diff --git a/backend/src/Logitar.Master.Application/Activity.cs b/backend/src/Logitar.Master.Application/Activity.cs index 72a3d7e..e6ce43c 100644 --- a/backend/src/Logitar.Master.Application/Activity.cs +++ b/backend/src/Logitar.Master.Application/Activity.cs @@ -1,5 +1,6 @@ using Logitar.EventSourcing; using Logitar.Portal.Contracts.Actors; +using Logitar.Portal.Contracts.Users; namespace Logitar.Master.Application; @@ -32,6 +33,19 @@ public Actor Actor } public ActorId ActorId => new(Actor.Id); + public User? User + { + get + { + if (_context == null) + { + throw new InvalidOperationException($"The activity has been been contextualized yet. You must call the '{nameof(Contextualize)}' method."); + } + + return _context.User; + } + } + public void Contextualize(ActivityContext context) { if (_context != null) diff --git a/backend/src/Logitar.Master.Application/Constants/Claims.cs b/backend/src/Logitar.Master.Application/Constants/Claims.cs new file mode 100644 index 0000000..22e3615 --- /dev/null +++ b/backend/src/Logitar.Master.Application/Constants/Claims.cs @@ -0,0 +1,6 @@ +namespace Logitar.Master.Application.Constants; + +internal static class Claims +{ + public const string PhoneCountryCode = "phone_country"; +} diff --git a/backend/src/Logitar.Master.Application/Constants/TokenTypes.cs b/backend/src/Logitar.Master.Application/Constants/TokenTypes.cs new file mode 100644 index 0000000..b71ed73 --- /dev/null +++ b/backend/src/Logitar.Master.Application/Constants/TokenTypes.cs @@ -0,0 +1,7 @@ +namespace Logitar.Master.Application.Constants; + +internal static class TokenTypes +{ + public const string Authentication = "auth+jwt"; + public const string Profile = "profile+jwt"; +} diff --git a/backend/src/Logitar.Master.Application/Logitar.Master.Application.csproj b/backend/src/Logitar.Master.Application/Logitar.Master.Application.csproj index 5868bc0..f019d8b 100644 --- a/backend/src/Logitar.Master.Application/Logitar.Master.Application.csproj +++ b/backend/src/Logitar.Master.Application/Logitar.Master.Application.csproj @@ -15,6 +15,7 @@ + @@ -25,6 +26,7 @@ + diff --git a/backend/src/Logitar.Master.Contracts/Accounts/ChangePhonePayload.cs b/backend/src/Logitar.Master.Contracts/Accounts/ChangePhonePayload.cs new file mode 100644 index 0000000..d4dd674 --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Accounts/ChangePhonePayload.cs @@ -0,0 +1,31 @@ +namespace Logitar.Master.Contracts.Accounts; + +public record ChangePhonePayload +{ + public string Locale { get; set; } + + public AccountPhone? Phone { get; set; } + public OneTimePasswordPayload? OneTimePassword { get; set; } + public string? ProfileCompletionToken { get; set; } + + public ChangePhonePayload() : this(string.Empty) + { + } + + public ChangePhonePayload(string locale) + { + Locale = locale; + } + + public ChangePhonePayload(string locale, AccountPhone phone, string? profileCompletionToken = null) : this(locale) + { + Phone = phone; + ProfileCompletionToken = profileCompletionToken; + } + + public ChangePhonePayload(string locale, OneTimePasswordPayload oneTimePassword, string? profileCompletionToken = null) : this(locale) + { + OneTimePassword = oneTimePassword; + ProfileCompletionToken = profileCompletionToken; + } +} diff --git a/backend/src/Logitar.Master.Contracts/Accounts/ChangePhoneResult.cs b/backend/src/Logitar.Master.Contracts/Accounts/ChangePhoneResult.cs new file mode 100644 index 0000000..6c50902 --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Accounts/ChangePhoneResult.cs @@ -0,0 +1,32 @@ +using Logitar.Portal.Contracts.Tokens; + +namespace Logitar.Master.Contracts.Accounts; + +public record ChangePhoneResult +{ + public OneTimePasswordValidation? OneTimePasswordValidation { get; set; } + public UserProfile? UserProfile { get; set; } + public string? ProfileCompletionToken { get; set; } + + public ChangePhoneResult() + { + } + + public ChangePhoneResult(OneTimePasswordValidation oneTimePasswordValidation) + { + OneTimePasswordValidation = oneTimePasswordValidation; + } + + public ChangePhoneResult(UserProfile userProfile) + { + UserProfile = userProfile; + } + + public ChangePhoneResult(CreatedToken profileCompletion) : this(profileCompletion.Token) + { + } + public ChangePhoneResult(string profileCompletionToken) + { + ProfileCompletionToken = profileCompletionToken; + } +} diff --git a/backend/src/Logitar.Master.Infrastructure/IdentityServices/MessageService.cs b/backend/src/Logitar.Master.Infrastructure/IdentityServices/MessageService.cs index 7613bc5..4d2d1a1 100644 --- a/backend/src/Logitar.Master.Infrastructure/IdentityServices/MessageService.cs +++ b/backend/src/Logitar.Master.Infrastructure/IdentityServices/MessageService.cs @@ -24,6 +24,16 @@ public async Task SendAsync(string template, Email email, string? return await SendAsync(template, recipient, locale, variables, cancellationToken); } + public async Task SendAsync(string template, Phone phone, string? locale, Dictionary? variables, CancellationToken cancellationToken) + { + RecipientPayload recipient = new() + { + Type = RecipientType.To, + PhoneNumber = phone.E164Formatted + }; + return await SendAsync(template, recipient, locale, variables, cancellationToken); + } + public async Task SendAsync(string template, User user, string? locale, Dictionary? variables, CancellationToken cancellationToken) { RecipientPayload recipient = new() diff --git a/backend/src/Logitar.Master.Infrastructure/IdentityServices/OneTimePasswordService.cs b/backend/src/Logitar.Master.Infrastructure/IdentityServices/OneTimePasswordService.cs index 5d2e17c..294586f 100644 --- a/backend/src/Logitar.Master.Infrastructure/IdentityServices/OneTimePasswordService.cs +++ b/backend/src/Logitar.Master.Infrastructure/IdentityServices/OneTimePasswordService.cs @@ -32,6 +32,10 @@ public async Task CreateAsync(User user, string purpose, Cancel RequestContext context = new(user.Id.ToString(), cancellationToken); return await _oneTimePasswordClient.CreateAsync(payload, context); } + public Task CreateAsync(User user, Phone phone, string purpose, CancellationToken cancellationToken) + { + throw new NotImplementedException(); // TODO(fpion): implement + } public async Task ValidateAsync(OneTimePasswordPayload oneTimePasswordPayload, string purpose, CancellationToken cancellationToken) { diff --git a/backend/src/Logitar.Master.Infrastructure/IdentityServices/TokenService.cs b/backend/src/Logitar.Master.Infrastructure/IdentityServices/TokenService.cs index 0cd494d..1d628b2 100644 --- a/backend/src/Logitar.Master.Infrastructure/IdentityServices/TokenService.cs +++ b/backend/src/Logitar.Master.Infrastructure/IdentityServices/TokenService.cs @@ -35,11 +35,20 @@ public async Task CreateAsync(string? subject, Email? email, strin return await _tokenClient.CreateAsync(payload, context); } + public Task CreateAsync(string? subject, IEnumerable claims, string type, CancellationToken cancellationToken) + { + throw new NotImplementedException(); // TODO(fpion): implement + } + public async Task ValidateAsync(string token, string type, CancellationToken cancellationToken) + { + return await ValidateAsync(token, consume: true, type, cancellationToken); + } + public async Task ValidateAsync(string token, bool consume, string type, CancellationToken cancellationToken) { ValidateTokenPayload payload = new(token) { - Consume = true, + Consume = consume, Type = type }; RequestContext context = new(cancellationToken); diff --git a/backend/src/Logitar.Master.Infrastructure/IdentityServices/UserService.cs b/backend/src/Logitar.Master.Infrastructure/IdentityServices/UserService.cs index 974af08..206d375 100644 --- a/backend/src/Logitar.Master.Infrastructure/IdentityServices/UserService.cs +++ b/backend/src/Logitar.Master.Infrastructure/IdentityServices/UserService.cs @@ -88,4 +88,14 @@ public async Task UpdateEmailAsync(Guid userId, Email email, CancellationT RequestContext context = new(userId.ToString(), cancellationToken); return await _userClient.UpdateAsync(userId, payload, context) ?? throw new InvalidOperationException($"The user 'Id={userId}' could not be found."); } + + public async Task UpdatePhoneAsync(Guid userId, Phone phone, CancellationToken cancellationToken) + { + UpdateUserPayload payload = new() + { + Phone = new Modification(new PhonePayload(phone.CountryCode, phone.Number, phone.Extension, phone.IsVerified)) + }; + RequestContext context = new(userId.ToString(), cancellationToken); + return await _userClient.UpdateAsync(userId, payload, context) ?? throw new InvalidOperationException($"The user 'Id={userId}' could not be found."); + } } diff --git a/backend/src/Logitar.Master/Logitar.Master.csproj b/backend/src/Logitar.Master/Logitar.Master.csproj index 23b149e..652865a 100644 --- a/backend/src/Logitar.Master/Logitar.Master.csproj +++ b/backend/src/Logitar.Master/Logitar.Master.csproj @@ -28,7 +28,6 @@ - all From bcd09cda7d9cb7abe2a3a51a3491dfe205a02b8a Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Tue, 30 Apr 2024 01:19:30 -0400 Subject: [PATCH 2/4] TODOs. --- .../Accounts/IOneTimePasswordService.cs | 2 +- .../src/Logitar.Master.Application/Accounts/ITokenService.cs | 2 +- backend/src/Logitar.Master.Application/Accounts/IUserService.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/Logitar.Master.Application/Accounts/IOneTimePasswordService.cs b/backend/src/Logitar.Master.Application/Accounts/IOneTimePasswordService.cs index c6cb28d..132db87 100644 --- a/backend/src/Logitar.Master.Application/Accounts/IOneTimePasswordService.cs +++ b/backend/src/Logitar.Master.Application/Accounts/IOneTimePasswordService.cs @@ -4,7 +4,7 @@ namespace Logitar.Master.Application.Accounts; -public interface IOneTimePasswordService +public interface IOneTimePasswordService // TODO(fpion): refactor { Task CreateAsync(User user, string purpose, CancellationToken cancellationToken = default); Task CreateAsync(User user, Phone phone, string purpose, CancellationToken cancellationToken = default); diff --git a/backend/src/Logitar.Master.Application/Accounts/ITokenService.cs b/backend/src/Logitar.Master.Application/Accounts/ITokenService.cs index b59a82c..b95f050 100644 --- a/backend/src/Logitar.Master.Application/Accounts/ITokenService.cs +++ b/backend/src/Logitar.Master.Application/Accounts/ITokenService.cs @@ -3,7 +3,7 @@ namespace Logitar.Master.Application.Accounts; -public interface ITokenService +public interface ITokenService // TODO(fpion): refactor { Task CreateAsync(string? subject, string type, CancellationToken cancellationToken = default); Task CreateAsync(string? subject, Email? email, string type, CancellationToken cancellationToken = default); diff --git a/backend/src/Logitar.Master.Application/Accounts/IUserService.cs b/backend/src/Logitar.Master.Application/Accounts/IUserService.cs index 0b2f1c3..bda8f96 100644 --- a/backend/src/Logitar.Master.Application/Accounts/IUserService.cs +++ b/backend/src/Logitar.Master.Application/Accounts/IUserService.cs @@ -3,7 +3,7 @@ namespace Logitar.Master.Application.Accounts; -public interface IUserService +public interface IUserService // TODO(fpion): refactor { Task AuthenticateAsync(User user, string password, CancellationToken cancellationToken = default); Task AuthenticateAsync(string uniqueName, string password, CancellationToken cancellationToken = default); From e31079b34fa23d6f9be97ffd001266a2689721b4 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Tue, 30 Apr 2024 01:23:41 -0400 Subject: [PATCH 3/4] Refactor. --- .../Accounts/OneTimePasswordExtensions.cs | 33 +++++++++---------- .../Accounts/UserExtensions.cs | 4 +-- .../OneTimePasswordService.cs | 13 ++++++-- .../IdentityServices/TokenService.cs | 13 ++++++-- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/backend/src/Logitar.Master.Application/Accounts/OneTimePasswordExtensions.cs b/backend/src/Logitar.Master.Application/Accounts/OneTimePasswordExtensions.cs index 0b75b87..c920889 100644 --- a/backend/src/Logitar.Master.Application/Accounts/OneTimePasswordExtensions.cs +++ b/backend/src/Logitar.Master.Application/Accounts/OneTimePasswordExtensions.cs @@ -18,19 +18,7 @@ public static Guid GetUserId(this OneTimePassword oneTimePassword) ?? throw new InvalidOperationException($"The One-Time Password (OTP) has no '{UserIdKey}' custom attribute."); return Guid.Parse(customAttribute.Value); } - public static void SetUserId(this CreateOneTimePasswordPayload payload, User user) - { - CustomAttribute? customAttribute = payload.CustomAttributes.SingleOrDefault(x => x.Key == UserIdKey); - if (customAttribute == null) - { - customAttribute = new(UserIdKey, user.Id.ToString()); - payload.CustomAttributes.Add(customAttribute); - } - else - { - customAttribute.Value = user.Id.ToString(); - } - } + public static void SetUserId(this CreateOneTimePasswordPayload payload, User user) => payload.SetCustomAttribute(UserIdKey, user.Id.ToString()); public static Phone GetPhone(this OneTimePassword oneTimePassword) { @@ -57,6 +45,15 @@ public static Phone GetPhone(this OneTimePassword oneTimePassword) } return (phone.Number == null || phone.E164Formatted == null) ? null : phone; } + public static void SetPhone(this CreateOneTimePasswordPayload payload, Phone phone) // TODO(fpion): unit tests + { + if (phone.CountryCode != null) + { + payload.SetCustomAttribute(PhoneCountryCodeKey, phone.CountryCode); + } + payload.SetCustomAttribute(PhoneNumberKey, phone.Number); + payload.SetCustomAttribute(PhoneE164FormattedKey, phone.E164Formatted); + } public static void EnsurePurpose(this OneTimePassword oneTimePassword, string purpose) { @@ -79,17 +76,19 @@ public static string GetPurpose(this OneTimePassword oneTimePassword) CustomAttribute? customAttribute = oneTimePassword.CustomAttributes.SingleOrDefault(x => x.Key == PurposeKey); return customAttribute?.Value; } - public static void SetPurpose(this CreateOneTimePasswordPayload payload, string purpose) + public static void SetPurpose(this CreateOneTimePasswordPayload payload, string purpose) => payload.SetCustomAttribute(PurposeKey, purpose); + + private static void SetCustomAttribute(this CreateOneTimePasswordPayload payload, string key, string value) { - CustomAttribute? customAttribute = payload.CustomAttributes.SingleOrDefault(x => x.Key == PurposeKey); + CustomAttribute? customAttribute = payload.CustomAttributes.SingleOrDefault(x => x.Key == key); if (customAttribute == null) { - customAttribute = new(PurposeKey, purpose); + customAttribute = new(key, value); payload.CustomAttributes.Add(customAttribute); } else { - customAttribute.Value = purpose; + customAttribute.Value = value; } } } diff --git a/backend/src/Logitar.Master.Application/Accounts/UserExtensions.cs b/backend/src/Logitar.Master.Application/Accounts/UserExtensions.cs index 0552d8f..b055337 100644 --- a/backend/src/Logitar.Master.Application/Accounts/UserExtensions.cs +++ b/backend/src/Logitar.Master.Application/Accounts/UserExtensions.cs @@ -16,7 +16,7 @@ public static class UserExtensions } public static void SetMultiFactorAuthenticationMode(this UpdateUserPayload payload, MultiFactorAuthenticationMode mode) { - payload.CustomAttributes.Add(new CustomAttributeModification(MultiFactorAuthenticationModeKey, mode.ToString())); + payload.CustomAttributes.Add(new CustomAttributeModification(MultiFactorAuthenticationModeKey, mode.ToString())); // TODO(fpion): refactor, see OneTimePasswordExtensions } public static string GetSubject(this User user) => user.Id.ToString(); @@ -24,7 +24,7 @@ public static void SetMultiFactorAuthenticationMode(this UpdateUserPayload paylo public static void CompleteProfile(this UpdateUserPayload payload) { string completedOn = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture); - payload.CustomAttributes.Add(new CustomAttributeModification(ProfileCompletedOnKey, completedOn)); + payload.CustomAttributes.Add(new CustomAttributeModification(ProfileCompletedOnKey, completedOn)); // TODO(fpion): refactor, see OneTimePasswordExtensions } public static bool IsProfileCompleted(this User user) { diff --git a/backend/src/Logitar.Master.Infrastructure/IdentityServices/OneTimePasswordService.cs b/backend/src/Logitar.Master.Infrastructure/IdentityServices/OneTimePasswordService.cs index 294586f..10e2094 100644 --- a/backend/src/Logitar.Master.Infrastructure/IdentityServices/OneTimePasswordService.cs +++ b/backend/src/Logitar.Master.Infrastructure/IdentityServices/OneTimePasswordService.cs @@ -32,9 +32,18 @@ public async Task CreateAsync(User user, string purpose, Cancel RequestContext context = new(user.Id.ToString(), cancellationToken); return await _oneTimePasswordClient.CreateAsync(payload, context); } - public Task CreateAsync(User user, Phone phone, string purpose, CancellationToken cancellationToken) + public async Task CreateAsync(User user, Phone phone, string purpose, CancellationToken cancellationToken) { - throw new NotImplementedException(); // TODO(fpion): implement + CreateOneTimePasswordPayload payload = new(Characters, Length) + { + ExpiresOn = DateTime.Now.AddSeconds(LifetimeSeconds), + MaximumAttempts = MaximumAttempts + }; + payload.SetUserId(user); + payload.SetPurpose(purpose); + payload.SetPhone(phone); + RequestContext context = new(user.Id.ToString(), cancellationToken); + return await _oneTimePasswordClient.CreateAsync(payload, context); } public async Task ValidateAsync(OneTimePasswordPayload oneTimePasswordPayload, string purpose, CancellationToken cancellationToken) diff --git a/backend/src/Logitar.Master.Infrastructure/IdentityServices/TokenService.cs b/backend/src/Logitar.Master.Infrastructure/IdentityServices/TokenService.cs index 1d628b2..6c9507d 100644 --- a/backend/src/Logitar.Master.Infrastructure/IdentityServices/TokenService.cs +++ b/backend/src/Logitar.Master.Infrastructure/IdentityServices/TokenService.cs @@ -35,9 +35,18 @@ public async Task CreateAsync(string? subject, Email? email, strin return await _tokenClient.CreateAsync(payload, context); } - public Task CreateAsync(string? subject, IEnumerable claims, string type, CancellationToken cancellationToken) + public async Task CreateAsync(string? subject, IEnumerable claims, string type, CancellationToken cancellationToken) { - throw new NotImplementedException(); // TODO(fpion): implement + CreateTokenPayload payload = new() + { + IsConsumable = true, + LifetimeSeconds = 3600, + Type = type, + Subject = subject, + }; + payload.Claims.AddRange(claims); + RequestContext context = new(cancellationToken); + return await _tokenClient.CreateAsync(payload, context); } public async Task ValidateAsync(string token, string type, CancellationToken cancellationToken) From 6c68797f8362684c2c31fad97732795cce7f84bc Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Tue, 30 Apr 2024 11:40:03 -0400 Subject: [PATCH 4/4] Resolving todos. --- .../Commands/ChangePhoneCommandHandler.cs | 10 +++++----- .../Accounts/IOneTimePasswordService.cs | 4 ++-- .../Accounts/OneTimePasswordExtensions.cs | 2 +- .../IdentityServices/OneTimePasswordService.cs | 17 ++++++----------- .../Accounts/OneTimePasswordExtensionsTests.cs | 16 ++++++++++++++++ 5 files changed, 30 insertions(+), 19 deletions(-) diff --git a/backend/src/Logitar.Master.Application/Accounts/Commands/ChangePhoneCommandHandler.cs b/backend/src/Logitar.Master.Application/Accounts/Commands/ChangePhoneCommandHandler.cs index 57ba281..5f70457 100644 --- a/backend/src/Logitar.Master.Application/Accounts/Commands/ChangePhoneCommandHandler.cs +++ b/backend/src/Logitar.Master.Application/Accounts/Commands/ChangePhoneCommandHandler.cs @@ -44,7 +44,7 @@ public async Task Handle(ChangePhoneCommand command, Cancella throw new ArgumentException($"The '{nameof(command)}.{nameof(command.Payload)}' is not valid.", nameof(command)); } - private async Task HandlePhoneAsync(AccountPhone phone, string locale, string? profileCompletionToken, User? user, CancellationToken cancellationToken) + private async Task HandlePhoneAsync(AccountPhone newPhone, string locale, string? profileCompletionToken, User? user, CancellationToken cancellationToken) { if (user == null) { @@ -52,8 +52,8 @@ private async Task HandlePhoneAsync(AccountPhone phone, strin user = await FindUserAsync(profileCompletionToken, cancellationToken); } - Phone contact = new(phone.CountryCode, phone.Number, extension: null, e164Formatted: "TODO"); - OneTimePassword oneTimePassword = await _oneTimePasswordService.CreateAsync(user, contact, ContactVerificationPurpose, cancellationToken); + Phone phone = new(newPhone.CountryCode, newPhone.Number, extension: null, e164Formatted: "TODO"); + OneTimePassword oneTimePassword = await _oneTimePasswordService.CreateAsync(user, ContactVerificationPurpose, phone, cancellationToken); if (oneTimePassword.Password == null) { throw new InvalidOperationException($"The One-Time Password (OTP) 'Id={oneTimePassword.Id}' has no password."); @@ -63,8 +63,8 @@ private async Task HandlePhoneAsync(AccountPhone phone, strin ["OneTimePassword"] = oneTimePassword.Password }; string template = ContactVerificationTemplate.Replace("{ContactType}", ContactType.Phone.ToString()); - SentMessages sentMessages = await _messageService.SendAsync(template, contact, locale, variables, cancellationToken); - SentMessage sentMessage = sentMessages.ToSentMessage(contact); + SentMessages sentMessages = await _messageService.SendAsync(template, phone, locale, variables, cancellationToken); + SentMessage sentMessage = sentMessages.ToSentMessage(phone); OneTimePasswordValidation oneTimePasswordValidation = new(oneTimePassword, sentMessage); return new ChangePhoneResult(oneTimePasswordValidation); diff --git a/backend/src/Logitar.Master.Application/Accounts/IOneTimePasswordService.cs b/backend/src/Logitar.Master.Application/Accounts/IOneTimePasswordService.cs index 132db87..ecdcd07 100644 --- a/backend/src/Logitar.Master.Application/Accounts/IOneTimePasswordService.cs +++ b/backend/src/Logitar.Master.Application/Accounts/IOneTimePasswordService.cs @@ -4,9 +4,9 @@ namespace Logitar.Master.Application.Accounts; -public interface IOneTimePasswordService // TODO(fpion): refactor +public interface IOneTimePasswordService { Task CreateAsync(User user, string purpose, CancellationToken cancellationToken = default); - Task CreateAsync(User user, Phone phone, string purpose, CancellationToken cancellationToken = default); + Task CreateAsync(User user, string purpose, Phone? phone = null, CancellationToken cancellationToken = default); Task ValidateAsync(OneTimePasswordPayload payload, string purpose, CancellationToken cancellationToken = default); } diff --git a/backend/src/Logitar.Master.Application/Accounts/OneTimePasswordExtensions.cs b/backend/src/Logitar.Master.Application/Accounts/OneTimePasswordExtensions.cs index c920889..3949d14 100644 --- a/backend/src/Logitar.Master.Application/Accounts/OneTimePasswordExtensions.cs +++ b/backend/src/Logitar.Master.Application/Accounts/OneTimePasswordExtensions.cs @@ -45,7 +45,7 @@ public static Phone GetPhone(this OneTimePassword oneTimePassword) } return (phone.Number == null || phone.E164Formatted == null) ? null : phone; } - public static void SetPhone(this CreateOneTimePasswordPayload payload, Phone phone) // TODO(fpion): unit tests + public static void SetPhone(this CreateOneTimePasswordPayload payload, Phone phone) { if (phone.CountryCode != null) { diff --git a/backend/src/Logitar.Master.Infrastructure/IdentityServices/OneTimePasswordService.cs b/backend/src/Logitar.Master.Infrastructure/IdentityServices/OneTimePasswordService.cs index 10e2094..eb62cfa 100644 --- a/backend/src/Logitar.Master.Infrastructure/IdentityServices/OneTimePasswordService.cs +++ b/backend/src/Logitar.Master.Infrastructure/IdentityServices/OneTimePasswordService.cs @@ -22,17 +22,9 @@ public OneTimePasswordService(IOneTimePasswordClient oneTimePasswordClient) public async Task CreateAsync(User user, string purpose, CancellationToken cancellationToken) { - CreateOneTimePasswordPayload payload = new(Characters, Length) - { - ExpiresOn = DateTime.Now.AddSeconds(LifetimeSeconds), - MaximumAttempts = MaximumAttempts - }; - payload.SetUserId(user); - payload.SetPurpose(purpose); - RequestContext context = new(user.Id.ToString(), cancellationToken); - return await _oneTimePasswordClient.CreateAsync(payload, context); + return await CreateAsync(user, purpose, phone: null, cancellationToken); } - public async Task CreateAsync(User user, Phone phone, string purpose, CancellationToken cancellationToken) + public async Task CreateAsync(User user, string purpose, Phone? phone, CancellationToken cancellationToken) { CreateOneTimePasswordPayload payload = new(Characters, Length) { @@ -41,7 +33,10 @@ public async Task CreateAsync(User user, Phone phone, string pu }; payload.SetUserId(user); payload.SetPurpose(purpose); - payload.SetPhone(phone); + if (phone != null) + { + payload.SetPhone(phone); + } RequestContext context = new(user.Id.ToString(), cancellationToken); return await _oneTimePasswordClient.CreateAsync(payload, context); } diff --git a/backend/tests/Logitar.Master.Application.UnitTests/Accounts/OneTimePasswordExtensionsTests.cs b/backend/tests/Logitar.Master.Application.UnitTests/Accounts/OneTimePasswordExtensionsTests.cs index d5a7706..656e8e3 100644 --- a/backend/tests/Logitar.Master.Application.UnitTests/Accounts/OneTimePasswordExtensionsTests.cs +++ b/backend/tests/Logitar.Master.Application.UnitTests/Accounts/OneTimePasswordExtensionsTests.cs @@ -106,6 +106,22 @@ public void HasPurpose_it_should_return_true_when_the_One_Time_Password_has_the_ Assert.True(password.HasPurpose(Purpose.ToLower())); } + [Fact(DisplayName = "SetPhone: it should set the correct phone.")] + public void SetPhone_it_should_set_the_correct_phone() + { + Phone phone = new(countryCode: "CA", number: "(514) 845-4636", extension: "12345", e164Formatted: "+15148454636") + { + IsVerified = true + }; + CreateOneTimePasswordPayload payload = new("0123456789", length: 6); + payload.SetPhone(phone); + + Assert.Equal(3, payload.CustomAttributes.Count); + Assert.Contains(payload.CustomAttributes, c => c.Key == "PhoneCountryCode" && c.Value == phone.CountryCode); + Assert.Contains(payload.CustomAttributes, c => c.Key == "PhoneNumber" && c.Value == phone.Number); + Assert.Contains(payload.CustomAttributes, c => c.Key == "PhoneE164Formatted" && c.Value == phone.E164Formatted); + } + [Fact(DisplayName = "SetPurpose: it should add the correct custom attribute.")] public void SetPurpose_it_should_add_the_correct_custom_attribute() {