.Forbidden("Too many attempts, please request a new code.", true);
+ }
+
+ var oneTimePassword = OneTimePasswordHelper.GenerateOneTimePassword(6);
+ var oneTimePasswordHash = passwordHasher.HashPassword(this, oneTimePassword);
+ emailLogin.UpdateVerificationCode(oneTimePasswordHash, timeProvider.GetUtcNow());
+ emailLoginRepository.Update(emailLogin);
+
+ var secondsSinceStarted = (timeProvider.GetUtcNow() - emailLogin.CreatedAt).TotalSeconds;
+ events.CollectEvent(new EmailLoginCodeResend((int)secondsSinceStarted));
+
+ await emailClient.SendAsync(emailLogin.Email, "Your verification code (resend)",
+ $"""
+ Here's your new verification code
+ We're sending this code again as you requested.
+ {oneTimePassword}
+ This code will expire in a few minutes.
+ """,
+ cancellationToken
+ );
+
+ return new ResendEmailLoginCodeResponse(EmailLogin.ValidForSeconds);
+ }
+}
diff --git a/application/account-management/Core/Features/Authentication/Commands/StartLogin.cs b/application/account-management/Core/Features/EmailAuthentication/Commands/StartEmailLogin.cs
similarity index 51%
rename from application/account-management/Core/Features/Authentication/Commands/StartLogin.cs
rename to application/account-management/Core/Features/EmailAuthentication/Commands/StartEmailLogin.cs
index fa779e73c..e385aed45 100644
--- a/application/account-management/Core/Features/Authentication/Commands/StartLogin.cs
+++ b/application/account-management/Core/Features/EmailAuthentication/Commands/StartEmailLogin.cs
@@ -1,40 +1,38 @@
using FluentValidation;
using JetBrains.Annotations;
-using PlatformPlatform.AccountManagement.Features.Authentication.Domain;
-using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands;
-using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain;
+using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain;
+using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Shared;
using PlatformPlatform.AccountManagement.Features.Users.Domain;
using PlatformPlatform.SharedKernel.Cqrs;
using PlatformPlatform.SharedKernel.Integrations.Email;
using PlatformPlatform.SharedKernel.Telemetry;
using PlatformPlatform.SharedKernel.Validation;
-namespace PlatformPlatform.AccountManagement.Features.Authentication.Commands;
+namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Commands;
[PublicAPI]
-public sealed record StartLoginCommand(string Email) : ICommand, IRequest>
+public sealed record StartEmailLoginCommand(string Email) : ICommand, IRequest>
{
public string Email { get; init; } = Email.Trim().ToLower();
}
[PublicAPI]
-public sealed record StartLoginResponse(LoginId LoginId, EmailConfirmationId EmailConfirmationId, int ValidForSeconds);
+public sealed record StartEmailLoginResponse(EmailLoginId EmailLoginId, int ValidForSeconds);
-public sealed class StartLoginValidator : AbstractValidator
+public sealed class StartEmailLoginValidator : AbstractValidator
{
- public StartLoginValidator()
+ public StartEmailLoginValidator()
{
RuleFor(x => x.Email).SetValidator(new SharedValidations.Email());
}
}
-public sealed class StartLoginHandler(
+public sealed class StartEmailLoginHandler(
IUserRepository userRepository,
- ILoginRepository loginRepository,
IEmailClient emailClient,
- IMediator mediator,
+ StartEmailConfirmation startEmailConfirmation,
ITelemetryEventsCollector events
-) : IRequestHandler>
+) : IRequestHandler>
{
private const string UnknownUserEmailTemplate =
"""
@@ -50,7 +48,7 @@ ITelemetryEventsCollector events
{oneTimePassword}
""";
- public async Task> Handle(StartLoginCommand command, CancellationToken cancellationToken)
+ public async Task> Handle(StartEmailLoginCommand command, CancellationToken cancellationToken)
{
var user = await userRepository.GetUserByEmailUnfilteredAsync(command.Email, cancellationToken);
@@ -61,26 +59,17 @@ await emailClient.SendAsync(command.Email.ToLower(), "Unknown user tried to logi
cancellationToken
);
- // Return a fake login process id to the client, so an attacker can't guess if the email is valid or not
- return new StartLoginResponse(LoginId.NewId(), EmailConfirmationId.NewId(), EmailConfirmation.ValidForSeconds);
+ return new StartEmailLoginResponse(EmailLoginId.NewId(), EmailLogin.ValidForSeconds);
}
- var result = await mediator.Send(
- new StartEmailConfirmationCommand(
- user.Email,
- "PlatformPlatform login verification code",
- LoginEmailTemplate,
- EmailConfirmationType.Login
- ),
- cancellationToken
+ var result = await startEmailConfirmation.StartAsync(
+ user.Email, "PlatformPlatform login verification code", LoginEmailTemplate, EmailLoginType.Login, cancellationToken
);
- if (!result.IsSuccess) return Result.From(result);
+ if (!result.IsSuccess) return Result.From(result);
- var login = Login.Create(user, result.Value!.EmailConfirmationId);
- await loginRepository.AddAsync(login, cancellationToken);
- events.CollectEvent(new LoginStarted(user.Id));
+ events.CollectEvent(new EmailLoginStarted(user.Id));
- return new StartLoginResponse(login.Id, login.EmailConfirmationId, EmailConfirmation.ValidForSeconds);
+ return new StartEmailLoginResponse(result.Value!, EmailLogin.ValidForSeconds);
}
}
diff --git a/application/account-management/Core/Features/EmailAuthentication/Commands/StartEmailSignup.cs b/application/account-management/Core/Features/EmailAuthentication/Commands/StartEmailSignup.cs
new file mode 100644
index 000000000..2e27eba82
--- /dev/null
+++ b/application/account-management/Core/Features/EmailAuthentication/Commands/StartEmailSignup.cs
@@ -0,0 +1,51 @@
+using FluentValidation;
+using JetBrains.Annotations;
+using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain;
+using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Shared;
+using PlatformPlatform.SharedKernel.Cqrs;
+using PlatformPlatform.SharedKernel.Telemetry;
+using PlatformPlatform.SharedKernel.Validation;
+
+namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Commands;
+
+[PublicAPI]
+public sealed record StartEmailSignupCommand(string Email) : ICommand, IRequest>
+{
+ public string Email { get; } = Email.Trim().ToLower();
+}
+
+[PublicAPI]
+public sealed record StartEmailSignupResponse(EmailLoginId EmailLoginId, int ValidForSeconds);
+
+public sealed class StartEmailSignupValidator : AbstractValidator
+{
+ public StartEmailSignupValidator()
+ {
+ RuleFor(x => x.Email).SetValidator(new SharedValidations.Email());
+ }
+}
+
+public sealed class StartEmailSignupHandler(StartEmailConfirmation startEmailConfirmation, ITelemetryEventsCollector events)
+ : IRequestHandler>
+{
+ public async Task> Handle(StartEmailSignupCommand command, CancellationToken cancellationToken)
+ {
+ var result = await startEmailConfirmation.StartAsync(
+ command.Email,
+ "Confirm your email address",
+ """
+ Your confirmation code is below
+ Enter it in your open browser window. It is only valid for a few minutes.
+ {oneTimePassword}
+ """,
+ EmailLoginType.Signup,
+ cancellationToken
+ );
+
+ if (!result.IsSuccess) return Result.From(result);
+
+ events.CollectEvent(new SignupStarted());
+
+ return Result.Success(new StartEmailSignupResponse(result.Value!, EmailLogin.ValidForSeconds));
+ }
+}
diff --git a/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmation.cs b/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLogin.cs
similarity index 52%
rename from application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmation.cs
rename to application/account-management/Core/Features/EmailAuthentication/Domain/EmailLogin.cs
index 9739cdd50..b21f4a4d7 100644
--- a/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmation.cs
+++ b/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLogin.cs
@@ -1,47 +1,51 @@
+using System.Security;
using JetBrains.Annotations;
using PlatformPlatform.SharedKernel.Domain;
using PlatformPlatform.SharedKernel.StronglyTypedIds;
-namespace PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain;
+namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain;
-public sealed class EmailConfirmation : AggregateRoot
+public sealed class EmailLogin : AggregateRoot
{
public const int MaxAttempts = 3;
public const int MaxResends = 1;
public const int ValidForSeconds = 300;
- private EmailConfirmation(string email, EmailConfirmationType type, string oneTimePasswordHash)
- : base(EmailConfirmationId.NewId())
+ private EmailLogin(string email, EmailLoginType type, string oneTimePasswordHash)
+ : base(EmailLoginId.NewId())
{
Email = email;
Type = type;
OneTimePasswordHash = oneTimePasswordHash;
- ValidUntil = CreatedAt.AddSeconds(ValidForSeconds);
}
public string Email { get; private set; }
- public EmailConfirmationType Type { get; private set; }
+ public EmailLoginType Type { get; private set; }
public string OneTimePasswordHash { get; private set; }
- [UsedImplicitly]
- public DateTimeOffset ValidUntil { get; private set; }
-
public int RetryCount { get; private set; }
public int ResendCount { get; private set; }
public bool Completed { get; private set; }
- public bool HasExpired(DateTimeOffset now)
+ public bool IsExpired(DateTimeOffset now)
{
- return ValidUntil < now;
+ if (CreatedAt > now)
+ {
+ throw new SecurityException($"EmailLogin '{Id}' has CreatedAt in the future. Possible data tampering.");
+ }
+
+ if (CreatedAt.AddSeconds(ValidForSeconds * (MaxResends + 1)) < now) return true;
+ if ((ModifiedAt ?? CreatedAt).AddSeconds(ValidForSeconds) < now) return true;
+ return false;
}
- public static EmailConfirmation Create(string email, string oneTimePasswordHash, EmailConfirmationType type)
+ public static EmailLogin Create(string email, string oneTimePasswordHash, EmailLoginType type)
{
- return new EmailConfirmation(email.ToLowerInvariant(), type, oneTimePasswordHash);
+ return new EmailLogin(email.ToLowerInvariant(), type, oneTimePasswordHash);
}
public void RegisterInvalidPasswordAttempt()
@@ -51,12 +55,12 @@ public void RegisterInvalidPasswordAttempt()
public void MarkAsCompleted(DateTimeOffset now)
{
- if (HasExpired(now) || RetryCount >= MaxAttempts)
+ if (IsExpired(now) || RetryCount >= MaxAttempts)
{
- throw new UnreachableException("This email confirmation has expired.");
+ throw new UnreachableException("This email login has expired.");
}
- if (Completed) throw new UnreachableException("The email has already been confirmed.");
+ if (Completed) throw new UnreachableException("The email login has already been completed.");
Completed = true;
}
@@ -65,24 +69,23 @@ public void UpdateVerificationCode(string oneTimePasswordHash, DateTimeOffset no
{
if (Completed)
{
- throw new UnreachableException("Cannot regenerate verification code for completed email confirmation");
+ throw new UnreachableException("Cannot regenerate verification code for completed email login.");
}
if (ResendCount >= MaxResends)
{
- throw new UnreachableException("Cannot regenerate verification code for email confirmation that has been resent too many times.");
+ throw new UnreachableException("Cannot regenerate verification code for email login that has been resent too many times.");
}
- ValidUntil = now.AddSeconds(ValidForSeconds);
OneTimePasswordHash = oneTimePasswordHash;
ResendCount++;
}
}
[PublicAPI]
-[IdPrefix("econf")]
-[JsonConverter(typeof(StronglyTypedIdJsonConverter))]
-public sealed record EmailConfirmationId(string Value) : StronglyTypedUlid(Value)
+[IdPrefix("emlog")]
+[JsonConverter(typeof(StronglyTypedIdJsonConverter))]
+public sealed record EmailLoginId(string Value) : StronglyTypedUlid(Value)
{
public override string ToString()
{
diff --git a/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginConfiguration.cs b/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginConfiguration.cs
new file mode 100644
index 000000000..b278b1a79
--- /dev/null
+++ b/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginConfiguration.cs
@@ -0,0 +1,14 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using PlatformPlatform.SharedKernel.EntityFramework;
+
+namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain;
+
+public sealed class EmailLoginConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("EmailLogins");
+ builder.MapStronglyTypedUuid(el => el.Id);
+ }
+}
diff --git a/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginRepository.cs b/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginRepository.cs
new file mode 100644
index 000000000..4399e73af
--- /dev/null
+++ b/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginRepository.cs
@@ -0,0 +1,24 @@
+using PlatformPlatform.AccountManagement.Database;
+using PlatformPlatform.SharedKernel.Domain;
+using PlatformPlatform.SharedKernel.Persistence;
+
+namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain;
+
+public interface IEmailLoginRepository : IAppendRepository
+{
+ void Update(EmailLogin aggregate);
+
+ EmailLogin[] GetByEmail(string email);
+}
+
+public sealed class EmailLoginRepository(AccountManagementDbContext accountManagementDbContext)
+ : RepositoryBase(accountManagementDbContext), IEmailLoginRepository
+{
+ public EmailLogin[] GetByEmail(string email)
+ {
+ return DbSet
+ .Where(el => !el.Completed)
+ .Where(el => el.Email == email.ToLowerInvariant())
+ .ToArray();
+ }
+}
diff --git a/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginTypes.cs b/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginTypes.cs
new file mode 100644
index 000000000..41f82eb80
--- /dev/null
+++ b/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginTypes.cs
@@ -0,0 +1,7 @@
+namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain;
+
+public enum EmailLoginType
+{
+ Login,
+ Signup
+}
diff --git a/application/account-management/Core/Features/EmailAuthentication/Shared/CompleteEmailConfirmation.cs b/application/account-management/Core/Features/EmailAuthentication/Shared/CompleteEmailConfirmation.cs
new file mode 100644
index 000000000..abb4626ef
--- /dev/null
+++ b/application/account-management/Core/Features/EmailAuthentication/Shared/CompleteEmailConfirmation.cs
@@ -0,0 +1,61 @@
+using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain;
+using PlatformPlatform.SharedKernel.Authentication;
+using PlatformPlatform.SharedKernel.Cqrs;
+using PlatformPlatform.SharedKernel.Telemetry;
+
+namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Shared;
+
+public sealed record CompleteEmailConfirmationResponse(string Email, int ConfirmationTimeInSeconds);
+
+public sealed class CompleteEmailConfirmation(
+ IEmailLoginRepository emailLoginRepository,
+ OneTimePasswordHelper oneTimePasswordHelper,
+ ITelemetryEventsCollector events,
+ TimeProvider timeProvider,
+ ILogger logger
+)
+{
+ public async Task> CompleteAsync(EmailLoginId id, string oneTimePassword, CancellationToken cancellationToken)
+ {
+ var emailLogin = await emailLoginRepository.GetByIdAsync(id, cancellationToken);
+
+ if (emailLogin is null)
+ {
+ return Result.NotFound($"Email login with id '{id}' not found.");
+ }
+
+ if (emailLogin.Completed)
+ {
+ logger.LogWarning("Email login with id '{EmailLoginId}' has already been completed", emailLogin.Id);
+ return Result.BadRequest($"Email login with id '{emailLogin.Id}' has already been completed.");
+ }
+
+ if (emailLogin.RetryCount >= EmailLogin.MaxAttempts)
+ {
+ emailLogin.RegisterInvalidPasswordAttempt();
+ emailLoginRepository.Update(emailLogin);
+ events.CollectEvent(new EmailLoginCodeBlocked(emailLogin.Id, emailLogin.Type, emailLogin.RetryCount));
+ return Result.Forbidden("Too many attempts, please request a new code.", true);
+ }
+
+ if (oneTimePasswordHelper.Validate(emailLogin.OneTimePasswordHash, oneTimePassword))
+ {
+ emailLogin.RegisterInvalidPasswordAttempt();
+ emailLoginRepository.Update(emailLogin);
+ events.CollectEvent(new EmailLoginCodeFailed(emailLogin.Id, emailLogin.Type, emailLogin.RetryCount));
+ return Result.BadRequest("The code is wrong or no longer valid.", true);
+ }
+
+ var confirmationTimeInSeconds = (int)(timeProvider.GetUtcNow() - emailLogin.CreatedAt).TotalSeconds;
+ if (emailLogin.IsExpired(timeProvider.GetUtcNow()))
+ {
+ events.CollectEvent(new EmailLoginCodeExpired(emailLogin.Id, emailLogin.Type, confirmationTimeInSeconds));
+ return Result.BadRequest("The code is no longer valid, please request a new code.", true);
+ }
+
+ emailLogin.MarkAsCompleted(timeProvider.GetUtcNow());
+ emailLoginRepository.Update(emailLogin);
+
+ return new CompleteEmailConfirmationResponse(emailLogin.Email, confirmationTimeInSeconds);
+ }
+}
diff --git a/application/account-management/Core/Features/EmailAuthentication/Shared/StartEmailConfirmation.cs b/application/account-management/Core/Features/EmailAuthentication/Shared/StartEmailConfirmation.cs
new file mode 100644
index 000000000..623579a4b
--- /dev/null
+++ b/application/account-management/Core/Features/EmailAuthentication/Shared/StartEmailConfirmation.cs
@@ -0,0 +1,49 @@
+using Microsoft.AspNetCore.Identity;
+using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain;
+using PlatformPlatform.SharedKernel.Authentication;
+using PlatformPlatform.SharedKernel.Cqrs;
+using PlatformPlatform.SharedKernel.Integrations.Email;
+
+namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Shared;
+
+public sealed class StartEmailConfirmation(
+ IEmailLoginRepository emailLoginRepository,
+ IEmailClient emailClient,
+ IPasswordHasher