Skip to content
Merged
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 @@ -190,10 +190,12 @@ private async Task VerifyAsync(Guid id)
title: "Demo Verification",
markupMessage: (MarkupString)
"""
This is a <b>demo</b> action.<br/><br/>
In a real app, you should verify identifiers via <b>Email</b>, <b>SMS</b>, or an <b>Authenticator</b> flow.
This will only mark the identifier as verified in UltimateAuth.
""",
This Development sample <b>bypasses ownership verification</b>.<br/><br/>
No email or SMS proof is checked. The identifier will be marked
as verified for demonstration purposes only.<br/><br/>
Production applications must implement a real
<b>IUserIdentifierVerifier</b>.
""",
yesText: "Verify", noText: "Cancel",
options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" });

Expand All @@ -203,7 +205,12 @@ This will only mark the identifier as verified in UltimateAuth.
return;
}

VerifyUserIdentifierRequest request = new() { Id = id };
VerifyUserIdentifierRequest request = new()
{
Id = id,
Proof = UserKey is null ? "demo-verification-bypass" : null
};

UAuthResult result;

if (UserKey is null)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using CodeBeam.UltimateAuth.Users;
using Microsoft.Extensions.Hosting;

namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure;

// TODO: Replace this demo bypass with a real verification challenge flow.
// Never register this implementation in production.
internal sealed class DevelopmentBypassUserIdentifierVerifier(IHostEnvironment environment) : IUserIdentifierVerifier
{
public const string DemoProof = "demo-verification-bypass";

public Task<bool> VerifyAsync(UserIdentifierVerificationContext context, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();

if (!environment.IsDevelopment())
{
throw new InvalidOperationException("Development verification bypass cannot run outside Development.");
}

return Task.FromResult(string.Equals(context.Proof, DemoProof, StringComparison.Ordinal));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
if (builder.Environment.IsDevelopment())
{
builder.Services.AddScoped<ICredentialResetNotifier, DevelopmentCredentialResetNotifier>();
builder.Services.AddScoped<IUserIdentifierVerifier, DevelopmentBypassUserIdentifierVerifier>();
}

builder.Services.Configure<ForwardedHeadersOptions>(options =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ private static int MapStatusCode(UAuthRuntimeException ex) =>
UAuthForbiddenException => StatusCodes.Status403Forbidden,
UAuthNotFoundException => StatusCodes.Status404NotFound,
UAuthChallengeRequiredException => StatusCodes.Status401Unauthorized,
UAuthIdentifierNotFoundException => StatusCodes.Status404NotFound,
UAuthIdentifierConflictException => StatusCodes.Status409Conflict,
UAuthIdentifierValidationException => StatusCodes.Status400BadRequest,
_ => StatusCodes.Status400BadRequest
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,10 @@
public sealed record VerifyUserIdentifierRequest
{
public Guid Id { get; init; }

/// <summary>
/// Required for self verification.
/// Not required for authorized admin verification.
/// </summary>
public string? Proof { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public static IServiceCollection AddUltimateAuthUsersReference(this IServiceColl
services.TryAddScoped<IUserLifecycleSnapshotProvider, UserLifecycleSnapshotProvider>();
services.AddScoped<ILoginIdentifierResolver, LoginIdentifierResolver>();

services.TryAddScoped<IUserIdentifierVerifier, NotConfiguredUserIdentifierVerifier>();

return services;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace CodeBeam.UltimateAuth.Users.Reference;

internal sealed class NotConfiguredUserIdentifierVerifier : IUserIdentifierVerifier
{
internal static InvalidOperationException CreateException() => new(
"User identifier verification is not configured. " +
"Implement IUserIdentifierVerifier and register it with " +
"services.AddScoped<IUserIdentifierVerifier, " +
"YourUserIdentifierVerifier>(). " +
"The implementation must validate ownership proof for the " +
"specified tenant, user, identifier and current value.");

public Task<bool> VerifyAsync(UserIdentifierVerificationContext context, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
throw CreateException();
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Errors;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
using CodeBeam.UltimateAuth.Server.Infrastructure;
using CodeBeam.UltimateAuth.Server.Options;
using CodeBeam.UltimateAuth.Users.Contracts;
using CodeBeam.UltimateAuth.Server.Services;
using CodeBeam.UltimateAuth.Users;
using CodeBeam.UltimateAuth.Users.Contracts;
using Microsoft.Extensions.Options;
using CodeBeam.UltimateAuth.Server.Services;

namespace CodeBeam.UltimateAuth.Users.Reference;

Expand All @@ -25,6 +26,7 @@ internal sealed class UserApplicationService : IUserApplicationService
private readonly IIdentifierNormalizer _identifierNormalizer;
private readonly IUserIdentifierAvailabilityService _identifierAvailabilityService;
private readonly ISessionStoreFactory _sessionStoreFactory;
private readonly IUserIdentifierVerifier _identifierVerifier;
private readonly UAuthServerOptions _options;
private readonly IClock _clock;

Expand All @@ -40,6 +42,7 @@ public UserApplicationService(
IIdentifierNormalizer identifierNormalizer,
IUserIdentifierAvailabilityService identifierAvailabilityService,
ISessionStoreFactory sessionStoreFactory,
IUserIdentifierVerifier ıdentifierVerifier,
IOptions<UAuthServerOptions> options,
IClock clock)
{
Expand All @@ -54,6 +57,7 @@ public UserApplicationService(
_identifierNormalizer = identifierNormalizer;
_identifierAvailabilityService = identifierAvailabilityService;
_sessionStoreFactory = sessionStoreFactory;
_identifierVerifier = ıdentifierVerifier;
_options = options.Value;
_clock = clock;
}
Expand All @@ -74,6 +78,12 @@ public async Task<UserCreateResult> CreateUserAsync(AccessContext context, Creat
async atomicCt =>
{
var now = _clock.UtcNow;

var allowVerifiedContacts = context.IsSystemActor ||
(
context.IsAuthenticated && string.Equals(context.Action, UAuthActions.Users.CreateAdmin, StringComparison.Ordinal)
);

var userKey = UserKey.New();

var lifecycleStore = _lifecycleStoreFactory.Create(context.ResourceTenant);
Expand Down Expand Up @@ -125,7 +135,7 @@ await identifierStore.AddAsync(
_identifierNormalizer.Normalize(UserIdentifierType.Email, request.Email).Normalized,
now,
true,
request.EmailVerified ? now : null), atomicCt);
allowVerifiedContacts && request.EmailVerified ? now : null), atomicCt);
}

if (!string.IsNullOrWhiteSpace(request.Phone))
Expand All @@ -140,7 +150,7 @@ await identifierStore.AddAsync(
_identifierNormalizer.Normalize(UserIdentifierType.Phone, request.Phone).Normalized,
now,
true,
request.PhoneVerified ? now : null), atomicCt);
allowVerifiedContacts && request.PhoneVerified ? now : null), atomicCt);
}

foreach (var integration in _integrations)
Expand Down Expand Up @@ -563,7 +573,7 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde
var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant);
var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt);

if (identifier is null || identifier.IsDeleted)
if (identifier is null || identifier.IsDeleted || identifier.UserKey != context.GetTargetUserKey())
throw new UAuthIdentifierNotFoundException("identifier_not_found");

if (identifier.Type == UserIdentifierType.Username && !_options.Identifiers.Behavior.AllowUsernameChange)
Expand Down Expand Up @@ -620,7 +630,7 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar

var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant);
var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt);
if (identifier is null || identifier.IsDeleted)
if (identifier is null || identifier.IsDeleted || identifier.UserKey != context.GetTargetUserKey())
throw new UAuthIdentifierNotFoundException("identifier_not_found");

if (identifier.IsPrimary)
Expand All @@ -644,7 +654,7 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr

var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant);
var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt);
if (identifier is null)
if (identifier is null || identifier.IsDeleted || identifier.UserKey != context.GetTargetUserKey())
throw new UAuthIdentifierNotFoundException("identifier_not_found");

if (!identifier.IsPrimary)
Expand Down Expand Up @@ -676,16 +686,51 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr

public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIdentifierRequest request, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();

var command = new AccessCommand(async innerCt =>
{
innerCt.ThrowIfCancellationRequested();

EnsureOverrideAllowed(context);

var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant);
var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt);
if (identifier is null)
if (identifier is null || identifier.IsDeleted || identifier.UserKey != context.GetTargetUserKey())
throw new UAuthIdentifierNotFoundException("identifier_not_found");

var expectedVersion = identifier.Version;

if (context.IsSelfAction)
{
if (_identifierVerifier is NotConfiguredUserIdentifierVerifier)
throw NotConfiguredUserIdentifierVerifier.CreateException();

if (string.IsNullOrWhiteSpace(request.Proof))
throw new UAuthIdentifierValidationException("identifier_verification_proof_required");

var verified = await _identifierVerifier.VerifyAsync(
new UserIdentifierVerificationContext(
Tenant: context.ResourceTenant,
UserKey: identifier.UserKey,
IdentifierId: identifier.Id,
Type: identifier.Type,
Value: identifier.Value,
Proof: request.Proof),
innerCt);

if (!verified)
throw new UAuthIdentifierValidationException("identifier_verification_failed");
}

// TODO: Provide a built-in identifier verification challenge flow:
// issuance, hashed storage, expiry, attempt limits and notification.
// Bind each challenge to tenant, user, identifier and current value.
//
// TODO: Coordinate single-use proof consumption with persistence.
// A concurrent identifier change must not be marked verified
// using proof issued for the previous value.

identifier.MarkVerified(_clock.UtcNow);
await identifierStore.SaveAsync(identifier, expectedVersion, innerCt);
});
Expand All @@ -701,7 +746,7 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde

var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant);
var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt);
if (identifier is null)
if (identifier is null || identifier.IsDeleted || identifier.UserKey != context.GetTargetUserKey())
throw new UAuthIdentifierNotFoundException("identifier_not_found");

var identifiers = await identifierStore.GetByUserAsync(identifier.UserKey, innerCt);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
using CodeBeam.UltimateAuth.Users.Contracts;

/// <summary>
/// Validates identifier ownership proof on the server.
/// </summary>
/// <remarks>
/// Implementations must bind the proof to the tenant, user,
/// identifier and current value; enforce expiry, attempt limits
/// and single-use consumption.
/// </remarks>
public interface IUserIdentifierVerifier
{
Task<bool> VerifyAsync(UserIdentifierVerificationContext context, CancellationToken ct = default);
}

public sealed record UserIdentifierVerificationContext(
TenantKey Tenant,
UserKey UserKey,
Guid IdentifierId,
UserIdentifierType Type,
string Value,
string Proof);
Loading
Loading