many fixes and improvements - rework for modules/ and common/

feat(emailer): add Postmark and Resend providers
This commit is contained in:
2025-06-06 12:21:43 -04:00
parent 31ba18fa8d
commit 25b94d3e02
313 changed files with 6586 additions and 18260 deletions

View File

@@ -0,0 +1,42 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Data;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeAddressRequest(
string? Address);
[PublicAPI]
public class ChangeAddressHandler(
UserManager userManager)
: Endpoint<ChangeAddressRequest>
{
public override void Configure()
{
Post("/api/users/address");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeAddressRequest request,
CancellationToken ct)
{
var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Address = request.Address;
var result = await userManager.UpdateAsync(user);
if (result.Succeeded)
await SendOkAsync(ct);
else
await SendUnauthorizedAsync(ct);
}
}

View File

@@ -0,0 +1,42 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Data;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeAliasRequest(
string? Alias);
[PublicAPI]
public class ChangeAliasHandler(
UserManager userManager)
: Endpoint<ChangeAliasRequest>
{
public override void Configure()
{
Post("/api/users/alias");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeAliasRequest request,
CancellationToken ct)
{
var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Alias = request.Alias;
var result = await userManager.UpdateAsync(user);
if (result.Succeeded)
await SendOkAsync(ct);
else
await SendUnauthorizedAsync(ct);
}
}

View File

@@ -0,0 +1,42 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Data;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeBirthDateRequest(
DateTime BirthDate);
[PublicAPI]
public class ChangeBirthDateHandler(
UserManager userManager)
: Endpoint<ChangeBirthDateRequest>
{
public override void Configure()
{
Post("/api/users/birthdate");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeBirthDateRequest request,
CancellationToken ct)
{
var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.BirthDate = request.BirthDate;
var result = await userManager.UpdateAsync(user);
if (result.Succeeded)
await SendOkAsync(ct);
else
await SendUnauthorizedAsync(ct);
}
}

View File

@@ -0,0 +1,43 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Data;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeEmailRequest(
string? Email);
[PublicAPI]
public class ChangeEmailHandler(
UserManager userManager)
: Endpoint<ChangeEmailRequest>
{
public override void Configure()
{
Post("/api/users/email");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeEmailRequest request,
CancellationToken ct)
{
var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Email = request.Email;
// TODO: check to see if identity resets the `email confirmed` flag - @jonathan
var result = await userManager.UpdateAsync(user);
if (result.Succeeded)
await SendOkAsync(ct);
else
await SendUnauthorizedAsync(ct);
}
}

View File

@@ -0,0 +1,44 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Data;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeFullnameRequest(
string? Firstname,
string? Lastname);
[PublicAPI]
public class ChangeFullnameHandler(
UserManager userManager)
: Endpoint<ChangeFullnameRequest>
{
public override void Configure()
{
Post("/api/users/fullname");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeFullnameRequest request,
CancellationToken ct)
{
var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Firstname = request.Firstname;
user.Lastname = request.Lastname;
var result = await userManager.UpdateAsync(user);
if (result.Succeeded)
await SendOkAsync(ct);
else
await SendUnauthorizedAsync(ct);
}
}

View File

@@ -0,0 +1,43 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Data;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record ChangePhoneRequest(
string? PhoneNumber);
[PublicAPI]
public class ChangePhoneHandler(
UserManager userManager)
: Endpoint<ChangePhoneRequest>
{
public override void Configure()
{
Post("/api/users/phone");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangePhoneRequest request,
CancellationToken ct)
{
var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.PhoneNumber = request.PhoneNumber;
// TODO: check to see if identity resets the `phone confirmed` flag - @jonathan
var result = await userManager.UpdateAsync(user);
if (result.Succeeded)
await SendOkAsync(ct);
else
await SendUnauthorizedAsync(ct);
}
}

View File

@@ -0,0 +1,73 @@
using Hutopy.Infrastructure.BlobStorage.Contracts;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Data;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record ChangePortraitRequest(
IFormFile File);
[PublicAPI]
public record ChangePortraitResponse(
string BlobUrl);
[PublicAPI]
public sealed class ChangePortraitRequestValidator : Validator<ChangePortraitRequest>
{
public ChangePortraitRequestValidator()
{
RuleFor(x => x.File)
.NotNull()
.NotEmpty();
}
}
[PublicAPI]
public class ChangePortraitHandler(
UserManager userManager,
IBlobStorage blobStorage)
: Endpoint<ChangePortraitRequest, ChangePortraitResponse>
{
public override void Configure()
{
Post("/api/users/portrait");
Options(o => o.WithTags("Users"));
AllowFileUploads();
}
public override async Task HandleAsync(
ChangePortraitRequest request,
CancellationToken ct)
{
var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
var blobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Users,
$"{user.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
request.File.OpenReadStream(),
request.File.ContentType,
ct);
user.PortraitUrl = blobUrl;
var result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(
new ChangePortraitResponse(blobUrl),
ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -0,0 +1,65 @@
using System.Text;
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.Handlers;
[PublicAPI]
public record ForgotPasswordRequest(
string Email);
[PublicAPI]
public class ForgotPasswordHandler(
UserManager userManager,
IEmailSender emailSender,
IOptionsSnapshot<WebsiteOptions> options)
: Endpoint<ForgotPasswordRequest>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/forgot-password");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ForgotPasswordRequest request,
CancellationToken ct)
{
// Find user by email
var user = await userManager.FindByEmailAsync(request.Email);
// Always return OK even if user not found to prevent email enumeration
if (user is null)
{
await SendOkAsync(ct);
return;
}
// Generate password reset token
var token = await userManager.GeneratePasswordResetTokenAsync(user);
// URL encode the token as it may contain characters that are not URL safe
var 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();
// Send email
await emailSender.SendEmailAsync(request.Email, subject, message);
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,47 @@
using Hutopy.Modules.Identity.Data;
using Hutopy.Modules.Identity.Models;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public class GetCurrentUserQueryHandler(
IdentityService identityService)
: EndpointWithoutRequest<UserDto>
{
public override void Configure()
{
Get("/api/users/profile");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
CancellationToken cancellationToken)
{
var userModel = await identityService.GetCurrentUserAsync();
if (userModel is null)
{
await SendNotFoundAsync(cancellationToken);
return;
}
var roles = await identityService.GetCurrentUserRolesAsync();
await SendOkAsync(
new UserDto
{
Id = userModel.Id,
Alias = userModel.Alias,
PortraitUrl = userModel.PortraitUrl,
Firstname = userModel.Firstname,
Lastname = userModel.Lastname,
Username = userModel.Username,
PhoneNumber = userModel.PhoneNumber,
Email = userModel.Email,
BirthDate = userModel.BirthDate,
Address = userModel.Address,
UserRoles = roles
},
cancellationToken);
}
}

View File

@@ -0,0 +1,37 @@
using Hutopy.Infrastructure.BlobStorage.Contracts;
using Hutopy.Modules.Identity.Data;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public class GetCurrentUserPortraitHandler(
IdentityService identityService,
IBlobStorage blobStorage
)
: EndpointWithoutRequest<Stream>
{
public override void Configure()
{
Get("/api/users/portrait");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
CancellationToken cancellationToken)
{
var identityUser = await identityService.GetCurrentUserAsync();
if (identityUser is null)
{
await SendNotFoundAsync(cancellationToken);
return;
}
var stream = await blobStorage.DownloadFileAsync(
ContainerNames.Users,
$"{identityUser.Id.ToString()}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
cancellationToken);
await SendOkAsync(stream, cancellationToken);
}
}

View File

@@ -0,0 +1,79 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Configuration;
using Hutopy.Modules.Identity.Data;
using Microsoft.Extensions.Options;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record LoginRequest(
string Email,
string Password);
[PublicAPI]
public record LoginResponse(
string AccessToken,
string RefreshToken);
[PublicAPI]
public class LoginHandler(
UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions)
: Endpoint<LoginRequest, LoginResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/login");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
LoginRequest request,
CancellationToken ct)
{
// Find user by email
var user = await userManager.FindByEmailAsync(request.Email);
if (user is null)
{
await SendStringAsync(
"Invalid email or password",
401,
cancellation: ct);
return;
}
// Verify password
var isPasswordValid = await userManager.CheckPasswordAsync(user, request.Password);
if (!isPasswordValid)
{
await SendStringAsync(
"Invalid email or password",
401,
cancellation: ct);
return;
}
// Generate 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);
await SendOkAsync(
new LoginResponse(accessToken, user.RefreshToken),
cancellation: ct);
}
}

View File

@@ -0,0 +1,141 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Configuration;
using Hutopy.Modules.Identity.Data;
using Microsoft.Extensions.Options;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public class FacebookUserInfo
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("email")] public string? Email { get; init; } // Email might be null if not granted
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("picture")] public required FacebookPictureData Picture { get; init; }
}
[PublicAPI]
public class FacebookPictureData
{
[JsonPropertyName("data")] public required FacebookPicture Picture { get; init; }
}
[PublicAPI]
public class FacebookPicture
{
[JsonPropertyName("url")] public required string Url { get; init; }
}
[PublicAPI]
public record LoginWithFacebookRequest(
string Token);
[PublicAPI]
public record LoginWithFacebookResponse(
string AccessToken,
string RefreshToken);
[PublicAPI]
public class LoginWithFacebookHandler(
IHttpClientFactory httpClientFactory,
UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions)
: Endpoint<LoginWithFacebookRequest, LoginWithFacebookResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/login-with-facebook");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
LoginWithFacebookRequest request,
CancellationToken ct)
{
// Verify the token with Facebook
using var httpClient = httpClientFactory.CreateClient();
using var 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)
{
await SendStringAsync(
"The token is not valid",
400,
cancellation: ct);
return;
}
// Extract the user info (email, name, profile picture)
var content = await response.Content.ReadAsStringAsync(ct);
var userInfo = JsonSerializer.Deserialize<FacebookUserInfo>(content);
if (userInfo is null || string.IsNullOrEmpty(userInfo.Id))
{
await SendStringAsync(
"Failed to retrieve user information from Facebook",
400,
cancellation: ct);
return;
}
// Check if user exists or create a new one
var user = await userManager.FindByEmailAsync(userInfo.Email!);
if (user is null)
{
var generatedPassword = PasswordGenerator.Next();
var generatedUser = new User
{
UserName = userInfo.Email ?? $"fb_{userInfo.Id}",
Email = userInfo.Email,
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
};
var result = await userManager.CreateAsync(
generatedUser,
generatedPassword);
if (!result.Succeeded)
{
await SendStringAsync(
result.Errors.First().Description,
400,
cancellation: ct);
return;
}
user = generatedUser;
}
// Generate refresh token
var 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);
await SendOkAsync(
new LoginWithFacebookResponse(accessToken, refreshToken),
cancellation: ct);
}
}

View File

@@ -0,0 +1,145 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Configuration;
using Hutopy.Modules.Identity.Data;
using Microsoft.Extensions.Options;
namespace Hutopy.Modules.Identity.Handlers;
class GoogleToken
{
[JsonPropertyName("access_token")] public required string AccessToken { get; init; }
[JsonPropertyName("token_type")] public required string TokenType { get; init; }
[JsonPropertyName("expires_in")] public required int ExpiresIn { get; init; }
[JsonPropertyName("scope")] public required string Scope { get; init; }
[JsonPropertyName("authuser")] public required string AuthUser { get; init; }
[JsonPropertyName("prompt")] public required string Prompt { get; init; }
}
public class GoogleUserInfo
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("email")] public required string Email { get; init; }
[JsonPropertyName("verified_email")] public required bool VerifiedEmail { get; init; }
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("given_name")] public required string GivenName { get; init; }
[JsonPropertyName("family_name")] public string FamilyName { get; init; } = string.Empty;
[JsonPropertyName("picture")] public required string Picture { get; init; }
}
[PublicAPI]
public record LoginWithGoogleRequest(
string Token);
[PublicAPI]
public record LoginWithGoogleResponse(
string AccessToken,
string RefreshToken);
[PublicAPI]
public class LoginWithGoogleHandler(
IHttpClientFactory httpClientFactory,
UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions)
: Endpoint<LoginWithGoogleRequest, LoginWithGoogleResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/login-with-google");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
LoginWithGoogleRequest request,
CancellationToken ct)
{
var googleToken = JsonSerializer.Deserialize<GoogleToken>(request.Token)!;
// Verify the token with Google
using var httpClient = httpClientFactory.CreateClient();
using var response = await httpClient.GetAsync(
$"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}",
ct);
if (!response.IsSuccessStatusCode)
{
await SendStringAsync(
"The token is not valid",
400,
cancellation: ct);
return;
}
// Extract the user info (email, name, etc.).
var content = await response.Content.ReadAsStringAsync(ct);
var userInfo = JsonSerializer.Deserialize<GoogleUserInfo>(content);
if (userInfo is null
|| !userInfo.VerifiedEmail
|| string.IsNullOrEmpty(userInfo.Email))
{
await SendStringAsync(
"The token does not contain an email",
400,
cancellation: ct);
return;
}
// Check if user exists or create a new one
var user = await userManager.FindByEmailAsync(userInfo.Email);
if (user is null)
{
var generatedPassword = PasswordGenerator.Next();
var refreshToken = RefreshTokenGenerator.Next();
var generatedUser = new User
{
UserName = userInfo.Email,
Email = userInfo.Email,
Firstname = userInfo.GivenName,
Lastname = userInfo.FamilyName,
Alias = userInfo.Name,
PortraitUrl = userInfo.Picture,
GoogleId = userInfo.Id,
RefreshToken = refreshToken,
RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime)
};
var result = await userManager.CreateAsync(
generatedUser,
generatedPassword);
if (!result.Succeeded)
{
await SendStringAsync(
result.Errors.First().Description,
400,
cancellation: ct);
return;
}
user = generatedUser;
}
// Generate 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);
await SendOkAsync(
new LoginWithGoogleResponse(accessToken, user.RefreshToken),
cancellation: ct);
}
}

View File

@@ -0,0 +1,71 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Configuration;
using Hutopy.Modules.Identity.Data;
using Microsoft.Extensions.Options;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record RefreshTokenRequest(
string RefreshToken);
[PublicAPI]
public record RefreshTokenResponse(
string AccessToken,
string RefreshToken);
[PublicAPI]
public class RefreshTokenHandler(
UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions)
: Endpoint<RefreshTokenRequest, RefreshTokenResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/refresh");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
RefreshTokenRequest request,
CancellationToken ct)
{
// Find the user using the refresh token
var user = await userManager.Users
.FirstOrDefaultAsync(u => u.RefreshToken == request.RefreshToken, ct);
if (user == null || user.RefreshTokenExpiryTime <= DateTime.UtcNow)
{
await SendUnauthorizedAsync(ct);
return;
}
// Generate a new refresh token if rotation is required
if (jwtOptions.Value.RefreshTokenRequireRotation || user.RefreshToken is null)
{
user.RefreshToken = RefreshTokenGenerator.Next();
}
// Update refresh token expiry time
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
await userManager.UpdateAsync(user);
// Generate a new access 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 SendOkAsync(
new RefreshTokenResponse(accessToken, user.RefreshToken),
cancellation: ct);
}
}

View File

@@ -0,0 +1,97 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Configuration;
using Hutopy.Modules.Identity.Data;
using Microsoft.Extensions.Options;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record RegisterRequest(
string Email,
string Password,
string Name);
[PublicAPI]
public record RegisterResponse(
string AccessToken,
string RefreshToken);
[PublicAPI]
public class RegisterHandler(
UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions)
: Endpoint<RegisterRequest, RegisterResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/register");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
RegisterRequest request,
CancellationToken ct)
{
// Check if the user already exists
var existingUser = await userManager.FindByEmailAsync(request.Email);
if (existingUser is not null)
{
await SendStringAsync(
"A user with this email already exists",
400,
cancellation: ct);
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;
// Create a new user
var user = new User
{
UserName = request.Email,
Email = request.Email,
Firstname = firstname,
Lastname = lastname,
Alias = request.Name,
RefreshToken = refreshToken,
RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime)
};
var result = await userManager.CreateAsync(
user,
request.Password);
if (!result.Succeeded)
{
await SendStringAsync(
result.Errors.First().Description,
400,
cancellation: ct);
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 SendOkAsync(
new RegisterResponse(accessToken, user.RefreshToken),
cancellation: ct);
}
}

View File

@@ -0,0 +1,55 @@
using Hutopy.Modules.Identity.Data;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record ResetPasswordRequest(
string Email,
string Token,
string NewPassword);
[PublicAPI]
public class ResetPasswordHandler(
UserManager userManager)
: Endpoint<ResetPasswordRequest>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/reset-password");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ResetPasswordRequest request,
CancellationToken ct)
{
// Find user by email
var user = await userManager.FindByEmailAsync(request.Email);
if (user is null)
{
await SendStringAsync(
"Invalid request",
400,
cancellation: ct);
return;
}
// Reset password with token
var result = await userManager.ResetPasswordAsync(
user,
request.Token,
request.NewPassword);
if (!result.Succeeded)
{
await SendStringAsync(
"Invalid or expired token",
400,
cancellation: ct);
return;
}
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,50 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Data;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record SetPasswordRequest(
string NewPassword);
[PublicAPI]
public class SetPasswordHandler(
UserManager userManager)
: Endpoint<SetPasswordRequest>
{
public override void Configure()
{
Post("/api/users/set-password");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
SetPasswordRequest request,
CancellationToken ct)
{
// Get current user id from claims
var userId = User.GetUserId().ToString();
// Get user from database
var user = await userManager.FindByIdAsync(userId);
if (user is null)
{
await SendForbiddenAsync(ct);
return;
}
var resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
var result = await userManager.ResetPasswordAsync(user, resetToken, request.NewPassword);
if (!result.Succeeded)
{
await SendStringAsync(
result.Errors.First().Description,
400,
cancellation: ct);
return;
}
await SendOkAsync(ct);
}
}