Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions modules/Email/src/SimpleModule.Email/EmailModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config

// Replace Identity's ConsoleEmailSender with a real one
services.AddScoped<IEmailSender<ApplicationUser>, IdentityEmailSender>();
services.AddScoped<IAccountUnlockEmailSender, IdentityAccountUnlockEmailSender>();

services.AddModuleJob<SendEmailJob>();
services.AddModuleJob<RetryFailedEmailsJob>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using SimpleModule.Email.Contracts;
using SimpleModule.Users.Contracts;

namespace SimpleModule.Email.Services;

public class IdentityAccountUnlockEmailSender(IEmailContracts emailContracts)
: IAccountUnlockEmailSender
{
public async Task SendUnlockLinkAsync(string email, string unlockLink)
{
await emailContracts.SendEmailAsync(
new SendEmailRequest
{
To = email,
Subject = "Unlock your account",
Body =
$"""Your account has been locked due to multiple failed sign-in attempts. <a href="{unlockLink}">Click here to unlock your account</a>.""",
IsHtml = true,
}
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using SimpleModule.Core.Events;

namespace SimpleModule.Users.Contracts.Events;

public sealed record UserSelfUnlockedEvent(UserId UserId, string Email) : IEvent;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace SimpleModule.Users.Contracts;

public interface IAccountUnlockEmailSender
{
Task SendUnlockLinkAsync(string email, string unlockLink);
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public static class Routes
public const string LoginWith2fa = "/LoginWith2fa";
public const string LoginWithRecoveryCode = "/LoginWithRecoveryCode";
public const string Lockout = "/Lockout";
public const string SendUnlockEmail = "/SendUnlockEmail";
public const string SendUnlockEmailConfirmation = "/SendUnlockEmailConfirmation";
public const string UnlockAccount = "/UnlockAccount";
public const string AccessDenied = "/AccessDenied";
public const string Error = "/Error";
public const string RegisterConfirmation = "/RegisterConfirmation";
Expand Down
14 changes: 13 additions & 1 deletion modules/Users/src/SimpleModule.Users/Pages/Account/Lockout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Link } from '@inertiajs/react';
import { Card, CardContent, Container } from '@simplemodule/ui';

export default function Lockout() {
Expand All @@ -8,9 +9,20 @@ export default function Lockout() {
<Card>
<CardContent className="p-8">
<h1 className="text-xl font-bold text-danger mb-2">Locked out</h1>
<p className="text-sm text-danger">
<p className="text-sm text-danger mb-4">
This account has been locked out, please try again later.
</p>
<hr className="mb-4" />
<p className="text-sm text-text-muted">
You can also{' '}
<Link
href="/Identity/Account/SendUnlockEmail"
className="text-primary underline hover:no-underline"
>
receive an unlock link by email
</Link>
.
</p>
</CardContent>
</Card>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { router } from '@inertiajs/react';
import {
Button,
Card,
CardContent,
Container,
Field,
FieldGroup,
Input,
Label,
} from '@simplemodule/ui';

export default function SendUnlockEmail() {
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
router.post('/Identity/Account/SendUnlockEmail', formData);
}

return (
<Container size="sm">
<div className="flex items-center justify-center min-h-[calc(100vh-12rem)]">
<div className="w-full max-w-md">
<Card>
<CardContent className="p-8">
<h1 className="text-xl font-bold mb-2">Unlock your account</h1>
<p className="text-sm text-text-muted mb-6">
Enter your email to receive an unlock link.
</p>
<hr className="mb-6" />
<form onSubmit={handleSubmit}>
<FieldGroup>
<Field>
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
required
autoComplete="username"
placeholder="name@example.com"
/>
</Field>
<Button type="submit" className="w-full">
Send Unlock Link
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
</div>
</Container>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Card, CardContent, Container } from '@simplemodule/ui';

export default function SendUnlockEmailConfirmation() {
return (
<Container size="sm">
<div className="flex items-center justify-center min-h-[calc(100vh-12rem)]">
<div className="w-full max-w-md">
<Card>
<CardContent className="p-8">
<h1 className="text-xl font-bold mb-4">Check your email</h1>
<p className="text-sm">
If an account with that email exists and is currently locked, we've sent an unlock
link. Please check your email.
</p>
</CardContent>
</Card>
</div>
</div>
</Container>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using SimpleModule.Core;
using SimpleModule.Core.Inertia;
using SimpleModule.Users.Contracts;

namespace SimpleModule.Users.Pages.Account;

public class SendUnlockEmailConfirmationEndpoint : IViewEndpoint
{
public const string Route = UsersConstants.Routes.SendUnlockEmailConfirmation;

public void Map(IEndpointRouteBuilder app)
{
app.MapGet(Route, () => Inertia.Render("Users/Account/SendUnlockEmailConfirmation"))
.AllowAnonymous();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.WebUtilities;
using SimpleModule.Core;
using SimpleModule.Core.Inertia;
using SimpleModule.Core.RateLimiting;
using SimpleModule.Users.Contracts;

namespace SimpleModule.Users.Pages.Account;

public class SendUnlockEmailEndpoint : IViewEndpoint
{
public const string Route = UsersConstants.Routes.SendUnlockEmail;

public void Map(IEndpointRouteBuilder app)
{
app.MapGet(Route, () => Inertia.Render("Users/Account/SendUnlockEmail")).AllowAnonymous();

app.MapPost(
Route,
async (
[FromForm] string email,
UserManager<ApplicationUser> userManager,
IAccountUnlockEmailSender unlockEmailSender,
HttpContext context
) =>
{
var user = await userManager.FindByEmailAsync(email);
if (user is not null && await userManager.IsLockedOutAsync(user))
{
var code = await userManager.GenerateUserTokenAsync(
user,
TokenOptions.DefaultProvider,
"AccountUnlock"
);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));

var request = context.Request;
var baseUrl = $"{request.Scheme}://{request.Host}";
var callbackUrl =
$"{baseUrl}{UsersConstants.ViewPrefix}{UsersConstants.Routes.UnlockAccount}?userId={Uri.EscapeDataString(user.Id)}&code={Uri.EscapeDataString(code)}";

await unlockEmailSender.SendUnlockLinkAsync(email, callbackUrl);
}

// Always redirect to confirmation — don't reveal whether the email exists or is locked
return TypedResults.Redirect(
$"{UsersConstants.ViewPrefix}{UsersConstants.Routes.SendUnlockEmailConfirmation}"
);
}
)
.AllowAnonymous()
.DisableAntiforgery()
.RateLimit("auth-strict");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Link } from '@inertiajs/react';
import { Button, Card, CardContent, Container } from '@simplemodule/ui';

interface Props {
success: boolean;
message: string;
}

export default function UnlockAccount({ success, message }: Props) {
return (
<Container size="sm">
<div className="flex items-center justify-center min-h-[calc(100vh-12rem)]">
<div className="w-full max-w-md">
<Card>
<CardContent className="p-8">
<h1
className={`text-xl font-bold mb-4 ${success ? 'text-success' : 'text-danger'}`}
>
{success ? 'Account unlocked' : 'Unlock failed'}
</h1>
<p className="text-sm mb-6">{message}</p>
{success && (
<Link href="/Identity/Account/Login">
<Button className="w-full">Sign in</Button>
</Link>
)}
</CardContent>
</Card>
</div>
</div>
</Container>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.WebUtilities;
using SimpleModule.Core;
using SimpleModule.Core.Inertia;
using SimpleModule.Users.Contracts;
using SimpleModule.Users.Contracts.Events;
using Wolverine;

namespace SimpleModule.Users.Pages.Account;

public class UnlockAccountEndpoint : IViewEndpoint
{
public const string Route = UsersConstants.Routes.UnlockAccount;

public void Map(IEndpointRouteBuilder app)
{
app.MapGet(
Route,
async Task<IResult> (
[FromQuery] string? userId,
[FromQuery] string? code,
UserManager<ApplicationUser> userManager,
IMessageBus bus
) =>
{
if (userId is null || code is null)
{
return TypedResults.Redirect("/");
}

var user = await userManager.FindByIdAsync(userId);
if (user is null)
{
return Inertia.Render(
"Users/Account/UnlockAccount",
new { success = false, message = "Unable to unlock account." }
);
}

var decodedCode = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
var isValid = await userManager.VerifyUserTokenAsync(
user,
TokenOptions.DefaultProvider,
"AccountUnlock",
decodedCode
);

if (!isValid)
{
return Inertia.Render(
"Users/Account/UnlockAccount",
new
{
success = false,
message = "Invalid or expired unlock link.",
}
);
}

user.LockoutEnd = null;
user.AccessFailedCount = 0;
await userManager.UpdateAsync(user);
await userManager.UpdateSecurityStampAsync(user);

await bus.PublishAsync(
new UserSelfUnlockedEvent(
UserId.From(user.Id),
user.Email ?? string.Empty
)
);

return Inertia.Render(
"Users/Account/UnlockAccount",
new
{
success = true,
message = "Your account has been unlocked successfully.",
}
);
}
)
.AllowAnonymous();
}
}
4 changes: 4 additions & 0 deletions modules/Users/src/SimpleModule.Users/Pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export const pages: Record<string, unknown> = {
'Users/Account/LoginWith2fa': () => import('./Account/LoginWith2fa'),
'Users/Account/LoginWithRecoveryCode': () => import('./Account/LoginWithRecoveryCode'),
'Users/Account/Lockout': () => import('./Account/Lockout'),
'Users/Account/SendUnlockEmail': () => import('./Account/SendUnlockEmail'),
'Users/Account/SendUnlockEmailConfirmation': () =>
import('./Account/SendUnlockEmailConfirmation'),
'Users/Account/UnlockAccount': () => import('./Account/UnlockAccount'),
'Users/Account/AccessDenied': () => import('./Account/AccessDenied'),
'Users/Account/Error': () => import('./Account/Error'),
'Users/Account/ExternalLogin': () => import('./Account/ExternalLogin'),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Microsoft.Extensions.Logging;
using SimpleModule.Users.Contracts;

namespace SimpleModule.Users.Services;

public partial class ConsoleAccountUnlockEmailSender(
ILogger<ConsoleAccountUnlockEmailSender> logger
) : IAccountUnlockEmailSender
{
public Task SendUnlockLinkAsync(string email, string unlockLink)
{
LogUnlockLink(logger, email, unlockLink);
return Task.CompletedTask;
}

[LoggerMessage(Level = LogLevel.Information, Message = "Account unlock for {Email}: {Link}")]
private static partial void LogUnlockLink(ILogger logger, string email, string link);
}
Loading
Loading