refactor(auth): cleanup auth module and streamline the registration flow

This commit is contained in:
2025-06-18 16:50:11 -04:00
parent 25b94d3e02
commit cdcfe8d7e2
24 changed files with 2140 additions and 1387 deletions

View File

@@ -1,4 +1,3 @@
using System.Text;
using System.Web;
using Hutopy.Infrastructure.Configuration;
using Hutopy.Infrastructure.Emailer.Contracts;
@@ -30,8 +29,8 @@ public class ForgotPasswordHandler(
CancellationToken ct)
{
// Find user by email
var user = await userManager.FindByEmailAsync(request.Email);
User? user = await userManager.FindByEmailAsync(request.Email);
// Always return OK even if user not found to prevent email enumeration
if (user is null)
{
@@ -40,26 +39,54 @@ public class ForgotPasswordHandler(
}
// Generate password reset token
var token = await userManager.GeneratePasswordResetTokenAsync(user);
string token = await userManager.GeneratePasswordResetTokenAsync(user);
// URL encode the token as it may contain characters that are not URL safe
var encodedToken = HttpUtility.UrlEncode(token);
string encodedToken = HttpUtility.UrlEncode(token);
// Build reset link
var resetLink = $"{options.Value.FrontendBaseUrl}/reset-password?email={HttpUtility.UrlEncode(request.Email)}&token={encodedToken}";
// TODO: Write a better email template
var subject = "Reset Your Password";
var message = new StringBuilder()
.AppendLine("<h1>Reset Your Password</h1>")
.AppendLine("<p>Please click the link below to reset your password:</p>")
.AppendLine($"<p><a href=\"{resetLink}\">Reset Password</a></p>")
.AppendLine("<p>If you did not request a password reset, please ignore this email.</p>")
.ToString();
string resetLink =
$"{options.Value.FrontendBaseUrl}/reset-password?email={HttpUtility.UrlEncode(request.Email)}&token={encodedToken}";
// Create a styled email message
string subject = "Reset your Hutopy password";
string message = $"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<h1 style="color: #2c3e50; margin-bottom: 20px;">Reset Your Hutopy Password</h1>
<p style="font-size: 16px; line-height: 1.5; margin-bottom: 25px;">
Please click the button below to reset your password:
</p>
<div style="text-align: center; margin: 30px 0;">
<a href='{resetLink}'
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);">
Reset Password
</a>
</div>
<p style="font-size: 14px; color: #7f8c8d; margin-top: 30px;">
If you did not request a password reset, 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='{resetLink}' style="color: #3498db; word-break: break-all;">{resetLink}</a>
</p>
</div>
""";
// Send email
await emailSender.SendEmailAsync(request.Email, subject, message);
await SendOkAsync(ct);
}
}

View File

@@ -32,8 +32,8 @@ public class LoginHandler(
LoginRequest request,
CancellationToken ct)
{
// Find user by email
var user = await userManager.FindByEmailAsync(request.Email);
// Find the user by email
User? user = await userManager.FindByEmailAsync(request.Email);
if (user is null)
{
await SendStringAsync(
@@ -44,7 +44,7 @@ public class LoginHandler(
}
// Verify password
var isPasswordValid = await userManager.CheckPasswordAsync(user, request.Password);
bool isPasswordValid = await userManager.CheckPasswordAsync(user, request.Password);
if (!isPasswordValid)
{
await SendStringAsync(
@@ -54,26 +54,36 @@ public class LoginHandler(
return;
}
// Generate new refresh token
// Check if the email is confirmed
if (!user.EmailConfirmed)
{
await SendStringAsync(
"Email not verified. Please check your email for verification instructions.",
401,
cancellation: ct);
return;
}
// Generate a new refresh token
user.RefreshToken = RefreshTokenGenerator.Next();
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
await userManager.UpdateAsync(user);
// Generate JWT token
var accessToken = JwtTokenHelper.GenerateJwtToken(
expiresIn: jwtOptions.Value.Lifetime,
issuer: jwtOptions.Value.Issuer,
audience: jwtOptions.Value.Audience,
key: jwtOptions.Value.Key,
userId: user.Id.ToString(),
email: user.Email ?? string.Empty,
alias: user.Alias,
firstname: user.Firstname ?? string.Empty,
lastname: user.Lastname ?? string.Empty,
portraitUrl: user.PortraitUrl);
string accessToken = JwtTokenHelper.GenerateJwtToken(
jwtOptions.Value.Lifetime,
jwtOptions.Value.Issuer,
jwtOptions.Value.Audience,
jwtOptions.Value.Key,
user.Id.ToString(),
user.Email ?? string.Empty,
user.Alias,
user.Firstname ?? string.Empty,
user.Lastname ?? string.Empty,
user.PortraitUrl);
await SendOkAsync(
new LoginResponse(accessToken, user.RefreshToken),
cancellation: ct);
ct);
}
}

View File

@@ -3,6 +3,7 @@ using System.Text.Json.Serialization;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Configuration;
using Hutopy.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Hutopy.Modules.Identity.Handlers;
@@ -56,8 +57,8 @@ public class LoginWithFacebookHandler(
CancellationToken ct)
{
// Verify the token with Facebook
using var httpClient = httpClientFactory.CreateClient();
using var response = await httpClient.GetAsync(
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);
if (!response.IsSuccessStatusCode)
@@ -70,8 +71,8 @@ public class LoginWithFacebookHandler(
}
// Extract the user info (email, name, profile picture)
var content = await response.Content.ReadAsStringAsync(ct);
var userInfo = JsonSerializer.Deserialize<FacebookUserInfo>(content);
string content = await response.Content.ReadAsStringAsync(ct);
FacebookUserInfo? userInfo = JsonSerializer.Deserialize<FacebookUserInfo>(content);
if (userInfo is null || string.IsNullOrEmpty(userInfo.Id))
{
await SendStringAsync(
@@ -82,23 +83,24 @@ public class LoginWithFacebookHandler(
}
// Check if user exists or create a new one
var user = await userManager.FindByEmailAsync(userInfo.Email!);
User? user = await userManager.FindByEmailAsync(userInfo.Email!);
if (user is null)
{
var generatedPassword = PasswordGenerator.Next();
var generatedUser = new User
string generatedPassword = PasswordGenerator.Next();
User generatedUser = new()
{
UserName = userInfo.Email ?? $"fb_{userInfo.Id}",
Email = userInfo.Email,
EmailConfirmed = true,
Firstname = userInfo.Name.Split(' ').FirstOrDefault() ?? "",
Lastname = userInfo.Name.Split(' ').Skip(1).FirstOrDefault() ?? "",
Alias = userInfo.Name,
PortraitUrl = userInfo.Picture.Picture.Url,
FacebookId = userInfo.Id, // Storing Facebook ID
FacebookId = userInfo.Id // Storing Facebook ID
};
var result = await userManager.CreateAsync(
IdentityResult result = await userManager.CreateAsync(
generatedUser,
generatedPassword);
@@ -115,27 +117,27 @@ public class LoginWithFacebookHandler(
}
// Generate refresh token
var refreshToken = RefreshTokenGenerator.Next();
string refreshToken = RefreshTokenGenerator.Next();
// Store refresh token in user's properties
user.RefreshToken = refreshToken;
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
await userManager.UpdateAsync(user);
var accessToken = JwtTokenHelper.GenerateJwtToken(
expiresIn: jwtOptions.Value.Lifetime,
issuer: jwtOptions.Value.Issuer,
audience: jwtOptions.Value.Audience,
key: jwtOptions.Value.Key,
userId: user.Id.ToString(),
email: user.Email ?? string.Empty,
alias: user.Alias,
firstname: user.Firstname ?? string.Empty,
lastname: user.Lastname ?? string.Empty,
portraitUrl: user.PortraitUrl);
string accessToken = JwtTokenHelper.GenerateJwtToken(
jwtOptions.Value.Lifetime,
jwtOptions.Value.Issuer,
jwtOptions.Value.Audience,
jwtOptions.Value.Key,
user.Id.ToString(),
user.Email ?? string.Empty,
user.Alias,
user.Firstname ?? string.Empty,
user.Lastname ?? string.Empty,
user.PortraitUrl);
await SendOkAsync(
new LoginWithFacebookResponse(accessToken, refreshToken),
cancellation: ct);
ct);
}
}

View File

@@ -3,11 +3,12 @@ using System.Text.Json.Serialization;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Configuration;
using Hutopy.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Hutopy.Modules.Identity.Handlers;
class GoogleToken
internal class GoogleToken
{
[JsonPropertyName("access_token")] public required string AccessToken { get; init; }
[JsonPropertyName("token_type")] public required string TokenType { get; init; }
@@ -55,11 +56,11 @@ public class LoginWithGoogleHandler(
LoginWithGoogleRequest request,
CancellationToken ct)
{
var googleToken = JsonSerializer.Deserialize<GoogleToken>(request.Token)!;
GoogleToken googleToken = JsonSerializer.Deserialize<GoogleToken>(request.Token)!;
// Verify the token with Google
using var httpClient = httpClientFactory.CreateClient();
using var response = await httpClient.GetAsync(
using HttpClient httpClient = httpClientFactory.CreateClient();
using HttpResponseMessage response = await httpClient.GetAsync(
$"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}",
ct);
if (!response.IsSuccessStatusCode)
@@ -72,8 +73,8 @@ public class LoginWithGoogleHandler(
}
// Extract the user info (email, name, etc.).
var content = await response.Content.ReadAsStringAsync(ct);
var userInfo = JsonSerializer.Deserialize<GoogleUserInfo>(content);
string content = await response.Content.ReadAsStringAsync(ct);
GoogleUserInfo? userInfo = JsonSerializer.Deserialize<GoogleUserInfo>(content);
if (userInfo is null
|| !userInfo.VerifiedEmail
|| string.IsNullOrEmpty(userInfo.Email))
@@ -85,17 +86,18 @@ public class LoginWithGoogleHandler(
return;
}
// Check if user exists or create a new one
var user = await userManager.FindByEmailAsync(userInfo.Email);
// Check if the user exists or create a new one
User? user = await userManager.FindByEmailAsync(userInfo.Email);
if (user is null)
{
var generatedPassword = PasswordGenerator.Next();
var refreshToken = RefreshTokenGenerator.Next();
var generatedUser = new User
string generatedPassword = PasswordGenerator.Next();
string refreshToken = RefreshTokenGenerator.Next();
User generatedUser = new()
{
UserName = userInfo.Email,
Email = userInfo.Email,
EmailConfirmed = true,
Firstname = userInfo.GivenName,
Lastname = userInfo.FamilyName,
Alias = userInfo.Name,
@@ -105,7 +107,7 @@ public class LoginWithGoogleHandler(
RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime)
};
var result = await userManager.CreateAsync(
IdentityResult result = await userManager.CreateAsync(
generatedUser,
generatedPassword);
@@ -121,25 +123,25 @@ public class LoginWithGoogleHandler(
user = generatedUser;
}
// Generate new refresh token
// Generate the new refresh token
user.RefreshToken = RefreshTokenGenerator.Next();
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
await userManager.UpdateAsync(user);
var accessToken = JwtTokenHelper.GenerateJwtToken(
expiresIn: jwtOptions.Value.Lifetime,
issuer: jwtOptions.Value.Issuer,
audience: jwtOptions.Value.Audience,
key: jwtOptions.Value.Key,
userId: user.Id.ToString(),
email: user.Email ?? string.Empty,
alias: user.Alias,
firstname: user.Firstname ?? string.Empty,
lastname: user.Lastname ?? string.Empty,
portraitUrl: user.PortraitUrl);
string accessToken = JwtTokenHelper.GenerateJwtToken(
jwtOptions.Value.Lifetime,
jwtOptions.Value.Issuer,
jwtOptions.Value.Audience,
jwtOptions.Value.Key,
user.Id.ToString(),
user.Email ?? string.Empty,
user.Alias,
user.Firstname ?? string.Empty,
user.Lastname ?? string.Empty,
user.PortraitUrl);
await SendOkAsync(
new LoginWithGoogleResponse(accessToken, user.RefreshToken),
cancellation: ct);
ct);
}
}

View File

@@ -1,7 +1,6 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Configuration;
using Hutopy.Modules.Identity.Data;
using Microsoft.Extensions.Options;
using Hutopy.Modules.Identity.Services;
using Microsoft.AspNetCore.Identity;
namespace Hutopy.Modules.Identity.Handlers;
@@ -13,13 +12,12 @@ public record RegisterRequest(
[PublicAPI]
public record RegisterResponse(
string AccessToken,
string RefreshToken);
string Message);
[PublicAPI]
public class RegisterHandler(
UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions)
EmailVerificationService emailVerificationService)
: Endpoint<RegisterRequest, RegisterResponse>
{
public override void Configure()
@@ -34,7 +32,7 @@ public class RegisterHandler(
CancellationToken ct)
{
// Check if the user already exists
var existingUser = await userManager.FindByEmailAsync(request.Email);
User? existingUser = await userManager.FindByEmailAsync(request.Email);
if (existingUser is not null)
{
await SendStringAsync(
@@ -44,27 +42,22 @@ public class RegisterHandler(
return;
}
// Create a refresh token
var refreshToken = RefreshTokenGenerator.Next();
// Split the name into firstname and lastname (if provided)
var nameParts = request.Name.Split(' ', 2);
var firstname = nameParts[0];
var lastname = nameParts.Length > 1 ? nameParts[1] : string.Empty;
string[] nameParts = request.Name.Split(' ', 2);
string firstname = nameParts[0];
string lastname = nameParts.Length > 1 ? nameParts[1] : string.Empty;
// Create a new user
var user = new User
User user = new()
{
UserName = request.Email,
Email = request.Email,
Firstname = firstname,
Lastname = lastname,
Alias = request.Name,
RefreshToken = refreshToken,
RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime)
Alias = request.Name
};
var result = await userManager.CreateAsync(
IdentityResult result = await userManager.CreateAsync(
user,
request.Password);
@@ -77,21 +70,10 @@ public class RegisterHandler(
return;
}
// Generate JWT token
var accessToken = JwtTokenHelper.GenerateJwtToken(
expiresIn: jwtOptions.Value.Lifetime,
issuer: jwtOptions.Value.Issuer,
audience: jwtOptions.Value.Audience,
key: jwtOptions.Value.Key,
userId: user.Id.ToString(),
email: user.Email ?? string.Empty,
alias: user.Alias,
firstname: user.Firstname ?? string.Empty,
lastname: user.Lastname ?? string.Empty,
portraitUrl: user.PortraitUrl);
await emailVerificationService.SendVerificationEmailAsync(user);
await SendOkAsync(
new RegisterResponse(accessToken, user.RefreshToken),
cancellation: ct);
new RegisterResponse("Registration successful! Please check your email to verify your account."),
ct);
}
}

View File

@@ -0,0 +1,58 @@
using Hutopy.Modules.Identity.Data;
using Hutopy.Modules.Identity.Services;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record ResendVerificationRequest(
string Email);
[PublicAPI]
public record ResendVerificationResponse(
string Message);
[PublicAPI]
public class ResendVerificationHandler(
EmailVerificationService emailWriter,
UserManager userManager)
: Endpoint<ResendVerificationRequest, ResendVerificationResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/resend-verification");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ResendVerificationRequest request,
CancellationToken ct)
{
// Find a user by email
User? user = await userManager.FindByEmailAsync(request.Email);
if (user is null)
{
// Don't reveal that the user doesn't exist
await SendOkAsync(
new ResendVerificationResponse(
"If your email exists in our system, a verification link has been sent."),
ct);
return;
}
// Check if the email is already confirmed
if (user.EmailConfirmed)
{
await SendOkAsync(
new ResendVerificationResponse("Your email is already verified. You can log in."),
ct);
return;
}
await emailWriter.SendVerificationEmailAsync(user);
await SendOkAsync(
new ResendVerificationResponse("If your email exists in our system, a verification link has been sent."),
ct);
}
}

View File

@@ -0,0 +1,60 @@
using System.Web;
using Hutopy.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record VerifyEmailRequest(
string UserId,
string Token);
[PublicAPI]
public record VerifyEmailResponse(
string Message);
[PublicAPI]
public class VerifyEmailHandler(
UserManager userManager)
: Endpoint<VerifyEmailRequest, VerifyEmailResponse>
{
public override void Configure()
{
AllowAnonymous();
Get("/api/users/verify-email");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
VerifyEmailRequest request,
CancellationToken ct)
{
// Find user by ID
User? user = await userManager.FindByIdAsync(request.UserId);
if (user is null)
{
await SendStringAsync(
"Invalid verification link",
400,
cancellation: ct);
return;
}
// Verify the token and confirm email
string decoded = HttpUtility.UrlDecode(request.Token);
string decodedWithPlus = request.Token.Replace(" ", "+");
IdentityResult result = await userManager.ConfirmEmailAsync(user, decodedWithPlus);
if (!result.Succeeded)
{
await SendStringAsync(
"Invalid verification link or the link has expired",
400,
cancellation: ct);
return;
}
await SendOkAsync(
new VerifyEmailResponse("Email verification successful! You can now log in."),
ct);
}
}