Skip to content

Commit

Permalink
Resending the one time password for login and signup does (#666)
Browse files Browse the repository at this point in the history
### Summary & Motivation

Introduce the ability to resend one-time passwords during the signup and
login processes. To achieve this, new `signup/resend-code` and
`login/resend-code` API endpoints have been implemented, along with
`ResendSignupCodeCommand` and `ResendLoginCodeCommand`. The Signup and
Login aggregates have been updated with a new `UpdateVerificationCode`
method to support this functionality.

The link to resend the login code has been moved below the Verify button
for simplicity. When a code is resent, the verification timeout is
extended by an additional 5 minutes. A 30-second cooldown between
sending verification codes has also been introduced to prevent abuse.

### Checklist

- [x] I have added tests, or done manual regression tests
- [x] I have updated the documentation, if necessary
  • Loading branch information
tjementum authored Jan 6, 2025
2 parents 3afc8ac + 4921901 commit 89f3997
Show file tree
Hide file tree
Showing 13 changed files with 373 additions and 76 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
=> await mediator.Send(command with { Id = id })
).AllowAnonymous();

group.MapPost("login/{id}/resend", async Task<ApiResult<ResendLoginCodeResponse>> (LoginId id, IMediator mediator)
=> await mediator.Send(new ResendLoginCodeCommand { Id = id })
).Produces<ResendLoginCodeResponse>().AllowAnonymous();

group.MapPost("logout", async Task<ApiResult> (IMediator mediator)
=> await mediator.Send(new LogoutCommand())
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,9 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
group.MapPost("{id}/complete", async Task<ApiResult> (SignupId id, CompleteSignupCommand command, IMediator mediator)
=> await mediator.Send(command with { Id = id })
).AllowAnonymous();

group.MapPost("{id}/resend-code", async Task<ApiResult<ResendSignupCodeResponse>> (SignupId id, IMediator mediator)
=> await mediator.Send(new ResendSignupCodeCommand { Id = id })
).Produces<ResendSignupCodeResponse>().AllowAnonymous();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using JetBrains.Annotations;
using Microsoft.AspNetCore.Identity;
using PlatformPlatform.AccountManagement.Features.Authentication.Domain;
using PlatformPlatform.AccountManagement.Features.Users.Domain;
using PlatformPlatform.SharedKernel.Authentication;
using PlatformPlatform.SharedKernel.Cqrs;
using PlatformPlatform.SharedKernel.Integrations.Email;
using PlatformPlatform.SharedKernel.Telemetry;

namespace PlatformPlatform.AccountManagement.Features.Authentication.Commands;

[PublicAPI]
public sealed record ResendLoginCodeCommand : ICommand, IRequest<Result<ResendLoginCodeResponse>>
{
[JsonIgnore] // Removes this property from the API contract
public LoginId Id { get; init; } = null!;
}

[PublicAPI]
public sealed record ResendLoginCodeResponse(int ValidForSeconds);

public sealed class ResendLoginCodeCommandHandler(
ILoginRepository loginRepository,
IUserRepository userRepository,
IEmailClient emailClient,
IPasswordHasher<object> passwordHasher,
ITelemetryEventsCollector events,
ILogger<ResendLoginCodeCommandHandler> logger
) : IRequestHandler<ResendLoginCodeCommand, Result<ResendLoginCodeResponse>>
{
public async Task<Result<ResendLoginCodeResponse>> Handle(ResendLoginCodeCommand command, CancellationToken cancellationToken)
{
var login = await loginRepository.GetByIdAsync(command.Id, cancellationToken);
if (login is null)
{
return Result<ResendLoginCodeResponse>.NotFound($"Login with id '{command.Id}' not found.");
}

if (login.Completed)
{
logger.LogWarning("Login with id '{LoginId}' has already been completed.", login.Id);
return Result<ResendLoginCodeResponse>.BadRequest($"The login with id {login.Id} has already been completed.");
}

if (login.ModifiedAt > TimeProvider.System.GetUtcNow().AddSeconds(-30))
{
return Result<ResendLoginCodeResponse>.BadRequest("You must wait at least 30 seconds before requesting a new code.");
}

var user = await userRepository.GetByIdUnfilteredAsync(login.UserId, cancellationToken);

var oneTimePassword = OneTimePasswordHelper.GenerateOneTimePassword(6);
var oneTimePasswordHash = passwordHasher.HashPassword(this, oneTimePassword);
login.UpdateVerificationCode(oneTimePasswordHash);
loginRepository.Update(login);

var secondsSinceLoginStarted = (TimeProvider.System.GetUtcNow() - login.CreatedAt).TotalSeconds;
events.CollectEvent(new LoginCodeResend(login.UserId, (int)secondsSinceLoginStarted));

await emailClient.SendAsync(user!.Email, "PlatformPlatform login verification code",
$"""
<h1 style="text-align:center;font-family=sans-serif;font-size:20px">Your confirmation code is below</h1>
<p style="text-align:center;font-family=sans-serif;font-size:16px">Enter it in your open browser window. It is only valid for a few minutes.</p>
<p style="text-align:center;font-family=sans-serif;font-size:40px;background:#f5f4f5">{oneTimePassword}</p>
""",
cancellationToken
);

return new ResendLoginCodeResponse(Login.ValidForSeconds);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ public void MarkAsCompleted()

Completed = true;
}

public void UpdateVerificationCode(string oneTimePasswordHash)
{
if (Completed)
{
throw new UnreachableException("Cannot regenerate verification code for completed login");
}

ValidUntil = TimeProvider.System.GetUtcNow().AddSeconds(ValidForSeconds);
OneTimePasswordHash = oneTimePasswordHash;
}
}

[PublicAPI]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using JetBrains.Annotations;
using Microsoft.AspNetCore.Identity;
using PlatformPlatform.AccountManagement.Features.Signups.Domain;
using PlatformPlatform.SharedKernel.Authentication;
using PlatformPlatform.SharedKernel.Cqrs;
using PlatformPlatform.SharedKernel.Integrations.Email;
using PlatformPlatform.SharedKernel.Telemetry;

namespace PlatformPlatform.AccountManagement.Features.Signups.Commands;

[PublicAPI]
public sealed record ResendSignupCodeCommand : ICommand, IRequest<Result<ResendSignupCodeResponse>>
{
[JsonIgnore] // Removes this property from the API contract
public SignupId Id { get; init; } = null!;
}

[PublicAPI]
public sealed record ResendSignupCodeResponse(int ValidForSeconds);

public sealed class ResendSignupCodeCommandHandler(
ISignupRepository signupRepository,
IEmailClient emailClient,
IPasswordHasher<object> passwordHasher,
ITelemetryEventsCollector events,
ILogger<ResendSignupCodeCommandHandler> logger
) : IRequestHandler<ResendSignupCodeCommand, Result<ResendSignupCodeResponse>>
{
public async Task<Result<ResendSignupCodeResponse>> Handle(ResendSignupCodeCommand command, CancellationToken cancellationToken)
{
var signup = await signupRepository.GetByIdAsync(command.Id, cancellationToken);
if (signup is null)
{
return Result<ResendSignupCodeResponse>.NotFound($"Signup with id '{command.Id}' not found.");
}

if (signup.Completed)
{
logger.LogWarning("Signup with id '{SignupId}' has already been completed.", signup.Id);
return Result<ResendSignupCodeResponse>.BadRequest($"The signup with id {signup.Id} has already been completed.");
}

if (signup.ModifiedAt > TimeProvider.System.GetUtcNow().AddSeconds(-30))
{
return Result<ResendSignupCodeResponse>.BadRequest("You must wait at least 30 seconds before requesting a new code.");
}

var oneTimePassword = OneTimePasswordHelper.GenerateOneTimePassword(6);
var oneTimePasswordHash = passwordHasher.HashPassword(this, oneTimePassword);
signup.UpdateVerificationCode(oneTimePasswordHash);
signupRepository.Update(signup);

var secondsSinceSignupStarted = (TimeProvider.System.GetUtcNow() - signup.CreatedAt).TotalSeconds;
events.CollectEvent(new SignupCodeResend(signup.TenantId, (int)secondsSinceSignupStarted));

await emailClient.SendAsync(signup.Email, "Confirm your email address",
$"""
<h1 style="text-align:center;font-family=sans-serif;font-size:20px">Your confirmation code is below</h1>
<p style="text-align:center;font-family=sans-serif;font-size:16px">Enter it in your open browser window. It is only valid for a few minutes.</p>
<p style="text-align:center;font-family=sans-serif;font-size:40px;background:#f5f4f5">{oneTimePassword}</p>
""",
cancellationToken
);


return new ResendSignupCodeResponse(Signup.ValidForSeconds);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public TenantId GetTenantId()
}

[PublicAPI]
public sealed record StartSignupResponse(string SignupId, int ValidForSeconds);
public sealed record StartSignupResponse(SignupId SignupId, int ValidForSeconds);

public sealed class StartSignupValidator : AbstractValidator<StartSignupCommand>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ public void MarkAsCompleted()

Completed = true;
}

public void UpdateVerificationCode(string oneTimePasswordHash)
{
if (Completed)
{
throw new UnreachableException("Cannot regenerate verification code for completed signup");
}

ValidUntil = TimeProvider.System.GetUtcNow().AddSeconds(ValidForSeconds);
OneTimePasswordHash = oneTimePasswordHash;
}
}

[PublicAPI]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ public sealed class LoginFailed(UserId userId, int retryCount)
public sealed class LoginStarted(UserId userId)
: TelemetryEvent(("user_id", userId));

public sealed class LoginCodeResend(UserId userId, int secondsSinceSignupStarted)
: TelemetryEvent(("user_id", userId), ("seconds_since_login_started", secondsSinceSignupStarted));

public sealed class Logout
: TelemetryEvent;

Expand All @@ -38,6 +41,9 @@ public sealed class SignupBlocked(TenantId tenantId, int retryCount)
public sealed class SignupCompleted(TenantId tenantId, int signupTimeInSeconds)
: TelemetryEvent(("tenant_id", tenantId), ("signup_time_in_seconds", signupTimeInSeconds));

public sealed class SignupCodeResend(TenantId tenantId, int secondsSinceSignupStarted)
: TelemetryEvent(("tenant_id", tenantId), ("seconds_since_signup_started", secondsSinceSignupStarted));

public sealed class SignupExpired(TenantId tenantId, int secondsFromCreation)
: TelemetryEvent(("tenant_id", tenantId), ("seconds_from_creation", secondsFromCreation));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public async Task StartSignup_WhenSubdomainIsFreeAndEmailIsValid_ShouldReturnSuc
response.EnsureSuccessStatusCode();
var responseBody = await response.DeserializeResponse<StartSignupResponse>();
responseBody.Should().NotBeNull();
responseBody!.SignupId.Should().NotBeNullOrEmpty();
responseBody!.SignupId.ToString().Should().NotBeNullOrEmpty();
responseBody.ValidForSeconds.Should().Be(300);

TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1);
Expand Down
84 changes: 52 additions & 32 deletions application/account-management/WebApp/routes/login/verify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { OneTimeCodeInput } from "@repo/ui/components/OneTimeCodeInput";
import { useExpirationTimeout } from "@repo/ui/hooks/useExpiration";
import logoMarkUrl from "@/shared/images/logo-mark.svg";
import poweredByUrl from "@/shared/images/powered-by.svg";
import { getLoginState } from "./-shared/loginState";
import { getLoginState, setLoginState } from "./-shared/loginState";
import { api } from "@/shared/lib/api/client";
import { FormErrorMessage } from "@repo/ui/components/FormErrorMessage";
import { loggedInPath } from "@repo/infrastructure/auth/constants";
Expand Down Expand Up @@ -50,6 +50,20 @@ export function CompleteLoginForm() {
}
);

const [{ success: resendSuccess, data: resendData }, resendAction] = useActionState(
api.actionPost("/api/account-management/authentication/login/{id}/resend"),
{ success: null }
);

useEffect(() => {
if (resendSuccess && resendData) {
setLoginState({
...getLoginState(),
expireAt: new Date(Date.now() + resendData.validForSeconds * 1000)
});
}
}, [resendSuccess, resendData]);

useEffect(() => {
if (success) {
window.location.href = loggedInPath;
Expand All @@ -63,42 +77,48 @@ export function CompleteLoginForm() {
}, [isExpired]);

return (
<Form action={action} validationErrors={errors} validationBehavior="aria" className="w-full max-w-sm space-y-3">
<input type="hidden" name="id" value={loginId} />
<div className="flex w-full flex-col gap-4 rounded-lg px-6 pt-8 pb-4">
<div className="flex justify-center">
<Link href="/">
<img src={logoMarkUrl} className="h-12 w-12" alt={t`Logo`} />
</Link>
</div>
<h1 className="mb-3 w-full text-center text-2xl">
<Trans>Enter your verification code</Trans>
</h1>
<div className="text-center text-gray-500 text-sm">
<Trans>
Please check your email for a verification code sent to <span className="font-semibold">{email}</span>
</Trans>
</div>
<div className="flex w-full flex-col gap-4">
<OneTimeCodeInput name="oneTimePassword" digitPattern={DigitPattern.DigitsAndChars} length={6} autoFocus />
<div className="text-center text-neutral-500 text-xs">
<div className="w-full max-w-sm space-y-3">
<Form action={action} validationErrors={errors} validationBehavior="aria">
<input type="hidden" name="id" value={loginId} />
<div className="flex w-full flex-col gap-4 rounded-lg px-6 pt-8 pb-4">
<div className="flex justify-center">
<Link href="/">
<Trans>Didn't receive the code? Resend</Trans>
<img src={logoMarkUrl} className="h-12 w-12" alt={t`Logo`} />
</Link>
<span className="font-normal tabular-nums leading-none">({expiresInString})</span>
</div>
<h1 className="mb-3 w-full text-center text-2xl">
<Trans>Enter your verification code</Trans>
</h1>
<div className="text-center text-gray-500 text-sm">
<Trans>
Please check your email for a verification code sent to <span className="font-semibold">{email}</span>
</Trans>
</div>
<div className="flex w-full flex-col gap-4">
<OneTimeCodeInput name="oneTimePassword" digitPattern={DigitPattern.DigitsAndChars} length={6} autoFocus />
</div>
<FormErrorMessage title={title} message={message} />
<Button type="submit" className="mt-4 w-full text-center">
<Trans>Verify</Trans>
</Button>
</div>
<FormErrorMessage title={title} message={message} />
<Button type="submit" className="mt-4 w-full text-center">
<Trans>Verify</Trans>
</Button>
<div className="flex flex-col items-center gap-6 text-neutral-500">
<p className="text-xs ">
<Trans>Can't find your code? Check your spam folder.</Trans>
</p>
<img src={poweredByUrl} alt={t`Powered by`} />
</Form>

<div className="flex flex-col items-center gap-6 text-neutral-500 px-6">
<div className="text-center text-neutral-500 text-xs">
<Form action={resendAction} className="inline">
<input type="hidden" name="id" value={loginId} />
<Button type="submit" variant="link" className="text-xs p-0 h-auto">
<Trans>Didn't receive the code? Resend</Trans>
</Button>
</Form>
<span className="font-normal tabular-nums leading-none ml-1">({expiresInString})</span>
</div>
<p className="text-xs">
<Trans>Can't find your code? Check your spam folder.</Trans>
</p>
<img src={poweredByUrl} alt={t`Powered by`} />
</div>
</Form>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function StartSignupForm() {
const { signupId, validForSeconds } = data;

setSignupState({
signupId: signupId,
signupId,
email,
expireAt: new Date(Date.now() + validForSeconds * 1000)
});
Expand Down
Loading

0 comments on commit 89f3997

Please sign in to comment.