feat(auth): adds local account authentication
This commit is contained in:
66
backend/src/Web/Features/Users/Handlers/ForgotPassword.cs
Normal file
66
backend/src/Web/Features/Users/Handlers/ForgotPassword.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
78
backend/src/Web/Features/Users/Handlers/Login.cs
Normal file
78
backend/src/Web/Features/Users/Handlers/Login.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
97
backend/src/Web/Features/Users/Handlers/Register.cs
Normal file
97
backend/src/Web/Features/Users/Handlers/Register.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
55
backend/src/Web/Features/Users/Handlers/ResetPassword.cs
Normal file
55
backend/src/Web/Features/Users/Handlers/ResetPassword.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
50
backend/src/Web/Features/Users/Handlers/SetPassword.cs
Normal file
50
backend/src/Web/Features/Users/Handlers/SetPassword.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
29
backend/src/Web/Features/Users/Services/IEmailSender.cs
Normal file
29
backend/src/Web/Features/Users/Services/IEmailSender.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
10
backend/src/Web/Features/Users/WebsiteOptions.cs
Normal file
10
backend/src/Web/Features/Users/WebsiteOptions.cs
Normal 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";
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
},
|
||||
"Stripe": {
|
||||
"HutopyRate": 0.05
|
||||
},
|
||||
"Website": {
|
||||
"FrontendBaseUrl": "https://hutopy.ca"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user