feat(auth): adds local account authentication

This commit is contained in:
2025-05-12 15:45:12 -04:00
parent 6d7282870d
commit fdfca7c757
24 changed files with 1446 additions and 279 deletions

View File

@@ -0,0 +1,66 @@
using Hutopy.Web.Features.Users;
using Hutopy.Web.Features.Users.Data;
using Hutopy.Web.Features.Users.Services;
using Microsoft.Extensions.Options;
using System.Text;
using System.Web;
namespace Hutopy.Web.Features.Users.Handlers;
[PublicAPI]
public record ForgotPasswordRequest(
string Email);
[PublicAPI]
public class ForgotPasswordHandler(
IdentityUserManager userManager,
IEmailSender emailSender,
ILogger<ForgotPasswordHandler> logger,
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.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,78 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Users.Data;
using Microsoft.Extensions.Options;
namespace Hutopy.Web.Features.Users.Handlers;
[PublicAPI]
public record LoginRequest(
string Email,
string Password);
[PublicAPI]
public record LoginResponse(
string AccessToken,
string RefreshToken);
[PublicAPI]
public class LoginHandler(
IdentityUserManager 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

@@ -2,7 +2,6 @@
using System.Text.Json.Serialization;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Users.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using IdentityUser = Hutopy.Web.Features.Users.Data.IdentityUser;

View File

@@ -2,7 +2,6 @@
using System.Text.Json.Serialization;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Users.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using IdentityUser = Hutopy.Web.Features.Users.Data.IdentityUser;

View File

@@ -0,0 +1,97 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Users.Data;
using Microsoft.Extensions.Options;
using IdentityUser = Hutopy.Web.Features.Users.Data.IdentityUser;
namespace Hutopy.Web.Features.Users.Handlers;
[PublicAPI]
public record RegisterRequest(
string Email,
string Password,
string Name);
[PublicAPI]
public record RegisterResponse(
string AccessToken,
string RefreshToken);
[PublicAPI]
public class RegisterHandler(
IdentityUserManager 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 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 refresh token
var refreshToken = RefreshTokenGenerator.Next();
// Split 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 new user
var user = new IdentityUser
{
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.Web.Features.Users.Data;
namespace Hutopy.Web.Features.Users.Handlers;
[PublicAPI]
public record ResetPasswordRequest(
string Email,
string Token,
string NewPassword);
[PublicAPI]
public class ResetPasswordHandler(
IdentityUserManager 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.Web.Common.Security;
using Hutopy.Web.Features.Users.Data;
namespace Hutopy.Web.Features.Users.Handlers;
[PublicAPI]
public record SetPasswordRequest(
string NewPassword);
[PublicAPI]
public class SetPasswordHandler(
IdentityUserManager 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);
}
}

View File

@@ -0,0 +1,29 @@
using System.Threading.Tasks;
namespace Hutopy.Web.Features.Users.Services;
public interface IEmailSender
{
Task SendEmailAsync(string email, string subject, string message);
}
public class EmailSender(ILogger<IEmailSender> logger)
: IEmailSender
{
public async Task SendEmailAsync(string email, string subject, string message)
{
try
{
logger.LogInformation("Sending email to {Email} with subject: {Subject}", email, subject);
// TODO: Implement actual email sending logic
await Task.Delay(1000);
logger.LogInformation("Email sent successfully to {Email}", email);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send email to {Email}", email);
throw;
}
}
}

View File

@@ -0,0 +1,10 @@
using System;
namespace Hutopy.Web.Features.Users;
public class WebsiteOptions
{
public const string SectionName = "Website";
public string FrontendBaseUrl { get; set; } = "https://localhost:5173";
}

View File

@@ -8,6 +8,7 @@ using Hutopy.Web.Features.Messages;
using Hutopy.Web.Features.Messages.Data;
using Hutopy.Web.Features.Users;
using Hutopy.Web.Features.Users.Data;
using Hutopy.Web.Features.Users.Services;
using Microsoft.AspNetCore.HttpOverrides;
using NSwag;
using NSwag.Generation.AspNetCore.Processors;
@@ -36,6 +37,8 @@ builder.Services.AddCors(options =>
});
});
builder.Services.AddTransient<IEmailSender, EmailSender>();
// Add services to the container.
builder.Services.AddWebServices();
builder.Services.AddAuthorizationAndAuthentication(builder.Configuration);
@@ -84,6 +87,7 @@ builder.AddMembershipModule(
o => o.MigrationsHistoryTable("__EFMigrationsHistory", MembershipDbContext.SchemaName)));
builder.Services.Configure<JwtOptions>(builder.Configuration.GetRequiredSection(JwtOptions.SectionName));
builder.Services.Configure<WebsiteOptions>(builder.Configuration.GetRequiredSection(WebsiteOptions.SectionName));
var app = builder.Build();

View File

@@ -6,5 +6,14 @@
"SecretKey": "sk_test_51OoveVDrRyqXtNdBaOs1DFFja0XhrQtJoAo83uSySMuqw4Wyt9NsuugrIHRqet9a50cr5GvolpTP8EZuTSttcgYx00gOUPNDoI",
"WebhookSecret": "whsec_cee07ef14cf784850cab63567048b5326fec7fd29c03f4659476524f8299aff1",
"HutopyRate": 0.05
},
"Website": {
"FrontendBaseUrl": "https://localhost:5173"
},
"Authentication": {
"Jwt": {
"Lifetime": "00:05:00",
"RefreshTokenLifetime": "0.00:30:00"
}
}
}

View File

@@ -4,5 +4,8 @@
},
"Stripe": {
"HutopyRate": 0.05
},
"Website": {
"FrontendBaseUrl": "https://hutopy.ca"
}
}