Skip to content
Open
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
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
Loading