Skip to content
This repository has been archived by the owner on Jul 9, 2024. It is now read-only.

Implemented Profile Completion Phone. #17

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using Logitar.Master.Contracts.Accounts;
using MediatR;

namespace Logitar.Master.Application.Accounts.Commands;

public record ChangePhoneCommand(ChangePhonePayload Payload) : Activity, IRequest<ChangePhoneResult>;
Original file line number Diff line number Diff line change
@@ -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<ChangePhoneCommand, ChangePhoneResult>
{
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<ChangePhoneResult> 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<ChangePhoneResult> HandlePhoneAsync(AccountPhone newPhone, string locale, string? profileCompletionToken, User? user, CancellationToken cancellationToken)
{
if (user == null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(profileCompletionToken, nameof(profileCompletionToken));
user = await FindUserAsync(profileCompletionToken, 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.");
}
Dictionary<string, string> variables = new()
{
["OneTimePassword"] = oneTimePassword.Password
};
string template = ContactVerificationTemplate.Replace("{ContactType}", ContactType.Phone.ToString());
SentMessages sentMessages = await _messageService.SendAsync(template, phone, locale, variables, cancellationToken);
SentMessage sentMessage = sentMessages.ToSentMessage(phone);
OneTimePasswordValidation oneTimePasswordValidation = new(oneTimePassword, sentMessage);

return new ChangePhoneResult(oneTimePasswordValidation);
}

private async Task<ChangePhoneResult> 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<TokenClaim> 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<User> 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.");
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,11 +13,9 @@ namespace Logitar.Master.Application.Accounts.Commands;

internal class SignInCommandHandler : IRequestHandler<SignInCommand, SignInCommandResult>
{
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;
Expand Down Expand Up @@ -57,7 +56,7 @@ public async Task<SignInCommandResult> 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<SignInCommandResult> HandleCredentialsAsync(Credentials credentials, string locale, IEnumerable<CustomAttribute> customAttributes, CancellationToken cancellationToken)
Expand All @@ -66,7 +65,7 @@ private async Task<SignInCommandResult> 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<string, string> variables = new()
{
["Token"] = token.Token
Expand Down Expand Up @@ -126,7 +125,7 @@ private async Task<SignInCommandResult> SendMultiFactorAuthenticationMessageAsyn

private async Task<SignInCommandResult> HandleAuthenticationTokenAsync(string token, IEnumerable<CustomAttribute> 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
Expand Down Expand Up @@ -167,10 +166,10 @@ private async Task<SignInCommandResult> HandleOneTimePasswordAsync(OneTimePasswo

private async Task<SignInCommandResult> CompleteProfileAsync(CompleteProfilePayload payload, IEnumerable<CustomAttribute> 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.");
Expand All @@ -183,7 +182,7 @@ private async Task<SignInCommandResult> 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ namespace Logitar.Master.Application.Accounts;
public interface IMessageService
{
Task<SentMessages> SendAsync(string template, Email email, string? locale = null, Dictionary<string, string>? variables = null, CancellationToken cancellationToken = default);
Task<SentMessages> SendAsync(string template, Phone phone, string? locale = null, Dictionary<string, string>? variables = null, CancellationToken cancellationToken = default);
Task<SentMessages> SendAsync(string template, User user, string? locale = null, Dictionary<string, string>? variables = null, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ namespace Logitar.Master.Application.Accounts;
public interface IOneTimePasswordService
{
Task<OneTimePassword> CreateAsync(User user, string purpose, CancellationToken cancellationToken = default);
Task<OneTimePassword> CreateAsync(User user, string purpose, Phone? phone = null, CancellationToken cancellationToken = default);
Task<OneTimePassword> ValidateAsync(OneTimePasswordPayload payload, string purpose, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@

namespace Logitar.Master.Application.Accounts;

public interface ITokenService
public interface ITokenService // TODO(fpion): refactor
{
Task<CreatedToken> CreateAsync(string? subject, string type, CancellationToken cancellationToken = default);
Task<CreatedToken> CreateAsync(string? subject, Email? email, string type, CancellationToken cancellationToken = default);
Task<CreatedToken> CreateAsync(string? subject, IEnumerable<TokenClaim> claims, string type, CancellationToken cancellationToken = default);
Task<ValidatedToken> ValidateAsync(string token, string type, CancellationToken cancellationToken = default);
Task<ValidatedToken> ValidateAsync(string token, bool consume, string type, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

namespace Logitar.Master.Application.Accounts;

public interface IUserService
public interface IUserService // TODO(fpion): refactor
{
Task<User> AuthenticateAsync(User user, string password, CancellationToken cancellationToken = default);
Task<User> AuthenticateAsync(string uniqueName, string password, CancellationToken cancellationToken = default);
Expand All @@ -12,4 +12,5 @@ public interface IUserService
Task<User?> FindAsync(Guid id, CancellationToken cancellationToken = default);
Task<User> SaveProfileAsync(Guid userId, SaveProfilePayload payload, CancellationToken cancellationToken = default);
Task<User> UpdateEmailAsync(Guid userId, Email email, CancellationToken cancellationToken = default);
Task<User> UpdatePhoneAsync(Guid userId, Phone phone, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -15,18 +18,41 @@ 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)
public static void SetUserId(this CreateOneTimePasswordPayload payload, User user) => payload.SetCustomAttribute(UserIdKey, user.Id.ToString());

public static Phone GetPhone(this OneTimePassword oneTimePassword)
{
CustomAttribute? customAttribute = payload.CustomAttributes.SingleOrDefault(x => x.Key == UserIdKey);
if (customAttribute == null)
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)
{
customAttribute = new(UserIdKey, user.Id.ToString());
payload.CustomAttributes.Add(customAttribute);
switch (customAttribute.Key)
{
case PhoneCountryCodeKey:
phone.CountryCode = customAttribute.Value;
break;
case PhoneE164FormattedKey:
phone.E164Formatted = customAttribute.Value;
break;
case PhoneNumberKey:
phone.Number = customAttribute.Value;
break;
}
}
else
return (phone.Number == null || phone.E164Formatted == null) ? null : phone;
}
public static void SetPhone(this CreateOneTimePasswordPayload payload, Phone phone)
{
if (phone.CountryCode != null)
{
customAttribute.Value = user.Id.ToString();
payload.SetCustomAttribute(PhoneCountryCodeKey, phone.CountryCode);
}
payload.SetCustomAttribute(PhoneNumberKey, phone.Number);
payload.SetCustomAttribute(PhoneE164FormattedKey, phone.E164Formatted);
}

public static void EnsurePurpose(this OneTimePassword oneTimePassword, string purpose)
Expand All @@ -50,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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ 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();

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)
{
Expand Down
14 changes: 14 additions & 0 deletions backend/src/Logitar.Master.Application/Activity.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Logitar.EventSourcing;
using Logitar.Portal.Contracts.Actors;
using Logitar.Portal.Contracts.Users;

namespace Logitar.Master.Application;

Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions backend/src/Logitar.Master.Application/Constants/Claims.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Logitar.Master.Application.Constants;

internal static class Claims
{
public const string PhoneCountryCode = "phone_country";
}
Original file line number Diff line number Diff line change
@@ -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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Logitar.Security" Version="6.1.1" />
<PackageReference Include="MediatR" Version="12.2.0" />
</ItemGroup>

Expand All @@ -25,6 +26,7 @@
<ItemGroup>
<Using Include="System.Globalization" />
<Using Include="System.Reflection" />
<Using Include="System.Security.Claims" />
<Using Include="System.Text" />
</ItemGroup>

Expand Down
Loading