Skip to content

Commit d458639

Browse files
committed
Credential Enhancement
1 parent a9a4412 commit d458639

26 files changed

Lines changed: 643 additions & 586 deletions

src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ public static class Credentials
6262
public const string RevokeSelf = "credentials.revoke.self";
6363
public const string RevokeAdmin = "credentials.revoke.admin";
6464
public const string ActivateSelf = "credentials.activate.self";
65-
public const string ActivateAdmin = "credentials.activate.admin";
6665
public const string BeginResetSelf = "credentials.beginreset.self";
6766
public const string BeginResetAdmin = "credentials.beginreset.admin";
6867
public const string CompleteResetSelf = "credentials.completereset.self";

src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,15 @@ public interface ICredentialEndpointHandler
77
{
88
Task<IResult> GetAllAsync(HttpContext ctx);
99
Task<IResult> AddAsync(HttpContext ctx);
10-
Task<IResult> ChangeAsync(string type, HttpContext ctx);
11-
Task<IResult> RevokeAsync(string type, HttpContext ctx);
12-
Task<IResult> BeginResetAsync(string type, HttpContext ctx);
13-
Task<IResult> CompleteResetAsync(string type, HttpContext ctx);
10+
Task<IResult> ChangeSecretAsync(HttpContext ctx);
11+
Task<IResult> RevokeAsync(HttpContext ctx);
12+
Task<IResult> BeginResetAsync(HttpContext ctx);
13+
Task<IResult> CompleteResetAsync(HttpContext ctx);
1414

1515
Task<IResult> GetAllAdminAsync(UserKey userKey, HttpContext ctx);
1616
Task<IResult> AddAdminAsync(UserKey userKey, HttpContext ctx);
17-
Task<IResult> RevokeAdminAsync(UserKey userKey, string type, HttpContext ctx);
18-
Task<IResult> ActivateAdminAsync(UserKey userKey, string type, HttpContext ctx);
19-
Task<IResult> DeleteAdminAsync(UserKey userKey, string type, HttpContext ctx);
20-
Task<IResult> BeginResetAdminAsync(UserKey userKey, string type, HttpContext ctx);
21-
Task<IResult> CompleteResetAdminAsync(UserKey userKey, string type, HttpContext ctx);
17+
Task<IResult> RevokeAdminAsync(UserKey userKey, HttpContext ctx);
18+
Task<IResult> DeleteAdminAsync(UserKey userKey, HttpContext ctx);
19+
Task<IResult> BeginResetAdminAsync(UserKey userKey, HttpContext ctx);
20+
Task<IResult> CompleteResetAdminAsync(UserKey userKey, HttpContext ctx);
2221
}

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

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -223,17 +223,17 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options
223223
credentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx)
224224
=> await h.AddAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
225225

226-
credentials.MapPost("/{type}/change", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx)
227-
=> await h.ChangeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
226+
credentials.MapPost("/change", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx)
227+
=> await h.ChangeSecretAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
228228

229-
credentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx)
230-
=> await h.RevokeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
229+
credentials.MapPost("/revoke", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx)
230+
=> await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
231231

232-
credentials.MapPost("/{type}/reset/begin", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx)
233-
=> await h.BeginResetAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
232+
credentials.MapPost("/reset/begin", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx)
233+
=> await h.BeginResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
234234

235-
credentials.MapPost("/{type}/reset/complete", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx)
236-
=> await h.CompleteResetAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
235+
credentials.MapPost("/reset/complete", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx)
236+
=> await h.CompleteResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
237237

238238

239239
adminCredentials.MapPost("/get", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx)
@@ -242,20 +242,17 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options
242242
adminCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx)
243243
=> await h.AddAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
244244

245-
adminCredentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx)
246-
=> await h.RevokeAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
245+
adminCredentials.MapPost("/revoke", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx)
246+
=> await h.RevokeAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
247247

248-
adminCredentials.MapPost("/{type}/activate", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx)
249-
=> await h.ActivateAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
248+
adminCredentials.MapPost("/reset/begin", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx)
249+
=> await h.BeginResetAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
250250

251-
adminCredentials.MapPost("/{type}/reset/begin", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx)
252-
=> await h.BeginResetAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
251+
adminCredentials.MapPost("/reset/complete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx)
252+
=> await h.CompleteResetAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
253253

254-
adminCredentials.MapPost("/{type}/reset/complete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx)
255-
=> await h.CompleteResetAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
256-
257-
adminCredentials.MapPost("/{type}/delete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx)
258-
=> await h.DeleteAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
254+
adminCredentials.MapPost("/delete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx)
255+
=> await h.DeleteAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement));
259256
}
260257

261258
if (options.Endpoints.Authorization != false)

src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/AccessCommand.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
namespace CodeBeam.UltimateAuth.Server.Infrastructure;
22

3-
internal sealed class AccessCommand : IAccessCommand
3+
public sealed class AccessCommand : IAccessCommand
44
{
55
private readonly Func<CancellationToken, Task> _execute;
66

@@ -12,7 +12,7 @@ public AccessCommand(Func<CancellationToken, Task> execute)
1212
public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct);
1313
}
1414

15-
internal sealed class AccessCommand<TResult> : IAccessCommand<TResult>
15+
public sealed class AccessCommand<TResult> : IAccessCommand<TResult>
1616
{
1717
private readonly Func<CancellationToken, Task<TResult>> _execute;
1818

src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
public sealed record CredentialDto
44
{
5+
public Guid Id { get; set; }
56
public CredentialType Type { get; init; }
67

78
public CredentialSecurityStatus Status { get; init; }
Lines changed: 161 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,41 @@
1-
namespace CodeBeam.UltimateAuth.Credentials.Contracts;
1+
using CodeBeam.UltimateAuth.Core.Errors;
2+
3+
namespace CodeBeam.UltimateAuth.Credentials.Contracts;
24

35
public sealed class CredentialSecurityState
46
{
57
public DateTimeOffset? RevokedAt { get; }
68
public DateTimeOffset? LockedUntil { get; }
79
public DateTimeOffset? ExpiresAt { get; }
8-
public DateTimeOffset? ResetRequestedAt { get; init; }
10+
public DateTimeOffset? ResetRequestedAt { get; }
11+
public DateTimeOffset? ResetExpiresAt { get; }
12+
public DateTimeOffset? ResetConsumedAt { get; }
13+
public int FailedAttemptCount { get; }
14+
public DateTimeOffset? LastFailedAt { get; }
915
public Guid SecurityStamp { get; }
1016

17+
public CredentialSecurityState(
18+
DateTimeOffset? revokedAt = null,
19+
DateTimeOffset? lockedUntil = null,
20+
DateTimeOffset? expiresAt = null,
21+
DateTimeOffset? resetRequestedAt = null,
22+
DateTimeOffset? resetExpiresAt = null,
23+
DateTimeOffset? resetConsumedAt = null,
24+
int failedAttemptCount = 0,
25+
DateTimeOffset? lastFailedAt = null,
26+
Guid securityStamp = default)
27+
{
28+
RevokedAt = revokedAt;
29+
LockedUntil = lockedUntil;
30+
ExpiresAt = expiresAt;
31+
ResetRequestedAt = resetRequestedAt;
32+
ResetExpiresAt = resetExpiresAt;
33+
ResetConsumedAt = resetConsumedAt;
34+
FailedAttemptCount = failedAttemptCount;
35+
LastFailedAt = lastFailedAt;
36+
SecurityStamp = securityStamp;
37+
}
38+
1139
public CredentialSecurityStatus Status(DateTimeOffset now)
1240
{
1341
if (RevokedAt is not null)
@@ -20,25 +48,19 @@ public CredentialSecurityStatus Status(DateTimeOffset now)
2048
return CredentialSecurityStatus.Expired;
2149

2250
if (ResetRequestedAt is not null)
51+
{
52+
if (ResetConsumedAt is not null)
53+
return CredentialSecurityStatus.Active;
54+
55+
if (ResetExpiresAt is not null && ResetExpiresAt <= now)
56+
return CredentialSecurityStatus.Active;
57+
2358
return CredentialSecurityStatus.ResetRequested;
59+
}
2460

2561
return CredentialSecurityStatus.Active;
2662
}
2763

28-
public CredentialSecurityState(
29-
DateTimeOffset? revokedAt = null,
30-
DateTimeOffset? lockedUntil = null,
31-
DateTimeOffset? expiresAt = null,
32-
DateTimeOffset? resetRequestedAt = null,
33-
Guid securityStamp = default)
34-
{
35-
RevokedAt = revokedAt;
36-
LockedUntil = lockedUntil;
37-
ExpiresAt = expiresAt;
38-
ResetRequestedAt = resetRequestedAt;
39-
SecurityStamp = securityStamp;
40-
}
41-
4264
/// <summary>
4365
/// Determines whether the credential can be used at the given time.
4466
/// </summary>
@@ -51,54 +73,158 @@ public static CredentialSecurityState Active(Guid? securityStamp = null)
5173
lockedUntil: null,
5274
expiresAt: null,
5375
resetRequestedAt: null,
54-
securityStamp: securityStamp ?? Guid.NewGuid());
76+
resetExpiresAt: null,
77+
resetConsumedAt: null,
78+
failedAttemptCount: 0,
79+
lastFailedAt: null,
80+
securityStamp: securityStamp ?? Guid.NewGuid()
81+
);
5582
}
5683

84+
/// <summary>
85+
/// Revokes the credential permanently.
86+
/// </summary>
5787
public CredentialSecurityState Revoke(DateTimeOffset now)
5888
{
89+
if (RevokedAt is not null)
90+
return this;
91+
5992
return new CredentialSecurityState(
6093
revokedAt: now,
6194
lockedUntil: LockedUntil,
6295
expiresAt: ExpiresAt,
6396
resetRequestedAt: ResetRequestedAt,
64-
securityStamp: Guid.NewGuid());
97+
resetExpiresAt: ResetExpiresAt,
98+
resetConsumedAt: ResetConsumedAt,
99+
failedAttemptCount: FailedAttemptCount,
100+
lastFailedAt: LastFailedAt,
101+
securityStamp: Guid.NewGuid()
102+
);
65103
}
66104

105+
/// <summary>
106+
/// Sets or clears expiry while preserving the rest of the state.
107+
/// </summary>
67108
public CredentialSecurityState SetExpiry(DateTimeOffset? expiresAt)
68109
{
110+
// optional: normalize already-expired value? keep as-is; domain policy can decide.
111+
if (ExpiresAt == expiresAt)
112+
return this;
113+
69114
return new CredentialSecurityState(
70115
revokedAt: RevokedAt,
71116
lockedUntil: LockedUntil,
72117
expiresAt: expiresAt,
73118
resetRequestedAt: ResetRequestedAt,
74-
securityStamp: SecurityStamp);
119+
resetExpiresAt: ResetExpiresAt,
120+
resetConsumedAt: ResetConsumedAt,
121+
failedAttemptCount: FailedAttemptCount,
122+
lastFailedAt: LastFailedAt,
123+
securityStamp: EnsureStamp(SecurityStamp)
124+
);
75125
}
76126

77-
public CredentialSecurityState BeginReset(DateTimeOffset now, bool rotateStamp = true)
78-
=> new(
79-
revokedAt: RevokedAt,
80-
lockedUntil: LockedUntil,
81-
expiresAt: ExpiresAt,
82-
resetRequestedAt: now,
83-
securityStamp: rotateStamp ? Guid.NewGuid() : SecurityStamp
84-
);
85-
86-
public CredentialSecurityState CompleteReset(bool rotateStamp = true)
87-
=> new(
127+
private static Guid EnsureStamp(Guid stamp) => stamp == Guid.Empty ? Guid.NewGuid() : stamp;
128+
129+
public CredentialSecurityState RotateStamp()
130+
{
131+
return new CredentialSecurityState(
88132
revokedAt: RevokedAt,
89133
lockedUntil: LockedUntil,
90134
expiresAt: ExpiresAt,
91-
resetRequestedAt: null,
92-
securityStamp: rotateStamp ? Guid.NewGuid() : SecurityStamp
135+
resetRequestedAt: ResetRequestedAt,
136+
resetExpiresAt: ResetExpiresAt,
137+
resetConsumedAt: ResetConsumedAt,
138+
failedAttemptCount: FailedAttemptCount,
139+
lastFailedAt: LastFailedAt,
140+
securityStamp: Guid.NewGuid()
93141
);
142+
}
94143

95-
public CredentialSecurityState RotateStamp()
144+
public CredentialSecurityState RegisterSuccessfulAuthentication()
96145
{
97146
return new CredentialSecurityState(
98147
revokedAt: RevokedAt,
99-
lockedUntil: LockedUntil,
148+
lockedUntil: null,
149+
expiresAt: ExpiresAt,
150+
resetRequestedAt: ResetRequestedAt,
151+
resetExpiresAt: ResetExpiresAt,
152+
resetConsumedAt: ResetConsumedAt,
153+
failedAttemptCount: 0,
154+
lastFailedAt: null,
155+
securityStamp: EnsureStamp(SecurityStamp)
156+
);
157+
}
158+
159+
public CredentialSecurityState RegisterFailedAttempt(DateTimeOffset now, int threshold, TimeSpan lockoutDuration)
160+
{
161+
if (threshold <= 0)
162+
throw new UAuthValidationException(nameof(threshold));
163+
164+
var failed = FailedAttemptCount + 1;
165+
166+
var newLockedUntil = LockedUntil;
167+
168+
if (failed >= threshold)
169+
{
170+
var candidate = now.Add(lockoutDuration);
171+
172+
if (LockedUntil is null || candidate > LockedUntil)
173+
newLockedUntil = candidate;
174+
}
175+
176+
return new CredentialSecurityState(
177+
revokedAt: RevokedAt,
178+
lockedUntil: newLockedUntil,
100179
expiresAt: ExpiresAt,
101180
resetRequestedAt: ResetRequestedAt,
102-
securityStamp: Guid.NewGuid());
181+
resetExpiresAt: ResetExpiresAt,
182+
resetConsumedAt: ResetConsumedAt,
183+
failedAttemptCount: failed,
184+
lastFailedAt: now,
185+
securityStamp: EnsureStamp(SecurityStamp)
186+
);
187+
}
188+
189+
public CredentialSecurityState BeginReset(DateTimeOffset now, TimeSpan validity)
190+
{
191+
if (validity <= TimeSpan.Zero)
192+
throw new UAuthValidationException("credential_lockout_threshold_invalid");
193+
194+
return new CredentialSecurityState(
195+
revokedAt: RevokedAt,
196+
lockedUntil: LockedUntil,
197+
expiresAt: ExpiresAt,
198+
resetRequestedAt: now,
199+
resetExpiresAt: now.Add(validity),
200+
resetConsumedAt: null,
201+
failedAttemptCount: FailedAttemptCount,
202+
lastFailedAt: LastFailedAt,
203+
securityStamp: Guid.NewGuid()
204+
);
205+
}
206+
207+
public CredentialSecurityState CompleteReset(DateTimeOffset now, bool rotateStamp = true)
208+
{
209+
if (ResetRequestedAt is null)
210+
throw new UAuthValidationException("reset_not_requested");
211+
212+
if (ResetConsumedAt is not null)
213+
throw new UAuthValidationException("reset_already_consumed");
214+
215+
if (ResetExpiresAt is not null && ResetExpiresAt <= now)
216+
throw new UAuthValidationException("reset_expired");
217+
218+
return new CredentialSecurityState(
219+
revokedAt: RevokedAt,
220+
lockedUntil: null,
221+
expiresAt: ExpiresAt,
222+
resetRequestedAt: null,
223+
resetExpiresAt: null,
224+
resetConsumedAt: now,
225+
failedAttemptCount: 0,
226+
lastFailedAt: null,
227+
securityStamp: rotateStamp ? Guid.NewGuid() : EnsureStamp(SecurityStamp)
228+
);
103229
}
104230
}

src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22

33
public sealed record BeginCredentialResetRequest
44
{
5-
public string? Reason { get; init; }
5+
public Guid Id { get; init; }
6+
public string? Channel { get; init; }
67
}

src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22

33
public sealed record ChangeCredentialRequest
44
{
5-
public CredentialType Type { get; init; }
6-
7-
public string CurrentSecret { get; init; } = default!;
8-
public string NewSecret { get; init; } = default!;
5+
public Guid Id { get; init; }
6+
public required string CurrentSecret { get; init; }
7+
public required string NewSecret { get; init; }
98
}

src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
public sealed record CompleteCredentialResetRequest
44
{
5+
public Guid Id { get; init; }
6+
public string? ResetToken { get; init; }
57
public required string NewSecret { get; init; }
6-
public string? Source { get; init; }
78
}

0 commit comments

Comments
 (0)