Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
4 changes: 3 additions & 1 deletion .cursor/rules/backend/backend.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ Carefully follow these instructions for C# backend development, including code s
- Avoid try-catch unless we cannot fix the reason. We have global exception handling to handle unknown exceptions.
- Use `SharedInfrastructureConfiguration.IsRunningInAzure` to determine if we are running in Azure.
- Don't add comments unless the code is truly not expressing the intent.
- Do add comments explains "the why" of code. In other words, never add comments that explains what code does, but explaining why code is there, what non-obvious problem it solves, is expected.
- Never add XML comments.
- Use `TimeProvider.System.GetUtcNow()` and not `DateTime.UtcNow()`.
- Inject TimeProvider from [FromPlatformServices] and use `timeProvider.GetUtcNow()` and not `DateTimeOffset.UtcNow()` in all business code that needs timers, current time, timezone info.
- Use `DateTimeOffset.UtcNow()` for code that needs timestamps and does not support/work with fake TimeProvider during testing.

## Implementation

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;
using PlatformPlatform.SharedKernel.Authentication;
using PlatformPlatform.SharedKernel.Authentication.TokenSigning;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;

namespace PlatformPlatform.AppGateway.Middleware;

public class AuthenticationCookieMiddleware(
ITokenSigningClient tokenSigningClient,
IHttpClientFactory httpClientFactory,
[FromPlatformServices] TimeProvider timeProvider,
ILogger<AuthenticationCookieMiddleware> logger
)
: IMiddleware
Expand Down Expand Up @@ -51,9 +52,9 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http

try
{
if (accessToken is null || ExtractExpirationFromToken(accessToken) < TimeProvider.System.GetUtcNow())
if (accessToken is null || ExtractExpirationFromToken(accessToken) < timeProvider.GetUtcNow())
{
if (ExtractExpirationFromToken(refreshToken) < TimeProvider.System.GetUtcNow())
if (ExtractExpirationFromToken(refreshToken) < timeProvider.GetUtcNow())
{
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName);
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName);
Expand Down Expand Up @@ -114,7 +115,10 @@ private void ReplaceAuthenticationHeaderWithCookie(HttpContext context, string r
// having to first serve the SPA. This is only secure if iFrames are not allowed to host the site.
var refreshTokenCookieOptions = new CookieOptions
{
HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax, Expires = refreshTokenExpires
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Expires = refreshTokenExpires
};
context.Response.Cookies.Append(AuthenticationTokenHttpKeys.RefreshTokenCookieName, refreshToken, refreshTokenCookieOptions);

Expand Down
1 change: 1 addition & 0 deletions application/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.11.1" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.1.13" />
<PackageVersion Include="Scrutor" Version="6.0.1" />
<PackageVersion Include="TimeProviderExtensions" Version="1.0.0" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using PlatformPlatform.AccountManagement.Features.Authentication.Domain;
using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands;
using PlatformPlatform.AccountManagement.Features.Users.Domain;
Expand Down Expand Up @@ -27,6 +28,7 @@ public sealed class CompleteLoginHandler(
AvatarUpdater avatarUpdater,
GravatarClient gravatarClient,
ITelemetryEventsCollector events,
[FromPlatformServices] TimeProvider timeProvider,
ILogger<CompleteLoginHandler> logger
) : IRequestHandler<CompleteLoginCommand, Result>
{
Expand Down Expand Up @@ -98,7 +100,7 @@ private void CompleteUserInvite(User user)
{
user.ConfirmEmail();
userRepository.Update(user);
var inviteAcceptedTimeInMinutes = (int)(TimeProvider.System.GetUtcNow() - user.CreatedAt).TotalMinutes;
var inviteAcceptedTimeInMinutes = (int)(timeProvider.GetUtcNow() - user.CreatedAt).TotalMinutes;
events.CollectEvent(new UserInviteAccepted(user.Id, inviteAcceptedTimeInMinutes));
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain;
using PlatformPlatform.SharedKernel.Authentication;
using PlatformPlatform.SharedKernel.Cqrs;
Expand All @@ -17,6 +18,7 @@ public sealed class CompleteEmailConfirmationHandler(
IEmailConfirmationRepository emailConfirmationRepository,
OneTimePasswordHelper oneTimePasswordHelper,
ITelemetryEventsCollector events,
[FromPlatformServices] TimeProvider timeProvider,
ILogger<CompleteEmailConfirmationHandler> logger
) : IRequestHandler<CompleteEmailConfirmationCommand, Result<CompleteEmailConfirmationResponse>>
{
Expand Down Expand Up @@ -51,14 +53,14 @@ public async Task<Result<CompleteEmailConfirmationResponse>> Handle(CompleteEmai
return Result<CompleteEmailConfirmationResponse>.BadRequest("The code is wrong or no longer valid.", true);
}

var confirmationTimeInSeconds = (int)(TimeProvider.System.GetUtcNow() - emailConfirmation.CreatedAt).TotalSeconds;
if (emailConfirmation.HasExpired())
var confirmationTimeInSeconds = (int)(timeProvider.GetUtcNow() - emailConfirmation.CreatedAt).TotalSeconds;
if (emailConfirmation.HasExpired(timeProvider))
{
events.CollectEvent(new EmailConfirmationExpired(emailConfirmation.Id, emailConfirmation.Type, confirmationTimeInSeconds));
return Result<CompleteEmailConfirmationResponse>.BadRequest("The code is no longer valid, please request a new code.", true);
}

emailConfirmation.MarkAsCompleted();
emailConfirmation.MarkAsCompleted(timeProvider);
emailConfirmationRepository.Update(emailConfirmation);

return new CompleteEmailConfirmationResponse(emailConfirmation.Email, confirmationTimeInSeconds);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using JetBrains.Annotations;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain;
using PlatformPlatform.SharedKernel.Authentication;
using PlatformPlatform.SharedKernel.Cqrs;
Expand All @@ -23,6 +24,7 @@ public sealed class ResendEmailConfirmationCodeHandler(
IEmailClient emailClient,
IPasswordHasher<object> passwordHasher,
ITelemetryEventsCollector events,
[FromPlatformServices] TimeProvider timeProvider,
ILogger<ResendEmailConfirmationCodeHandler> logger
) : IRequestHandler<ResendEmailConfirmationCodeCommand, Result<ResendEmailConfirmationCodeResponse>>
{
Expand All @@ -45,10 +47,10 @@ public async Task<Result<ResendEmailConfirmationCodeResponse>> Handle(ResendEmai

var oneTimePassword = OneTimePasswordHelper.GenerateOneTimePassword(6);
var oneTimePasswordHash = passwordHasher.HashPassword(this, oneTimePassword);
emailConfirmation.UpdateVerificationCode(oneTimePasswordHash);
emailConfirmation.UpdateVerificationCode(oneTimePasswordHash, timeProvider);
emailConfirmationRepository.Update(emailConfirmation);

var secondsSinceSignupStarted = (TimeProvider.System.GetUtcNow() - emailConfirmation.CreatedAt).TotalSeconds;
var secondsSinceSignupStarted = (timeProvider.GetUtcNow() - emailConfirmation.CreatedAt).TotalSeconds;
events.CollectEvent(new EmailConfirmationResend((int)secondsSinceSignupStarted));

await emailClient.SendAsync(emailConfirmation.Email, "Your verification code (resend)",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using FluentValidation;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain;
using PlatformPlatform.SharedKernel.Authentication;
using PlatformPlatform.SharedKernel.Cqrs;
Expand Down Expand Up @@ -29,15 +30,15 @@ public StartEmailConfirmationValidator()
public sealed class StartEmailConfirmationHandler(
IEmailConfirmationRepository emailConfirmationRepository,
IEmailClient emailClient,
IPasswordHasher<object> passwordHasher
) : IRequestHandler<StartEmailConfirmationCommand, Result<StartEmailConfirmationResponse>>
IPasswordHasher<object> passwordHasher,
[FromPlatformServices] TimeProvider timeProvider) : IRequestHandler<StartEmailConfirmationCommand, Result<StartEmailConfirmationResponse>>
{
public async Task<Result<StartEmailConfirmationResponse>> Handle(StartEmailConfirmationCommand command, CancellationToken cancellationToken)
{
var existingConfirmations = emailConfirmationRepository.GetByEmail(command.Email).ToArray();

var lockoutMinutes = command.Type == EmailConfirmationType.Signup ? -60 : -15;
if (existingConfirmations.Count(r => r.CreatedAt > TimeProvider.System.GetUtcNow().AddMinutes(lockoutMinutes)) >= 3)
if (existingConfirmations.Count(r => r.CreatedAt > timeProvider.GetUtcNow().AddMinutes(lockoutMinutes)) >= 3)
{
return Result<StartEmailConfirmationResponse>.TooManyRequests("Too many attempts to confirm this email address. Please try again later.");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ private EmailConfirmation(string email, EmailConfirmationType type, string oneTi

public bool Completed { get; private set; }

public bool HasExpired()
public bool HasExpired(TimeProvider timeProvider)
{
return ValidUntil < TimeProvider.System.GetUtcNow();
return ValidUntil < timeProvider.GetUtcNow();
}

public static EmailConfirmation Create(string email, string oneTimePasswordHash, EmailConfirmationType type)
Expand All @@ -49,9 +49,9 @@ public void RegisterInvalidPasswordAttempt()
RetryCount++;
}

public void MarkAsCompleted()
public void MarkAsCompleted(TimeProvider timeProvider)
{
if (HasExpired() || RetryCount >= MaxAttempts)
if (HasExpired(timeProvider) || RetryCount >= MaxAttempts)
{
throw new UnreachableException("This email confirmation has expired.");
}
Expand All @@ -61,7 +61,7 @@ public void MarkAsCompleted()
Completed = true;
}

public void UpdateVerificationCode(string oneTimePasswordHash)
public void UpdateVerificationCode(string oneTimePasswordHash, TimeProvider timeProvider)
{
if (Completed)
{
Expand All @@ -73,7 +73,7 @@ public void UpdateVerificationCode(string oneTimePasswordHash)
throw new UnreachableException("Cannot regenerate verification code for email confirmation that has been resent too many times.");
}

ValidUntil = TimeProvider.System.GetUtcNow().AddSeconds(ValidForSeconds);
ValidUntil = timeProvider.GetUtcNow().AddSeconds(ValidForSeconds);
OneTimePasswordHash = oneTimePasswordHash;
ResendCount++;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using PlatformPlatform.AccountManagement.Features.Users.Domain;
using PlatformPlatform.SharedKernel.Cqrs;
using PlatformPlatform.SharedKernel.Domain;
Expand All @@ -13,8 +14,8 @@ public sealed record DeclineInvitationCommand(TenantId TenantId) : ICommand, IRe
public sealed class DeclineInvitationHandler(
IUserRepository userRepository,
IExecutionContext executionContext,
ITelemetryEventsCollector events
) : IRequestHandler<DeclineInvitationCommand, Result>
ITelemetryEventsCollector events,
[FromPlatformServices] TimeProvider timeProvider) : IRequestHandler<DeclineInvitationCommand, Result>
{
public async Task<Result> Handle(DeclineInvitationCommand command, CancellationToken cancellationToken)
{
Expand All @@ -34,7 +35,7 @@ public async Task<Result> Handle(DeclineInvitationCommand command, CancellationT
}

// Calculate how long the invitation existed
var inviteExistedTimeInMinutes = (int)(TimeProvider.System.GetUtcNow() - user.CreatedAt).TotalMinutes;
var inviteExistedTimeInMinutes = (int)(timeProvider.GetUtcNow() - user.CreatedAt).TotalMinutes;

// Delete the user to decline the invitation
userRepository.Remove(user);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using PlatformPlatform.AccountManagement.Database;
using PlatformPlatform.SharedKernel.Domain;
using PlatformPlatform.SharedKernel.ExecutionContext;
Expand Down Expand Up @@ -40,7 +41,10 @@ CancellationToken cancellationToken
Task<User[]> GetUsersByEmailUnfilteredAsync(string email, CancellationToken cancellationToken);
}

internal sealed class UserRepository(AccountManagementDbContext accountManagementDbContext, IExecutionContext executionContext)
internal sealed class UserRepository(
AccountManagementDbContext accountManagementDbContext,
IExecutionContext executionContext,
[FromPlatformServices] TimeProvider timeProvider)
: RepositoryBase<User, UserId>(accountManagementDbContext), IUserRepository
{
/// <summary>
Expand Down Expand Up @@ -89,16 +93,16 @@ public async Task<User[]> GetByIdsAsync(UserId[] ids, CancellationToken cancella

public async Task<(int TotalUsers, int ActiveUsers, int PendingUsers)> GetUserSummaryAsync(CancellationToken cancellationToken)
{
var thirtyDaysAgo = TimeProvider.System.GetUtcNow().AddDays(-30);
var thirtyDaysAgo = timeProvider.GetUtcNow().AddDays(-30);

var summary = await DbSet
.GroupBy(_ => 1) // Group all records into a single group to calculate multiple COUNT aggregates in one query
.Select(g => new
{
TotalUsers = g.Count(),
ActiveUsers = g.Count(u => u.EmailConfirmed && u.ModifiedAt >= thirtyDaysAgo),
PendingUsers = g.Count(u => !u.EmailConfirmed)
}
{
TotalUsers = g.Count(),
ActiveUsers = g.Count(u => u.EmailConfirmed && u.ModifiedAt >= thirtyDaysAgo),
PendingUsers = g.Count(u => !u.EmailConfirmed)
}
)
.SingleAsync(cancellationToken);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using PlatformPlatform.AccountManagement.Database;
Expand All @@ -13,6 +10,9 @@
using PlatformPlatform.SharedKernel.Domain;
using PlatformPlatform.SharedKernel.Tests;
using PlatformPlatform.SharedKernel.Tests.Persistence;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Xunit;

namespace PlatformPlatform.AccountManagement.Tests.Authentication;
Expand Down Expand Up @@ -159,20 +159,20 @@ public async Task CompleteLogin_WhenLoginExpired_ShouldReturnBadRequest()
("TenantId", DatabaseSeeder.Tenant1Owner.TenantId.ToString()),
("UserId", DatabaseSeeder.Tenant1Owner.Id.ToString()),
("Id", loginId.ToString()),
("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)),
("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)),
("ModifiedAt", null),
("EmailConfirmationId", emailConfirmationId.ToString()),
("Completed", false)
]
);
Connection.Insert("EmailConfirmations", [
("Id", emailConfirmationId.ToString()),
("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)),
("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)),
("ModifiedAt", null),
("Email", DatabaseSeeder.Tenant1Owner.Email),
("Type", EmailConfirmationType.Signup),
("OneTimePasswordHash", new PasswordHasher<object>().HashPassword(this, CorrectOneTimePassword)),
("ValidUntil", TimeProvider.System.GetUtcNow().AddMinutes(-10)),
("ValidUntil", TimeProvider.GetUtcNow().AddMinutes(-10)),
("RetryCount", 0),
("ResendCount", 0),
("Completed", false)
Expand Down Expand Up @@ -234,7 +234,7 @@ public async Task CompleteLogin_WithValidPreferredTenant_ShouldLoginToPreferredT

Connection.Insert("Tenants", [
("Id", tenant2Id.Value),
("CreatedAt", TimeProvider.System.GetUtcNow()),
("CreatedAt", TimeProvider.GetUtcNow()),
("ModifiedAt", null),
("Name", Faker.Company.CompanyName()),
("State", TenantState.Active.ToString()),
Expand All @@ -245,7 +245,7 @@ public async Task CompleteLogin_WithValidPreferredTenant_ShouldLoginToPreferredT
Connection.Insert("Users", [
("TenantId", tenant2Id.Value),
("Id", user2Id.ToString()),
("CreatedAt", TimeProvider.System.GetUtcNow()),
("CreatedAt", TimeProvider.GetUtcNow()),
("ModifiedAt", null),
("Email", DatabaseSeeder.Tenant1Owner.Email),
("EmailConfirmed", true),
Expand Down Expand Up @@ -307,7 +307,7 @@ public async Task CompleteLogin_WithPreferredTenantUserDoesNotHaveAccess_ShouldL

Connection.Insert("Tenants", [
("Id", tenant2Id.Value),
("CreatedAt", TimeProvider.System.GetUtcNow()),
("CreatedAt", TimeProvider.GetUtcNow()),
("ModifiedAt", null),
("Name", Faker.Company.CompanyName()),
("State", TenantState.Active.ToString()),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using NSubstitute;
Expand All @@ -10,6 +8,8 @@
using PlatformPlatform.SharedKernel.Tests;
using PlatformPlatform.SharedKernel.Tests.Persistence;
using PlatformPlatform.SharedKernel.Validation;
using System.Net;
using System.Net.Http.Json;
using Xunit;

namespace PlatformPlatform.AccountManagement.Tests.Authentication;
Expand Down Expand Up @@ -124,12 +124,12 @@ public async Task StartLogin_WhenTooManyAttempts_ShouldReturnTooManyRequests()
var oneTimePasswordHash = new PasswordHasher<object>().HashPassword(this, OneTimePasswordHelper.GenerateOneTimePassword(6));
Connection.Insert("EmailConfirmations", [
("Id", EmailConfirmationId.NewId().ToString()),
("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-i)),
("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-i)),
("ModifiedAt", null),
("Email", email.ToLower()),
("Type", EmailConfirmationType.Login.ToString()),
("OneTimePasswordHash", oneTimePasswordHash),
("ValidUntil", TimeProvider.System.GetUtcNow().AddMinutes(-i - 1)), // All should be expired
("ValidUntil", TimeProvider.GetUtcNow().AddMinutes(-i - 1)), // All should be expired
("RetryCount", 0),
("ResendCount", 0),
("Completed", false)
Expand Down
Loading