fix: confirm email changes and enforce clean backend build
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using FastEndpoints;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Identity.Data;
|
||||
using Socialize.Api.Modules.Identity.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Api.Modules.Identity.Handlers;
|
||||
@@ -9,10 +10,15 @@ namespace Socialize.Api.Modules.Identity.Handlers;
|
||||
internal record ChangeEmailRequest(
|
||||
string? Email);
|
||||
|
||||
[PublicAPI]
|
||||
internal record ChangeEmailResponse(
|
||||
string Message);
|
||||
|
||||
[PublicAPI]
|
||||
internal class ChangeEmailHandler(
|
||||
UserManager userManager)
|
||||
: Endpoint<ChangeEmailRequest>
|
||||
UserManager userManager,
|
||||
EmailVerificationService emailVerificationService)
|
||||
: Endpoint<ChangeEmailRequest, ChangeEmailResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
@@ -32,18 +38,28 @@ internal class ChangeEmailHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
user.Email = request.Email;
|
||||
|
||||
// TODO: check to see if identity resets the `email confirmed` flag - @jonathan
|
||||
IdentityResult result = await userManager.UpdateAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
if (string.IsNullOrWhiteSpace(request.Email))
|
||||
{
|
||||
await SendOkAsync(ct);
|
||||
await SendStringAsync(
|
||||
"Email is required",
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
else
|
||||
|
||||
string newEmail = request.Email.Trim();
|
||||
if (string.Equals(user.Email, newEmail, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
await SendOkAsync(
|
||||
new ChangeEmailResponse("Email is already set to this address."),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await emailVerificationService.SendEmailChangeConfirmationAsync(user, newEmail);
|
||||
|
||||
await SendOkAsync(
|
||||
new ChangeEmailResponse("Please check your new email address to confirm the change."),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,22 @@ internal class ChangePhoneHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
user.PhoneNumber = request.PhoneNumber;
|
||||
// TODO: check to see if identity resets the `phone confirmed` flag - @jonathan
|
||||
string? newPhoneNumber = string.IsNullOrWhiteSpace(request.PhoneNumber)
|
||||
? null
|
||||
: request.PhoneNumber.Trim();
|
||||
|
||||
IdentityResult result = await userManager.UpdateAsync(user);
|
||||
IdentityResult result;
|
||||
if (newPhoneNumber is null)
|
||||
{
|
||||
user.PhoneNumber = null;
|
||||
user.PhoneNumberConfirmed = false;
|
||||
result = await userManager.UpdateAsync(user);
|
||||
}
|
||||
else
|
||||
{
|
||||
string token = await userManager.GenerateChangePhoneNumberTokenAsync(user, newPhoneNumber);
|
||||
result = await userManager.ChangePhoneNumberAsync(user, newPhoneNumber, token);
|
||||
}
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using System.Web;
|
||||
using Socialize.Api.Modules.Identity.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
internal record ConfirmEmailChangeRequest(
|
||||
string UserId,
|
||||
string Email,
|
||||
string Token);
|
||||
|
||||
[PublicAPI]
|
||||
internal record ConfirmEmailChangeResponse(
|
||||
string Message);
|
||||
|
||||
[PublicAPI]
|
||||
internal class ConfirmEmailChangeHandler(
|
||||
UserManager userManager)
|
||||
: Endpoint<ConfirmEmailChangeRequest, ConfirmEmailChangeResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
AllowAnonymous();
|
||||
Get("/api/users/confirm-email-change");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ConfirmEmailChangeRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
User? user = await userManager.FindByIdAsync(request.UserId);
|
||||
if (user is null)
|
||||
{
|
||||
await SendStringAsync(
|
||||
"Invalid email change link",
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string newEmail = request.Email.Trim();
|
||||
string decodedToken = HttpUtility.UrlDecode(request.Token).Replace(" ", "+", StringComparison.Ordinal);
|
||||
IdentityResult result = await userManager.ChangeEmailAsync(user, newEmail, decodedToken);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
await SendStringAsync(
|
||||
"Invalid email change link or the link has expired",
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
IdentityResult usernameResult = await userManager.SetUserNameAsync(user, newEmail);
|
||||
if (!usernameResult.Succeeded)
|
||||
{
|
||||
await SendStringAsync(
|
||||
usernameResult.Errors.First().Description,
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(
|
||||
new ConfirmEmailChangeResponse("Email address changed successfully."),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,13 @@ internal class GetCurrentUserQueryHandler(
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken ct)
|
||||
{
|
||||
UserModel? userModel = await identityService.GetCurrentUserAsync();
|
||||
|
||||
if (userModel is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,6 @@ internal class GetCurrentUserQueryHandler(
|
||||
Address = userModel.Address,
|
||||
UserRoles = roles
|
||||
},
|
||||
cancellationToken);
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,21 +19,21 @@ internal class GetCurrentUserPortraitHandler(
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken ct)
|
||||
{
|
||||
UserModel? identityUser = await identityService.GetCurrentUserAsync();
|
||||
|
||||
if (identityUser is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
MemoryStream stream = await blobStorage.DownloadFileAsync(
|
||||
ContainerNames.Users,
|
||||
$"{identityUser.Id.ToString()}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
|
||||
cancellationToken);
|
||||
ct);
|
||||
|
||||
await SendOkAsync(stream, cancellationToken);
|
||||
await SendOkAsync(stream, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +61,8 @@ internal class LoginWithFacebookHandler(
|
||||
{
|
||||
// Verify the token with Facebook
|
||||
using HttpClient httpClient = httpClientFactory.CreateClient();
|
||||
using HttpResponseMessage response = await httpClient.GetAsync(
|
||||
$"https://graph.facebook.com/me?access_token={request.Token}&fields=id,name,email,picture.width(200).height(200)",
|
||||
ct);
|
||||
Uri userInfoUri = new($"https://graph.facebook.com/me?access_token={request.Token}&fields=id,name,email,picture.width(200).height(200)");
|
||||
using HttpResponseMessage response = await httpClient.GetAsync(userInfoUri, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await SendStringAsync(
|
||||
|
||||
@@ -63,9 +63,8 @@ internal class LoginWithGoogleHandler(
|
||||
|
||||
// Verify the token with Google
|
||||
using HttpClient httpClient = httpClientFactory.CreateClient();
|
||||
using HttpResponseMessage response = await httpClient.GetAsync(
|
||||
$"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}",
|
||||
ct);
|
||||
Uri userInfoUri = new($"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}");
|
||||
using HttpResponseMessage response = await httpClient.GetAsync(userInfoUri, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await SendStringAsync(
|
||||
|
||||
@@ -42,8 +42,7 @@ internal class VerifyEmailHandler(
|
||||
}
|
||||
|
||||
// Verify the token and confirm email
|
||||
string decoded = HttpUtility.UrlDecode(request.Token);
|
||||
string decodedWithPlus = request.Token.Replace(" ", "+");
|
||||
string decodedWithPlus = HttpUtility.UrlDecode(request.Token).Replace(" ", "+", StringComparison.Ordinal);
|
||||
IdentityResult result = await userManager.ConfirmEmailAsync(user, decodedWithPlus);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
|
||||
@@ -16,13 +16,7 @@ internal sealed class AccessTokenFactory(
|
||||
IList<string> roles = await userManager.GetRolesAsync(user);
|
||||
IList<Claim> claims = await userManager.GetClaimsAsync(user);
|
||||
|
||||
string persona = roles.Contains(KnownRoles.Manager, StringComparer.Ordinal)
|
||||
? KnownRoles.Manager
|
||||
: roles.Contains(KnownRoles.Client, StringComparer.Ordinal)
|
||||
? KnownRoles.Client
|
||||
: roles.Contains(KnownRoles.Provider, StringComparer.Ordinal)
|
||||
? KnownRoles.Provider
|
||||
: KnownRoles.WorkspaceMember;
|
||||
string persona = GetPersona(roles);
|
||||
|
||||
List<Claim> tokenClaims = [.. claims, new Claim(KnownClaims.Persona, persona)];
|
||||
|
||||
@@ -40,4 +34,24 @@ internal sealed class AccessTokenFactory(
|
||||
roles,
|
||||
tokenClaims);
|
||||
}
|
||||
|
||||
private static string GetPersona(IList<string> roles)
|
||||
{
|
||||
if (roles.Contains(KnownRoles.Manager, StringComparer.Ordinal))
|
||||
{
|
||||
return KnownRoles.Manager;
|
||||
}
|
||||
|
||||
if (roles.Contains(KnownRoles.Client, StringComparer.Ordinal))
|
||||
{
|
||||
return KnownRoles.Client;
|
||||
}
|
||||
|
||||
if (roles.Contains(KnownRoles.Provider, StringComparer.Ordinal))
|
||||
{
|
||||
return KnownRoles.Provider;
|
||||
}
|
||||
|
||||
return KnownRoles.WorkspaceMember;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,4 +58,52 @@ internal sealed class EmailVerificationService(
|
||||
</div>
|
||||
""");
|
||||
}
|
||||
|
||||
public async Task SendEmailChangeConfirmationAsync(
|
||||
User user,
|
||||
string newEmail)
|
||||
{
|
||||
string token = await userManager.GenerateChangeEmailTokenAsync(user, newEmail);
|
||||
string encodedEmail = HttpUtility.UrlEncode(newEmail);
|
||||
string encodedToken = HttpUtility.UrlEncode(token);
|
||||
string confirmationLink =
|
||||
$"{options.Value.FrontendBaseUrl}/verify-email?changeEmail=true&userId={user.Id}&email={encodedEmail}&token={encodedToken}";
|
||||
|
||||
await emailSender.SendEmailAsync(
|
||||
newEmail,
|
||||
"Confirm your new email address",
|
||||
$"""
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
|
||||
<h1 style="color: #2c3e50; margin-bottom: 20px;">Confirm your new email address</h1>
|
||||
|
||||
<p style="font-size: 16px; line-height: 1.5; margin-bottom: 25px;">
|
||||
Please confirm this email address for your Socialize account by clicking the button below:
|
||||
</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href='{confirmationLink}'
|
||||
style="background-color: #3498db;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);">
|
||||
Confirm Email Address
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #7f8c8d; margin-top: 30px;">
|
||||
If you did not request this change, please ignore this email.
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px; color: #7f8c8d; margin-top: 20px;">
|
||||
If the button doesn't work, you can copy and paste this link into your browser:
|
||||
<br>
|
||||
<a href='{confirmationLink}' style="color: #3498db; word-break: break-all;">{confirmationLink}</a>
|
||||
</p>
|
||||
</div>
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user