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
Original file line number Diff line number Diff line change
Expand Up @@ -149,24 +149,29 @@ public AuthenticationSecurityState ResetFailuresIfWindowExpired(DateTimeOffset n
securityVersion: SecurityVersion + 1);
}

/// <summary>
/// Registers a failed authentication attempt. Optionally locks until now + duration when threshold reached.
/// If already locked, may extend lock depending on extendLock.
/// </summary>
public AuthenticationSecurityState RegisterFailure(DateTimeOffset now, int threshold, TimeSpan lockoutDuration, bool extendLock = true)
public AuthenticationSecurityState RegisterFailure(DateTimeOffset now, int threshold, TimeSpan lockoutDuration, TimeSpan failureWindow, bool extendLock = true)
{
if (threshold < 0)
throw new UAuthValidationException(nameof(threshold));

var effectiveFailedAttempts = FailedAttempts;
var effectiveLockedUntil = LockedUntil;

// Existing lock expired.
if (effectiveLockedUntil.HasValue && now >= effectiveLockedUntil.Value)
{
effectiveFailedAttempts = 0;
effectiveLockedUntil = null;
}

// Previous failure sequence expired.
if (failureWindow > TimeSpan.Zero &&
LastFailedAt is DateTimeOffset lastFailedAt &&
now - lastFailedAt > failureWindow)
{
effectiveFailedAttempts = 0;
}

var nextCount = effectiveFailedAttempts + 1;

DateTimeOffset? nextLockedUntil = effectiveLockedUntil;
Expand Down
12 changes: 10 additions & 2 deletions src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Domain;

namespace CodeBeam.UltimateAuth.Server.Flows;

Expand All @@ -8,6 +9,13 @@ namespace CodeBeam.UltimateAuth.Server.Flows;
/// </summary>
public sealed class LoginAuthority : ILoginAuthority
{
private readonly IClock _clock;

public LoginAuthority(IClock clock)
{
_clock = clock;
}

public LoginDecision Decide(LoginDecisionContext context)
{
if (!context.UserExists || context.UserKey is null)
Expand All @@ -18,7 +26,7 @@ public LoginDecision Decide(LoginDecisionContext context)
var state = context.SecurityState;
if (state is not null)
{
if (state.IsLocked(DateTimeOffset.UtcNow))
if (state.IsLocked(_clock.UtcNow))
return LoginDecision.Deny(AuthFailureReason.LockedOut);

if (state.RequiresReauthentication)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ public async Task<LoginResult> LoginAsync(AuthFlowContext flow, LoginRequest req
if (!loginExecution.SuppressFailureAttempt)
{
var version = factorState.SecurityVersion;
factorState = factorState.RegisterFailure(now, _options.Login.MaxFailedAttempts, _options.Login.LockoutDuration, _options.Login.ExtendLockOnFailure);
factorState = factorState.RegisterFailure(now, _options.Login.MaxFailedAttempts, _options.Login.LockoutDuration, _options.Login.FailureWindow, _options.Login.ExtendLockOnFailure);
await _authenticationSecurityManager.UpdateAsync(factorState, version, ct);
}

Expand Down Expand Up @@ -259,8 +259,7 @@ public async Task<LoginResult> LoginAsync(AuthFlowContext flow, LoginRequest req
};
}

await _events.DispatchAsync(
new UserLoggedInContext(flow.Tenant, userKey.Value, now, flow.Device, issuedSession.Session.SessionId));
await _events.DispatchAsync(new UserLoggedInContext(flow.Tenant, userKey.Value, now, flow.Device, issuedSession.Session.SessionId));

return LoginResult.Success(issuedSession.Session.SessionId, tokens);
}
Expand Down
102 changes: 101 additions & 1 deletion tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,112 @@
using Microsoft.AspNetCore.Hosting;
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
using CodeBeam.UltimateAuth.Credentials.Contracts;
using CodeBeam.UltimateAuth.Credentials.Reference;
using CodeBeam.UltimateAuth.Server.Infrastructure;
using CodeBeam.UltimateAuth.Users.Contracts;
using CodeBeam.UltimateAuth.Users.Reference;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace CodeBeam.UltimateAuth.Tests.Integration;

public class AuthServerFactory : WebApplicationFactory<Program>
{
public IntegrationTestClock Clock { get; } = new();

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");

builder.ConfigureServices(services =>
{
services.RemoveAll<IClock>();
services.AddSingleton<IClock>(Clock);
});
}

internal async Task<IntegrationTestUser> CreateLoginUserAsync(
string? identifier = null,
string? secret = null,
CancellationToken ct = default)
{
using var scope = Services.CreateScope();

var services = scope.ServiceProvider;

var lifecycleFactory =
services.GetRequiredService<IUserLifecycleStoreFactory>();

var identifierFactory =
services.GetRequiredService<IUserIdentifierStoreFactory>();

var credentialFactory =
services.GetRequiredService<IPasswordCredentialStoreFactory>();

var normalizer =
services.GetRequiredService<IIdentifierNormalizer>();

var hasher =
services.GetRequiredService<IUAuthPasswordHasher>();

var clock =
services.GetRequiredService<IClock>();

var tenant = TenantKeys.Single;
var userKey = UserKey.New();

identifier ??= $"test-{Guid.NewGuid():N}";
secret ??= $"Test-{Guid.NewGuid():N}!";

var now = clock.UtcNow;

var lifecycleStore = lifecycleFactory.Create(tenant);
var identifierStore = identifierFactory.Create(tenant);
var credentialStore = credentialFactory.Create(tenant);

await lifecycleStore.AddAsync(
UserLifecycle.Create(
tenant,
userKey,
now),
ct);

var normalized = normalizer
.Normalize(
UserIdentifierType.Username,
identifier)
.Normalized;

await identifierStore.AddAsync(
UserIdentifier.Create(
Guid.NewGuid(),
tenant,
userKey,
UserIdentifierType.Username,
identifier,
normalized,
now,
isPrimary: true,
verifiedAt: now),
ct);

await credentialStore.AddAsync(
PasswordCredential.Create(
Guid.NewGuid(),
tenant,
userKey,
hasher.Hash(secret),
CredentialSecurityState.Active(),
new CredentialMetadata(),
now),
ct);

return new IntegrationTestUser(
userKey,
identifier,
secret);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using CodeBeam.UltimateAuth.Core.Abstractions;

namespace CodeBeam.UltimateAuth.Tests.Integration;

public sealed class IntegrationTestClock : IClock
{
private readonly object _sync = new();

private DateTimeOffset _utcNow = new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero);

public DateTimeOffset UtcNow
{
get
{
lock (_sync)
{
return _utcNow;
}
}
}

public void Advance(TimeSpan duration)
{
if (duration < TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(duration));

lock (_sync)
{
_utcNow = _utcNow.Add(duration);
}
}

public void Set(DateTimeOffset value)
{
lock (_sync)
{
_utcNow = value.ToUniversalTime();
}
}

public void Reset()
{
lock (_sync)
{
_utcNow = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using CodeBeam.UltimateAuth.Core.Domain;

namespace CodeBeam.UltimateAuth.Tests.Integration;

internal sealed record IntegrationTestUser(
UserKey UserKey,
string Identifier,
string Secret);
Loading
Loading