Skip to content

Commit 5e2457d

Browse files
committed
Enhanced Login Lockout Client Handling
1 parent 52b0d4e commit 5e2457d

File tree

17 files changed

+221
-50
lines changed

17 files changed

+221
-50
lines changed

samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,21 @@
8484
</MudStack>
8585
<MudTextField @ref="@_usernameField" @bind-Value="@_username" Variant="Variant.Outlined" Label="Username" Immediate="true" />
8686
<MudPasswordField @bind-Value="@_password" Variant="Variant.Outlined" Label="Password" Immediate="true" />
87-
<MudButton Variant="Variant.Filled" Color="Color.Primary" ButtonType="ButtonType.Submit">Login</MudButton>
87+
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@_isLocked" ButtonType="ButtonType.Submit">Login</MudButton>
88+
@if (_isLocked)
89+
{
90+
<MudAlert NoIcon="true" Severity="Severity.Error" Variant="Variant.Filled">
91+
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
92+
<MudStack Spacing="0">
93+
<MudText Typo="Typo.subtitle2">Your account is locked.</MudText>
94+
<MudText Typo="Typo.subtitle2">Try again in </MudText>
95+
</MudStack>
96+
<MudProgressCircular Class="mud-theme-error" Value="@_progressPercent" Max="100" Size="Size.Large">
97+
@_remaining.Minutes.ToString("00"):@_remaining.Seconds.ToString("00")
98+
</MudProgressCircular>
99+
</MudStack>
100+
</MudAlert>
101+
}
88102
</MudStack>
89103
</UAuthLoginForm>
90104
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true">

samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs

Lines changed: 111 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ public partial class Login
1717
private UAuthClientProductInfo? _productInfo;
1818
private MudTextField<string> _usernameField = default!;
1919

20+
private DateTimeOffset? _lockoutUntil;
21+
private TimeSpan _remaining;
22+
private System.Threading.Timer? _countdownTimer;
23+
private bool _isLocked;
24+
private DateTimeOffset? _lockoutStartedAt;
25+
private TimeSpan _lockoutDuration;
26+
private double _progressPercent;
27+
2028
[CascadingParameter]
2129
public UAuthState AuthState { get; set; } = default!;
2230

@@ -75,32 +83,119 @@ private async Task ProgrammaticLogin()
7583

7684
protected override void OnAfterRender(bool firstRender)
7785
{
78-
if (firstRender)
86+
if (!firstRender)
87+
return;
88+
89+
var uri = Nav.ToAbsoluteUri(Nav.Uri);
90+
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
91+
92+
if (query.TryGetValue("error", out var error))
7993
{
80-
var uri = Nav.ToAbsoluteUri(Nav.Uri);
81-
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
94+
query.TryGetValue("lockedUntil", out var lockedRaw);
95+
query.TryGetValue("remainingAttempts", out var remainingRaw);
8296

83-
if (query.TryGetValue("error", out var error))
84-
{
85-
ShowLoginError(error.ToString());
86-
ClearQueryString();
87-
}
97+
HandleLoginError(error.ToString(), lockedRaw.ToString(), remainingRaw.ToString());
98+
99+
ClearQueryString();
88100
}
89101
}
90102

91-
private void ShowLoginError(string code)
103+
private void HandleLoginError(string code, string? lockedUntilRaw, string? remainingRaw)
92104
{
93-
var message = code switch
105+
if (code == "locked" && long.TryParse(lockedUntilRaw, out var unix))
94106
{
95-
"invalid_credentials" => "Invalid username or password.",
96-
"locked" => "Your account is locked.",
97-
"mfa_required" => "Multi-factor authentication required.",
98-
_ => "Login failed."
99-
};
107+
_lockoutUntil = DateTimeOffset.FromUnixTimeSeconds(unix);
108+
StartCountdown();
109+
return;
110+
}
111+
112+
ShowLoginError(code, remainingRaw);
113+
}
114+
115+
private void ShowLoginError(string code, string? remainingRaw)
116+
{
117+
string message;
118+
119+
switch (code)
120+
{
121+
case "invalid_credentials":
122+
if (int.TryParse(remainingRaw, out var remaining) && remaining > 0)
123+
{
124+
message = $"Invalid username or password. {remaining} attempt(s) remaining.";
125+
}
126+
else
127+
{
128+
message = "Invalid username or password.";
129+
}
130+
break;
131+
132+
case "mfa_required":
133+
message = "Multi-factor authentication required.";
134+
break;
135+
136+
default:
137+
message = "Login failed.";
138+
break;
139+
}
100140

101141
Snackbar.Add(message, Severity.Error);
102142
}
103143

144+
private void StartCountdown()
145+
{
146+
if (_lockoutUntil is null)
147+
return;
148+
149+
_isLocked = true;
150+
_lockoutStartedAt = DateTimeOffset.UtcNow;
151+
_lockoutDuration = _lockoutUntil.Value - DateTimeOffset.UtcNow;
152+
UpdateRemaining();
153+
154+
_countdownTimer?.Dispose();
155+
_countdownTimer = new System.Threading.Timer(_ =>
156+
{
157+
InvokeAsync(() =>
158+
{
159+
UpdateRemaining();
160+
161+
if (_remaining <= TimeSpan.Zero)
162+
{
163+
_countdownTimer?.Dispose();
164+
_isLocked = false;
165+
_lockoutUntil = null;
166+
_progressPercent = 0;
167+
}
168+
169+
StateHasChanged();
170+
});
171+
}, null, 0, 1000);
172+
}
173+
174+
private void UpdateRemaining()
175+
{
176+
if (_lockoutUntil is null || _lockoutStartedAt is null)
177+
return;
178+
179+
var now = DateTimeOffset.UtcNow;
180+
181+
_remaining = _lockoutUntil.Value - now;
182+
183+
if (_remaining <= TimeSpan.Zero)
184+
{
185+
_remaining = TimeSpan.Zero;
186+
_progressPercent = 0;
187+
return;
188+
}
189+
190+
var elapsed = now - _lockoutStartedAt.Value;
191+
192+
if (_lockoutDuration.TotalSeconds > 0)
193+
{
194+
var percent = 100 - (elapsed.TotalSeconds / _lockoutDuration.TotalSeconds * 100);
195+
_progressPercent = Math.Max(0, percent);
196+
}
197+
}
198+
104199
private void ClearQueryString()
105200
{
106201
var uri = new Uri(Nav.Uri);
@@ -111,5 +206,6 @@ private void ClearQueryString()
111206
public void Dispose()
112207
{
113208
AuthStateProvider.AuthenticationStateChanged -= OnAuthStateChanged;
209+
_countdownTimer?.Dispose();
114210
}
115211
}

samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
//o.Session.IdleTimeout = TimeSpan.FromSeconds(15);
4141
//o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30);
4242
//o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32);
43+
o.Login.MaxFailedAttempts = 1;
44+
o.Login.LockoutDuration = TimeSpan.FromSeconds(10);
4345
})
4446
.AddUltimateAuthUsersInMemory()
4547
.AddUltimateAuthUsersReference()

src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,20 @@ public sealed record LoginResult
1010
public RefreshToken? RefreshToken { get; init; }
1111
public LoginContinuation? Continuation { get; init; }
1212
public AuthFailureReason? FailureReason { get; init; }
13+
public DateTimeOffset? LockoutUntilUtc { get; init; }
14+
public int? RemainingAttempts { get; init; }
1315

1416
public bool IsSuccess => Status == LoginStatus.Success;
1517
public bool RequiresContinuation => Continuation is not null;
1618
public bool RequiresMfa => Continuation?.Type == LoginContinuationType.Mfa;
1719
public bool RequiresPkce => Continuation?.Type == LoginContinuationType.Pkce;
1820

19-
public static LoginResult Failed(AuthFailureReason? reason = null)
21+
public static LoginResult Failed(AuthFailureReason? reason = null, DateTimeOffset? lockoutUntilUtc = null)
2022
=> new()
2123
{
2224
Status = LoginStatus.Failed,
23-
FailureReason = reason
25+
FailureReason = reason,
26+
LockoutUntilUtc = lockoutUntilUtc
2427
};
2528

2629
public static LoginResult Success(AuthSessionId sessionId, AuthTokens? tokens = null)

src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ public sealed class UAuthLoginOptions
1414
public int MaxFailedAttempts { get; set; } = 10;
1515

1616
/// <summary>
17-
/// Duration (in minutes) for which the user is locked out after exceeding <see cref="MaxFailedAttempts" />.
17+
/// Duration for which the user is locked out after exceeding <see cref="MaxFailedAttempts" />.
1818
/// </summary>
19-
public int LockoutMinutes { get; set; } = 15;
19+
public TimeSpan LockoutDuration { get; set; } = TimeSpan.FromMinutes(15);
2020

2121
internal UAuthLoginOptions Clone() => new()
2222
{
2323
MaxFailedAttempts = MaxFailedAttempts,
24-
LockoutMinutes = LockoutMinutes
24+
LockoutDuration = LockoutDuration
2525
};
2626
}

src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthLoginOptionsValidator.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public ValidateOptionsResult Validate(string? name, UAuthLoginOptions options)
1414
if (options.MaxFailedAttempts > 100)
1515
errors.Add("Login.MaxFailedAttempts cannot exceed 100. Use 0 to disable lockout.");
1616

17-
if (options.MaxFailedAttempts > 0 && options.LockoutMinutes <= 0)
17+
if (options.MaxFailedAttempts > 0 && options.LockoutDuration <= TimeSpan.Zero)
1818
errors.Add("Login.LockoutMinutes must be greater than zero when lockout is enabled.");
1919

2020
return errors.Count == 0

src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveRedirectResponse.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,30 @@ public sealed class EffectiveRedirectResponse
1111
public string? FailureQueryKey { get; }
1212
public IReadOnlyDictionary<AuthFailureReason, string>? FailureCodes { get; }
1313
public bool AllowReturnUrlOverride { get; }
14+
public bool IncludeLockoutTiming { get; }
15+
public bool IncludeRemainingAttempts { get; }
1416

1517
public EffectiveRedirectResponse(
1618
bool enabled,
1719
string? successPath,
1820
string? failurePath,
1921
string? failureQueryKey,
2022
IReadOnlyDictionary<AuthFailureReason, string>? failureCodes,
21-
bool allowReturnUrlOverride)
23+
bool allowReturnUrlOverride,
24+
bool includeLockoutTiming,
25+
bool includeRemainingAttempts)
2226
{
2327
Enabled = enabled;
2428
SuccessPath = successPath;
2529
FailurePath = failurePath;
2630
FailureQueryKey = failureQueryKey;
2731
FailureCodes = failureCodes;
2832
AllowReturnUrlOverride = allowReturnUrlOverride;
33+
IncludeLockoutTiming = includeLockoutTiming;
34+
IncludeRemainingAttempts = includeRemainingAttempts;
2935
}
3036

31-
public static readonly EffectiveRedirectResponse Disabled = new(false, null, null, null, null, false);
37+
public static readonly EffectiveRedirectResponse Disabled = new(false, null, null, null, null, false, false, false);
3238

3339
public static EffectiveRedirectResponse FromLogin(LoginRedirectOptions login)
3440
=> new(
@@ -37,7 +43,9 @@ public static EffectiveRedirectResponse FromLogin(LoginRedirectOptions login)
3743
login.FailureRedirect,
3844
login.FailureQueryKey,
3945
login.FailureCodes,
40-
login.AllowReturnUrlOverride
46+
login.AllowReturnUrlOverride,
47+
login.IncludeLockoutTiming,
48+
login.IncludeRemainingAttempts
4149
);
4250

4351
public static EffectiveRedirectResponse FromLogout(LogoutRedirectOptions logout)
@@ -47,6 +55,8 @@ public static EffectiveRedirectResponse FromLogout(LogoutRedirectOptions logout)
4755
null,
4856
null,
4957
null,
50-
logout.AllowReturnUrlOverride
58+
logout.AllowReturnUrlOverride,
59+
false,
60+
false
5161
);
5262
}

src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
using CodeBeam.UltimateAuth.Core.Abstractions;
22
using CodeBeam.UltimateAuth.Core.Contracts;
33
using CodeBeam.UltimateAuth.Core.Domain;
4-
using CodeBeam.UltimateAuth.Core.Options;
54
using CodeBeam.UltimateAuth.Server.Abstractions;
65
using CodeBeam.UltimateAuth.Server.Auth;
76
using CodeBeam.UltimateAuth.Server.Infrastructure;
8-
using CodeBeam.UltimateAuth.Server.Options;
97
using CodeBeam.UltimateAuth.Server.Services;
108
using Microsoft.AspNetCore.Http;
119

@@ -68,7 +66,7 @@ public async Task<IResult> LoginAsync(HttpContext ctx)
6866

6967
if (!result.IsSuccess)
7068
{
71-
var decisionFailure = _redirectResolver.ResolveFailure(authFlow, ctx, result.FailureReason ?? AuthFailureReason.Unknown);
69+
var decisionFailure = _redirectResolver.ResolveFailure(authFlow, ctx, result.FailureReason ?? AuthFailureReason.Unknown, result);
7270

7371
return decisionFailure.Enabled
7472
? Results.Redirect(decisionFailure.TargetUrl!)

src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ public async Task<LoginResult> LoginAsync(AuthFlowContext flow, LoginRequest req
127127
};
128128

129129
var decision = _authority.Decide(decisionContext);
130+
DateTimeOffset? lockoutUntilUtc = null;
130131

131132
if (candidateUserId is not null)
132133
{
@@ -138,6 +139,11 @@ public async Task<LoginResult> LoginAsync(AuthFlowContext flow, LoginRequest req
138139
{
139140
var isCurrentlyLocked = securityState?.IsLocked == true && securityState?.LockedUntil is DateTimeOffset until && until > now;
140141

142+
if (isCurrentlyLocked)
143+
{
144+
lockoutUntilUtc = securityState!.LockedUntil;
145+
}
146+
141147
if (!isCurrentlyLocked)
142148
{
143149
await _securityWriter.RecordFailedLoginAsync(request.Tenant, candidateUserId, now, ct);
@@ -147,16 +153,25 @@ public async Task<LoginResult> LoginAsync(AuthFlowContext flow, LoginRequest req
147153

148154
if (_options.Login.MaxFailedAttempts > 0 && nextCount >= _options.Login.MaxFailedAttempts)
149155
{
150-
var lockedUntil = now.AddMinutes(_options.Login.LockoutMinutes);
156+
var lockedUntil = now.Add(_options.Login.LockoutDuration);
151157
await _securityWriter.LockUntilAsync(request.Tenant, candidateUserId, lockedUntil, ct);
158+
lockoutUntilUtc = lockedUntil;
152159
}
153160
}
154161

155162
}
156163
}
157164

158165
if (decision.Kind == LoginDecisionKind.Deny)
166+
{
167+
if (lockoutUntilUtc is not null)
168+
return LoginResult.Failed(AuthFailureReason.LockedOut, lockoutUntilUtc);
169+
170+
if (decision.FailureReason == AuthFailureReason.LockedOut)
171+
return LoginResult.Failed(decision.FailureReason, lockoutUntilUtc);
172+
159173
return LoginResult.Failed(decision.FailureReason);
174+
}
160175

161176
if (decision.Kind == LoginDecisionKind.Challenge)
162177
{

0 commit comments

Comments
 (0)