refactor(auth): cleanup auth module and streamline the registration flow
This commit is contained in:
@@ -16,7 +16,7 @@ public static class DependencyInjection
|
||||
|
||||
builder.Services.Configure<JwtOptions>(
|
||||
builder.Configuration.GetRequiredSection(JwtOptions.SectionName));
|
||||
|
||||
|
||||
builder.Services.AddAuthentication()
|
||||
.AddBearerToken(IdentityConstants.BearerScheme);
|
||||
|
||||
@@ -35,35 +35,36 @@ public static class DependencyInjection
|
||||
|
||||
// Scoped services
|
||||
builder.Services.AddScoped<IdentityService>();
|
||||
builder.Services.AddTransient<IUserLookup, UserLookup>();
|
||||
builder.Services.AddScoped<EmailVerificationService>();
|
||||
builder.Services.AddScoped<IUserLookup, UserLookup>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static async Task<IApplicationBuilder> UseIdentityModuleAsync(
|
||||
this IApplicationBuilder app,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
await using var context = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
|
||||
await context.Database.MigrateAsync(cancellationToken: cancellationToken);
|
||||
|
||||
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>();
|
||||
IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
|
||||
using IServiceScope scope = scopeFactory.CreateScope();
|
||||
await using IdentityDbContext context = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
RoleManager<Role> roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>();
|
||||
await TrySeedAsync(roleManager);
|
||||
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
private static async Task TrySeedAsync(RoleManager<Role> roleManager)
|
||||
{
|
||||
var administratorRole = new Role(KnownRoles.Administrator);
|
||||
Role administratorRole = new(KnownRoles.Administrator);
|
||||
if (roleManager.Roles.All(r => r.Name != administratorRole.Name))
|
||||
{
|
||||
await roleManager.CreateAsync(administratorRole);
|
||||
}
|
||||
|
||||
var roleCreator = new Role(KnownRoles.Creator);
|
||||
Role roleCreator = new(KnownRoles.Creator);
|
||||
if (roleManager.Roles.All(r => r.Name != roleCreator.Name))
|
||||
{
|
||||
await roleManager.CreateAsync(roleCreator);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
58
backend/Modules/Identity/Handlers/ResendVerification.cs
Normal file
58
backend/Modules/Identity/Handlers/ResendVerification.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
60
backend/Modules/Identity/Handlers/VerifyEmail.cs
Normal file
60
backend/Modules/Identity/Handlers/VerifyEmail.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Web;
|
||||
using Hutopy.Infrastructure.Configuration;
|
||||
using Hutopy.Infrastructure.Emailer.Contracts;
|
||||
using Hutopy.Modules.Identity.Data;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Services;
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class EmailVerificationService(
|
||||
IOptionsSnapshot<WebsiteOptions> options,
|
||||
UserManager userManager,
|
||||
IEmailSender emailSender)
|
||||
{
|
||||
public async Task SendVerificationEmailAsync(
|
||||
User user)
|
||||
{
|
||||
// Generate email confirmation token
|
||||
string token = await userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
string encodedToken = HttpUtility.UrlEncode(token);
|
||||
string verificationLink = $"{options.Value.FrontendBaseUrl}/verify-email?userId={user.Id}&token={encodedToken}";
|
||||
|
||||
// Send verification email
|
||||
await emailSender.SendEmailAsync(
|
||||
user.Email!,
|
||||
"Verify your 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;">Welcome to Hutopy!</h1>
|
||||
|
||||
<p style="font-size: 16px; line-height: 1.5; margin-bottom: 25px;">
|
||||
Please verify your email address by clicking the button below:
|
||||
</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href='{verificationLink}'
|
||||
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);">
|
||||
Verify Email Address
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #7f8c8d; margin-top: 30px;">
|
||||
If you did not request this, 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='{verificationLink}' style="color: #3498db; word-break: break-all;">{verificationLink}</a>
|
||||
</p>
|
||||
</div>
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Hutopy.Modules.Tipping.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
|
||||
@@ -18,19 +19,19 @@ public class StripeWebhookEndpoint(
|
||||
{
|
||||
Post("/api/stripe");
|
||||
AllowAnonymous();
|
||||
Options(o => o.WithTags( "Webhooks"));
|
||||
Options(o => o.WithTags("Webhooks"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
using var streamReader = new StreamReader(HttpContext.Request.Body);
|
||||
var json = await streamReader.ReadToEndAsync(ct);
|
||||
using StreamReader streamReader = new(HttpContext.Request.Body);
|
||||
string json = await streamReader.ReadToEndAsync(ct);
|
||||
|
||||
var signatureHeader = HttpContext.Request.Headers["Stripe-Signature"];
|
||||
var stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.WebhookSecret);
|
||||
StringValues signatureHeader = HttpContext.Request.Headers["Stripe-Signature"];
|
||||
Event? stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.WebhookSecret);
|
||||
|
||||
var stripeSession = stripeEvent.Data.Object as Session;
|
||||
var stripeSubscription = stripeEvent.Data.Object as Subscription;
|
||||
Session? stripeSession = stripeEvent.Data.Object as Session;
|
||||
Subscription? stripeSubscription = stripeEvent.Data.Object as Subscription;
|
||||
|
||||
switch (stripeEvent.Type)
|
||||
{
|
||||
@@ -41,11 +42,23 @@ public class StripeWebhookEndpoint(
|
||||
// Check if this is a one-time tip
|
||||
case "payment" when stripeSession.PaymentIntentId != null
|
||||
&& stripeSession.PaymentIntent.Status == "paid":
|
||||
// Get the customer email from the appropriate place
|
||||
string customerEmail = stripeSession.CustomerDetails?.Email ??
|
||||
stripeSession.Customer?.Email ??
|
||||
"";
|
||||
|
||||
// Get the receipt URL, preferring the one directly on the charge if available
|
||||
string receiptUrl = stripeSession.PaymentIntent?.Charges?.Data.FirstOrDefault()?.ReceiptUrl ??
|
||||
stripeSession.Invoice?.HostedInvoiceUrl ??
|
||||
"";
|
||||
|
||||
await tipPaymentNotifier.NotifyPaymentSucceedAsync(
|
||||
stripeSession.Id,
|
||||
stripeSession.Invoice.HostedInvoiceUrl,
|
||||
receiptUrl,
|
||||
customerEmail,
|
||||
ct);
|
||||
break;
|
||||
|
||||
// Check if this is a subscription
|
||||
case "subscription" when stripeSession.SubscriptionId != null:
|
||||
await membershipNotifier.NotifyPaymentSucceedAsync(
|
||||
@@ -53,13 +66,13 @@ public class StripeWebhookEndpoint(
|
||||
stripeSession.Invoice.HostedInvoiceUrl,
|
||||
stripeSession.Invoice.Total,
|
||||
stripeSession.Invoice.Currency,
|
||||
cancellationToken: ct);
|
||||
ct);
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
case "invoice.payment_succeeded":
|
||||
var invoice = (stripeEvent.Data.Object as Invoice);
|
||||
Invoice? invoice = stripeEvent.Data.Object as Invoice;
|
||||
Debug.Assert(invoice != null);
|
||||
Debug.Assert(invoice.Subscription != null);
|
||||
await membershipNotifier.NotifyPaymentSucceedAsync(
|
||||
@@ -67,7 +80,7 @@ public class StripeWebhookEndpoint(
|
||||
invoice.HostedInvoiceUrl,
|
||||
invoice.Total,
|
||||
invoice.Currency,
|
||||
cancellationToken: ct);
|
||||
ct);
|
||||
break;
|
||||
|
||||
case "customer.subscription.updated":
|
||||
|
||||
@@ -2,5 +2,9 @@ namespace Hutopy.Modules.Tipping.Contracts;
|
||||
|
||||
public interface ITipPaymentNotifier
|
||||
{
|
||||
Task NotifyPaymentSucceedAsync(string stripeId, string invoiceUrl, CancellationToken ct);
|
||||
Task NotifyPaymentSucceedAsync(
|
||||
string stripeId,
|
||||
string invoiceUrl,
|
||||
string customerEmail,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Hutopy.Infrastructure.Emailer.Contracts;
|
||||
using Hutopy.Modules.Creators.Contracts;
|
||||
using Hutopy.Modules.Tipping.Contracts;
|
||||
using Hutopy.Modules.Tipping.Data;
|
||||
|
||||
@@ -5,27 +7,104 @@ namespace Hutopy.Modules.Tipping.Services;
|
||||
|
||||
public class TipPaymentNotifier(
|
||||
TippingDbContext dbContext,
|
||||
IEmailSender emailSender,
|
||||
ICreatorLookup creatorLookup,
|
||||
ILogger<TipPaymentNotifier> logger)
|
||||
: ITipPaymentNotifier
|
||||
{
|
||||
public async Task NotifyPaymentSucceedAsync(
|
||||
string sessionId,
|
||||
string invoiceUrl,
|
||||
string receiptUrl,
|
||||
string customerEmail,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tip = await dbContext.Tips.SingleOrDefaultAsync(
|
||||
Tip? tip = await dbContext.Tips.SingleOrDefaultAsync(
|
||||
t => t.StripeSessionId == sessionId,
|
||||
cancellationToken: ct);
|
||||
ct);
|
||||
|
||||
if (tip is not null)
|
||||
{
|
||||
tip.Status = TipStatus.Paid;
|
||||
tip.StripeInvoiceUrl = invoiceUrl;
|
||||
tip.StripeInvoiceUrl = receiptUrl; // Store the receipt URL
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
// Look up creator information
|
||||
CreatorReference? creator = await creatorLookup.GetCreatorAsync(tip.CreatorId, ct);
|
||||
|
||||
if (!string.IsNullOrEmpty(customerEmail))
|
||||
{
|
||||
await SendTipConfirmationEmailAsync(
|
||||
customerEmail,
|
||||
creator?.Name ?? "le créateur",
|
||||
tip.Amount,
|
||||
tip.Currency,
|
||||
receiptUrl); // Pass the receipt URL
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogError("Tip with session ID {SessionId} not found", sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendTipConfirmationEmailAsync(
|
||||
string email,
|
||||
string creatorUsername,
|
||||
decimal amount,
|
||||
string currency,
|
||||
string receiptUrl) // Add receipt URL parameter
|
||||
{
|
||||
string subject = $"Merci pour votre soutien à {creatorUsername}";
|
||||
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;">{creatorUsername} vous remercie !</h1>
|
||||
|
||||
<p style="font-size: 16px; line-height: 1.5; margin-bottom: 15px;">
|
||||
Votre paiement de <strong>{amount} {currency}</strong> a été traité avec succès.
|
||||
</p>
|
||||
|
||||
<div style="background-color: #f8f9fa; border-radius: 4px; padding: 20px; margin: 30px 0; border-left: 4px solid #3498db;">
|
||||
<p style="font-size: 16px; margin: 0; line-height: 1.5;">
|
||||
Ce reçu confirme votre soutien à <strong>{creatorUsername}</strong>. Merci de contribuer à son travail !
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(string.IsNullOrEmpty(receiptUrl) ? "" : $"""
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href='{receiptUrl}'
|
||||
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);">
|
||||
Voir le reçu
|
||||
</a>
|
||||
</div>
|
||||
""")}
|
||||
|
||||
<p style="font-size: 14px; color: #7f8c8d; margin-top: 30px;">
|
||||
Cet email sert de reçu pour votre transaction. Nous vous conseillons de le conserver pour vos archives.
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px; color: #7f8c8d; margin-top: 20px; text-align: center; border-top: 1px solid #eee; padding-top: 20px;">
|
||||
Merci d'utiliser Hutopy pour soutenir vos créateurs préférés !
|
||||
</p>
|
||||
</div>
|
||||
""";
|
||||
|
||||
try
|
||||
{
|
||||
await emailSender.SendEmailAsync(email, subject, message);
|
||||
logger.LogInformation("Tip confirmation email sent to {Email} for tip to {Creator}", email,
|
||||
creatorUsername);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to send tip confirmation email to {Email}", email);
|
||||
// Don't throw the exception as this should not fail the payment processing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user