diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 350cadd2..22c26b0d 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -12,6 +12,7 @@ + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj index 13ddab2b..32b6af51 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj @@ -14,6 +14,7 @@ + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index 22f00b6f..f3f88149 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -1,3 +1,4 @@ +using CodeBeam.UltimateAuth.Authentication.InMemory; using CodeBeam.UltimateAuth.Authorization.InMemory; using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; @@ -8,8 +9,6 @@ using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Sample.UAuthHub.Components; using CodeBeam.UltimateAuth.Security.Argon2; -using CodeBeam.UltimateAuth.Server.Authentication; -using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; @@ -57,6 +56,7 @@ .AddUltimateAuthAuthorizationReference() .AddUltimateAuthInMemorySessions() .AddUltimateAuthInMemoryTokens() + .AddUltimateAuthInMemoryAuthenticationSecurity() .AddUltimateAuthArgon2(); builder.Services.AddUltimateAuthClient(o => diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj index 34314a51..7cc56f81 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj @@ -15,6 +15,7 @@ + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Custom/UAuthPageComponent.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Custom/UAuthPageComponent.razor new file mode 100644 index 00000000..5af543e4 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Custom/UAuthPageComponent.razor @@ -0,0 +1,10 @@ + + + @ChildContent + + + +@code { + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor new file mode 100644 index 00000000..7873ce91 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor @@ -0,0 +1,93 @@ +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + Identifier Management + User: @AuthState?.Identity?.DisplayName + + + + + Suspend Account + + + + Delete Account + + + + + +@code { + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private async Task SuspendAccountAsync() + { + var info = await DialogService.ShowMessageBoxAsync( + title: "Are You Sure", + markupMessage: (MarkupString) + """ + You are going to suspend your account.

+ You can still active your account later. + """, + yesText: "Suspend", noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (info != true) + { + Snackbar.Add("Suspend process cancelled", Severity.Info); + return; + } + + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfUserStatus.SelfSuspended }; + var result = await UAuthClient.Users.ChangeStatusSelfAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Your account suspended successfully.", Severity.Success); + MudDialog.Close(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Delete failed.", Severity.Error); + } + } + + private async Task DeleteAccountAsync() + { + var info = await DialogService.ShowMessageBoxAsync( + title: "Are You Sure", + markupMessage: (MarkupString) + """ + You are going to delete your account.

+ This action can't be undone.

+ (Actually it is, admin can handle soft deleted accounts.) + """, + yesText: "Delete", noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (info != true) + { + Snackbar.Add("Deletion cancelled", Severity.Info); + return; + } + + var result = await UAuthClient.Users.DeleteMeAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Your account deleted successfully.", Severity.Success); + MudDialog.Close(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Delete failed.", Severity.Error); + } + } +} \ No newline at end of file diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor new file mode 100644 index 00000000..47436c7e --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor @@ -0,0 +1,109 @@ +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Credentials.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject IUAuthStateManager StateManager +@inject NavigationManager Nav + + + + Credential Management + User: @AuthState?.Identity?.DisplayName + + + + + + + + + + + + + + + + + + Change Password + + + + + + Cancel + OK + + + +@code { + private MudForm _form = null!; + private string? _oldPassword; + private string? _newPassword; + private string? _newPasswordCheck; + private bool _passwordMode1 = false; + private bool _passwordMode2 = false; + private bool _passwordMode3 = true; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender) + { + + } + } + + private async Task ChangePasswordAsync() + { + if (_form is null) + return; + + await _form.Validate(); + if (!_form.IsValid) + { + Snackbar.Add("Form is not valid.", Severity.Error); + return; + } + + if (_newPassword != _newPasswordCheck) + { + Snackbar.Add("New password and check do not match", Severity.Error); + return; + } + + ChangeCredentialRequest request = new ChangeCredentialRequest + { + CurrentSecret = _oldPassword!, + NewSecret = _newPassword!, + }; + + var result = await UAuthClient.Credentials.ChangeMyAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Password changed successfully", Severity.Success); + // Logout also if you want. Password change only revoke other open sessions, not the current one. + // await UAuthClient.Flows.LogoutAsync(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "An error occurred while changing password", Severity.Error); + } + } + + private string PasswordMatch(string arg) => _newPassword != arg ? "Passwords don't match" : string.Empty; + + private void Submit() => MudDialog.Close(DialogResult.Ok(true)); + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor index 6aba7ba5..dc5b27f1 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor @@ -1,17 +1,23 @@ -@using CodeBeam.UltimateAuth.Users.Contracts +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar +@inject IDialogService DialogService - + Identifier Management User: @AuthState?.Identity?.DisplayName - + - Identifiers + + Identifiers + + + @@ -22,27 +28,27 @@ - + @if (context.Item.IsPrimary) { - + } else { - + } - + - + @@ -54,17 +60,29 @@ - - - - - - Add - + + + + + + + + + + + + + + + + + Add + + - - + + Cancel @@ -73,8 +91,12 @@ @code { + private MudDataGrid? _grid; private UserIdentifierType _newIdentifierType; private string? _newIdentifierValue; + private bool _newIdentifierPrimary; + private bool _loading = false; + private bool _reloadQueued; [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; @@ -82,8 +104,6 @@ [Parameter] public UAuthState AuthState { get; set; } = default!; - private List _identifiers = new(); - protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); @@ -93,12 +113,77 @@ var result = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); if (result != null && result.IsSuccess && result.Value != null) { - _identifiers = result.Value.ToList(); + await ReloadAsync(); StateHasChanged(); } } } + private async Task> LoadServerData(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new PageRequest + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + var res = await UAuthClient.Identifiers.GetMyIdentifiersAsync(req); + + if (!res.IsSuccess || res.Value is null) + { + Snackbar.Add(res.Problem?.Title ?? "Failed", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task ReloadAsync() + { + if (_loading) + { + _reloadQueued = true; + return; + } + + if (_grid is null) + return; + + _loading = true; + await InvokeAsync(StateHasChanged); + await Task.Delay(300); + + try + { + await _grid.ReloadServerData(); + } + finally + { + _loading = false; + + if (_reloadQueued) + { + _reloadQueued = false; + await ReloadAsync(); + } + + await InvokeAsync(StateHasChanged); + } + } + private async Task CommittedItemChanges(UserIdentifierDto item) { UpdateUserIdentifierRequest updateRequest = new() @@ -113,9 +198,10 @@ } else { - Snackbar.Add("Failed to update identifier", Severity.Error); + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to update identifier", Severity.Error); } + await ReloadAsync(); return DataGridEditFormAction.Close; } @@ -130,15 +216,15 @@ AddUserIdentifierRequest request = new() { Type = _newIdentifierType, - Value = _newIdentifierValue + Value = _newIdentifierValue, + IsPrimary = _newIdentifierPrimary }; var result = await UAuthClient.Identifiers.AddSelfAsync(request); if (result.IsSuccess) { Snackbar.Add("Identifier added successfully", Severity.Success); - var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - _identifiers = getResult.Value?.ToList() ?? new List(); + await ReloadAsync(); StateHasChanged(); } else @@ -147,37 +233,52 @@ } } - private async Task SetPrimaryAsync(Guid id) + private async Task VerifyAsync(Guid id) { - SetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; - var result = await UAuthClient.Identifiers.SetPrimarySelfAsync(request); + var demoInfo = await DialogService.ShowMessageBoxAsync( + title: "Demo verification", + markupMessage: (MarkupString) + """ + This is a demo action.

+ In a real app, you should verify identifiers via Email, SMS, or an Authenticator flow. + This will only mark the identifier as verified in UltimateAuth. + """, + yesText: "Verify", noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (demoInfo != true) + { + Snackbar.Add("Verification cancelled", Severity.Info); + return; + } + + VerifyUserIdentifierRequest request = new() { IdentifierId = id }; + var result = await UAuthClient.Identifiers.VerifySelfAsync(request); if (result.IsSuccess) { - Snackbar.Add("Primary identifier set successfully", Severity.Success); - var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - _identifiers = getResult.Value?.ToList() ?? new List(); + Snackbar.Add("Identifier verified successfully", Severity.Success); + await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to set primary identifier", Severity.Error); + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to verify primary identifier", Severity.Error); } } - private async Task VerifyAsync(Guid id) + private async Task SetPrimaryAsync(Guid id) { - VerifyUserIdentifierRequest request = new() { IdentifierId = id }; - var result = await UAuthClient.Identifiers.VerifySelfAsync(request); + SetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; + var result = await UAuthClient.Identifiers.SetPrimarySelfAsync(request); if (result.IsSuccess) { - Snackbar.Add("Primary identifier verified successfully", Severity.Success); - var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - _identifiers = getResult.Value?.ToList() ?? new List(); + Snackbar.Add("Primary identifier set successfully", Severity.Success); + await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to verify primary identifier", Severity.Error); + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to set primary identifier", Severity.Error); } } @@ -188,8 +289,7 @@ if (result.IsSuccess) { Snackbar.Add("Primary identifier unset successfully", Severity.Success); - var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - _identifiers = getResult.Value?.ToList() ?? new List(); + await ReloadAsync(); StateHasChanged(); } else @@ -205,8 +305,7 @@ if (result.IsSuccess) { Snackbar.Add("Identifier deleted successfully", Severity.Success); - var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - _identifiers = getResult.Value?.ToList() ?? new List(); + await ReloadAsync(); StateHasChanged(); } else diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor new file mode 100644 index 00000000..9f89c886 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor @@ -0,0 +1,155 @@ +@using CodeBeam.UltimateAuth.Authorization.Contracts +@using CodeBeam.UltimateAuth.Core.Defaults +@using System.Reflection + +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar + + + + Role Permissions + @Role.Name + + + + @* For Debug *@ + @* Current Permissions: @string.Join(", ", Role.Permissions) *@ + + @foreach (var group in _groups) + { + + + + + @group.Name (@group.Items.Count(x => x.Selected)/@group.Items.Count) + + + + + @foreach (var perm in group.Items) + { + + + + } + + + + } + + + + + Cancel + Save + + + +@code { + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public RoleInfo Role { get; set; } = default!; + + private List _groups = new(); + + protected override void OnInitialized() + { + var catalog = UAuthPermissionCatalog.GetAdminPermissions(); + var expanded = PermissionExpander.Expand(Role.Permissions, catalog); + var selected = expanded.Select(x => x.Value).ToHashSet(); + + _groups = catalog + .GroupBy(p => p.Split('.')[0]) + .Select(g => new PermissionGroup + { + Name = g.Key, + Items = g.Select(p => new PermissionItem + { + Value = p, + Selected = selected.Contains(p) + }).ToList() + }) + .OrderBy(x => x.Name) + .ToList(); + } + + void ToggleGroup(PermissionGroup group, bool value) + { + foreach (var item in group.Items) + item.Selected = value; + } + + void TogglePermission(PermissionItem item, bool value) + { + item.Selected = value; + } + + bool? GetGroupState(PermissionGroup group) + { + var selected = group.Items.Count(x => x.Selected); + + if (selected == 0) + return false; + + if (selected == group.Items.Count) + return true; + + return null; + } + + private async Task Save() + { + var permissions = _groups.SelectMany(g => g.Items).Where(x => x.Selected).Select(x => Permission.From(x.Value)).ToList(); + + var req = new SetPermissionsRequest + { + Permissions = permissions + }; + + var res = await UAuthClient.Authorization.SetPermissionsAsync(Role.Id, req); + + if (!res.IsSuccess) + { + Snackbar.Add(res.Problem?.Title ?? "Failed to update permissions", Severity.Error); + return; + } + + Role = (await UAuthClient.Authorization.QueryRolesAsync(new RoleQuery() { Search = Role.Name })).Value!.Items.First(); + Snackbar.Add("Permissions updated", Severity.Success); + RefreshUI(); + } + + private void RefreshUI() + { + var catalog = UAuthPermissionCatalog.GetAdminPermissions(); + var expanded = PermissionExpander.Expand(Role.Permissions, catalog); + var selected = expanded.Select(x => x.Value).ToHashSet(); + + foreach (var group in _groups) + { + foreach (var item in group.Items) + { + item.Selected = selected.Contains(item.Value); + } + } + + StateHasChanged(); + } + + private void Cancel() => MudDialog.Cancel(); + + private class PermissionGroup + { + public string Name { get; set; } = ""; + public List Items { get; set; } = new(); + } + + private class PermissionItem + { + public string Value { get; set; } = ""; + public bool Selected { get; set; } + } +} \ No newline at end of file diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor new file mode 100644 index 00000000..a6c7df97 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor @@ -0,0 +1,174 @@ +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + Identifier Management + User: @AuthState?.Identity?.DisplayName + + + + + + + + Name + + + + + + + + + + + + + + + + + + + Personal + + + + + + + + + + + + + + + + + + + Localization + + + + + + + + + + + @foreach (var tz in TimeZoneInfo.GetSystemTimeZones()) + { + @tz.Id - @tz.DisplayName + } + + + + + + + + + + + + Cancel + Save + + + +@code { + + private MudForm? _form; + + private string? _firstName; + private string? _lastName; + private string? _displayName; + + private DateTime? _birthDate; + private string? _gender; + private string? _bio; + + private string? _language; + private string? _timeZone; + private string? _culture; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + var result = await UAuthClient.Users.GetMeAsync(); + + if (result.IsSuccess && result.Value is not null) + { + var p = result.Value; + + _firstName = p.FirstName; + _lastName = p.LastName; + _displayName = p.DisplayName; + + _gender = p.Gender; + _birthDate = p.BirthDate?.ToDateTime(TimeOnly.MinValue); + _bio = p.Bio; + + _language = p.Language; + _timeZone = p.TimeZone; + _culture = p.Culture; + } + } + + private async Task SaveAsync() + { + if (AuthState is null || AuthState.Identity is null) + { + Snackbar.Add("No AuthState found.", Severity.Error); + return; + } + + if (_form is not null) + { + await _form.Validate(); + if (!_form.IsValid) + return; + } + + var request = new UpdateProfileRequest + { + FirstName = _firstName, + LastName = _lastName, + DisplayName = _displayName, + BirthDate = _birthDate.HasValue ? DateOnly.FromDateTime(_birthDate.Value) : null, + Gender = _gender, + Bio = _bio, + Language = _language, + TimeZone = _timeZone, + Culture = _culture + }; + + var result = await UAuthClient.Users.UpdateMeAsync(request); + + if (result.IsSuccess) + { + Snackbar.Add("Profile updated", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to update profile", Severity.Error); + } + } + + private void Cancel() => MudDialog.Cancel(); + +} \ No newline at end of file diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor new file mode 100644 index 00000000..2b94e413 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor @@ -0,0 +1,73 @@ +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Credentials.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject IUAuthStateManager StateManager +@inject NavigationManager Nav + + + + Reset Credential + + + + + This is a demonstration of how to implement a credential reset flow. + In a production application, you should use reset token or code in email, SMS etc. verification steps. + + + Reset request always returns ok even with not found users due to security reasons. + + + Request Reset + @if (_resetRequested) + { + Your reset code is: (Copy it before next step) + @_resetCode + Use Reset Code + } + + + + Cancel + OK + + + +@code { + private bool _resetRequested = false; + private string? _resetCode; + private string? _identifier; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private async Task RequestResetAsync() + { + var request = new BeginCredentialResetRequest + { + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Code, + Identifier = _identifier ?? string.Empty + }; + + var result = await UAuthClient.Credentials.BeginResetMyAsync(request); + if (!result.IsSuccess || result.Value is null) + { + Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "Failed to request credential reset.", Severity.Error); + return; + } + + _resetCode = result.Value.Token; + _resetRequested = true; + } + + private void Submit() => MudDialog.Close(DialogResult.Ok(true)); + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor new file mode 100644 index 00000000..540bc7ae --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor @@ -0,0 +1,236 @@ +@using CodeBeam.UltimateAuth.Authorization.Contracts +@using CodeBeam.UltimateAuth.Core.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + + Role Management + Manage system roles + + + + + + + + Roles + + + + + + + + + + + @GetPermissionCount(context.Item) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Create + + + + + + + + Close + + + +@code { + private MudDataGrid? _grid; + private bool _loading; + private bool _reloadQueued; + private string? _newRoleName; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private async Task> LoadServerData(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new RoleQuery + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + var res = await UAuthClient.Authorization.QueryRolesAsync(req); + + if (!res.IsSuccess || res.Value == null) + { + Snackbar.Add(res.Problem?.Title ?? "Failed", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task CommittedItemChanges(RoleInfo role) + { + var req = new RenameRoleRequest + { + Name = role.Name + }; + + var result = await UAuthClient.Authorization.RenameRoleAsync(role.Id, req); + + if (result.IsSuccess) + { + Snackbar.Add("Role renamed", Severity.Success); + } + else + { + Snackbar.Add(result.Problem?.Title ?? "Rename failed", Severity.Error); + } + + await ReloadAsync(); + return DataGridEditFormAction.Close; + } + + private async Task CreateRole() + { + if (string.IsNullOrWhiteSpace(_newRoleName)) + { + Snackbar.Add("Role name required", Severity.Warning); + return; + } + + var req = new CreateRoleRequest + { + Name = _newRoleName + }; + + var res = await UAuthClient.Authorization.CreateRoleAsync(req); + + if (res.IsSuccess) + { + Snackbar.Add("Role created", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(res.Problem?.Title ?? "Create failed", Severity.Error); + } + } + + private async Task DeleteRole(RoleId roleId) + { + var confirm = await DialogService.ShowMessageBoxAsync( + "Delete role", + "Are you sure?", + yesText: "Delete", + cancelText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass="uauth-blur-slight" }); + + if (confirm != true) + return; + + var req = new DeleteRoleRequest(); + var result = await UAuthClient.Authorization.DeleteRoleAsync(roleId, req); + + if (result.IsSuccess) + { + Snackbar.Add($"Role deleted, assignments removed from {result.Value?.RemovedAssignments.ToString() ?? "unknown"} users.", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "Deletion failed.", Severity.Error); + } + } + + private async Task EditPermissions(RoleInfo role) + { + var dialog = await DialogService.ShowAsync( + "Edit Permissions", + new DialogParameters + { + { nameof(PermissionDialog.Role), role } + }, + new DialogOptions + { + CloseButton = true, + MaxWidth = MaxWidth.Large, + FullWidth = true + }); + + var result = await dialog.Result; + await ReloadAsync(); + } + + private async Task ReloadAsync() + { + if (_grid is null) + return; + + await _grid.ReloadServerData(); + } + + private int GetPermissionCount(RoleInfo role) + { + var expanded = PermissionExpander.Expand(role.Permissions, UAuthPermissionCatalog.GetAdminPermissions()); + return expanded.Count; + } + + private void Cancel() => MudDialog.Cancel(); + +} \ No newline at end of file diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor new file mode 100644 index 00000000..414e65e4 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor @@ -0,0 +1,407 @@ +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject IUAuthStateManager StateManager +@inject NavigationManager Nav + + + + Identifier Management + User: @AuthState?.Identity?.DisplayName + + + @if (_chainDetail is not null) + { + + + + Device Details + + + + @if (!_chainDetail.IsRevoked) + { + + Revoke Device + + } + + + + + + + Device Type + @_chainDetail.DeviceType + + + + Platform + @_chainDetail.Platform + + + + Operating System + @_chainDetail.OperatingSystem + + + + Browser + @_chainDetail.Browser + + + + Created + @_chainDetail.CreatedAt.ToLocalTime() + + + + Last Seen + @_chainDetail.LastSeenAt.ToLocalTime() + + + + State + + @_chainDetail.State + + + + + Active Session + @_chainDetail.ActiveSessionId + + + + Rotation Count + @_chainDetail.RotationCount + + + + Touch Count + @_chainDetail.TouchCount + + + + + + Session History + + + + Session Id + Created + Expires + Status + + + + @context.SessionId + @context.CreatedAt.ToLocalTime() + @context.ExpiresAt.ToLocalTime() + + @if (context.IsRevoked) + { + Revoked + } + else + { + Active + } + + + + + } + else + { + + Logout All Devices + Logout Other Devices + Revoke All Devices + Revoke Other Devices + + + + Identifiers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Id + @context.Item.ChainId + + + + Created At + @context.Item.CreatedAt + + + + Touch Count + @context.Item.TouchCount + + + + Rotation Count + @context.Item.RotationCount + + + + + + + + + } + + + Cancel + OK + + + +@code { + private MudDataGrid? _grid; + private bool _loading = false; + private bool _reloadQueued; + private SessionChainDetailDto? _chainDetail; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender) + { + var result = await UAuthClient.Sessions.GetMyChainsAsync(); + if (result != null && result.IsSuccess && result.Value != null) + { + await ReloadAsync(); + StateHasChanged(); + } + } + } + + private async Task> LoadServerData(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new PageRequest + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + var res = await UAuthClient.Sessions.GetMyChainsAsync(); + + if (!res.IsSuccess || res.Value is null) + { + Snackbar.Add(res.Problem?.Title ?? "Failed", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task ReloadAsync() + { + if (_loading) + { + _reloadQueued = true; + return; + } + + if (_grid is null) + return; + + _loading = true; + await InvokeAsync(StateHasChanged); + await Task.Delay(300); + + try + { + await _grid.ReloadServerData(); + } + finally + { + _loading = false; + + if (_reloadQueued) + { + _reloadQueued = false; + await ReloadAsync(); + } + + await InvokeAsync(StateHasChanged); + } + } + + private async Task LogoutAllAsync() + { + var result = await UAuthClient.Flows.LogoutAllDevicesSelfAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Logged out of all devices.", Severity.Success); + Nav.NavigateTo("/login"); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to logout", Severity.Error); + } + } + + private async Task LogoutOthersAsync() + { + + var result = await UAuthClient.Flows.LogoutOtherDevicesSelfAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Logged out of other devices.", Severity.Success); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to logout", Severity.Error); + } + } + + private async Task LogoutDeviceAsync(SessionChainId chainId) + { + LogoutDeviceSelfRequest request = new() { ChainId = chainId }; + var result = await UAuthClient.Flows.LogoutDeviceSelfAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Logged out of device.", Severity.Success); + if (result?.Value?.CurrentChain == true) + { + Nav.NavigateTo("/login"); + return; + } + await ReloadAsync(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to logout", Severity.Error); + } + } + + private async Task RevokeAllAsync() + { + var result = await UAuthClient.Sessions.RevokeAllMyChainsAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Logged out of all devices.", Severity.Success); + Nav.NavigateTo("/login"); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to logout", Severity.Error); + } + } + + private async Task RevokeOthersAsync() + { + var result = await UAuthClient.Sessions.RevokeMyOtherChainsAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Revoked all other devices.", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to logout", Severity.Error); + } + } + + private async Task RevokeChainAsync(SessionChainId chainId) + { + var result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId); + if (result.IsSuccess) + { + Snackbar.Add("Device revoked successfully.", Severity.Success); + + if (result?.Value?.CurrentChain == true) + { + Nav.NavigateTo("/login"); + return; + } + await ReloadAsync(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to logout", Severity.Error); + } + } + + private async Task ShowChainDetailsAsync(SessionChainId chainId) + { + var result = await UAuthClient.Sessions.GetMyChainDetailAsync(chainId); + if (result.IsSuccess) + { + _chainDetail = result.Value; + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to fetch chain details.", Severity.Error); + _chainDetail = null; + } + } + + private void ClearDetail() + { + _chainDetail = null; + } + + private void Submit() => MudDialog.Close(DialogResult.Ok(true)); + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor index 336f6537..7239bf28 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor @@ -4,11 +4,11 @@ @inject NavigationManager Nav - + - UltimateAuth - - Blazor Server Sample + UltimateAuth + + Blazor Server Sample @@ -34,6 +34,7 @@ + @if (state.Identity?.SessionState is not null && state.Identity.SessionState != SessionState.Active) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs index 8ee2d479..47d68df7 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs @@ -1,5 +1,8 @@ using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; using Microsoft.AspNetCore.Components; using MudBlazor; @@ -56,6 +59,57 @@ private void HandleSignInClick() GoToLoginWithReturn(); } + private async Task Validate() + { + try + { + var result = await UAuthClient.Flows.ValidateAsync(); + + if (result.IsValid) + { + if (result.Snapshot?.Identity.UserStatus == UserStatus.SelfSuspended) + { + Snackbar.Add("Your account is suspended by you.", Severity.Warning); + return; + } + Snackbar.Add($"Session active • Tenant: {result.Snapshot?.Identity?.Tenant.Value} • User: {result.Snapshot?.Identity?.PrimaryUserName}", Severity.Success); + } + else + { + switch (result.State) + { + case SessionState.Expired: + Snackbar.Add("Session expired. Please sign in again.", Severity.Warning); + break; + + case SessionState.DeviceMismatch: + Snackbar.Add("Session invalid for this device.", Severity.Error); + break; + + default: + Snackbar.Add($"Session state: {result.State}", Severity.Error); + break; + } + } + } + catch (UAuthTransportException) + { + Snackbar.Add("Network error.", Severity.Error); + } + catch (UAuthProtocolException) + { + Snackbar.Add("Invalid response.", Severity.Error); + } + catch (UAuthException ex) + { + Snackbar.Add($"UAuth error: {ex.Message}", Severity.Error); + } + catch (Exception ex) + { + Snackbar.Add($"Unexpected error: {ex.Message}", Severity.Error); + } + } + private void GoToLoginWithReturn() { var uri = Nav.ToAbsoluteUri(Nav.Uri); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor new file mode 100644 index 00000000..b059ee89 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor @@ -0,0 +1,2 @@ +@page "/authorized-test" +@attribute [Authorize] \ No newline at end of file diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index f1008c9d..6cd64562 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -8,20 +8,137 @@ @inject ISnackbar Snackbar @inject IDialogService DialogService @using System.Security.Claims +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Custom + +@if (AuthState?.Identity?.UserStatus == UserStatus.SelfSuspended) +{ + + + + Your account is suspended. Please active it before continue. + + + + Set Active + Logout + + + + return; +} - - - - + + + + + + + + + Session + + + + + + + Validate + + + + + + Manual Refresh + + + + + + Logout + + + + + + Account + + + + + Manage Sessions + + + + Manage Profile + + + + Manage Identifiers + + + + Manage Credentials + + + + Suspend | Delete Account + + + + Admin + + + + + + + + + @if (_showAdminPreview) + { + + Admin operations are shown for preview. Sign in as an Admin to execute them. + + } + + @if (AuthState?.IsInRole("Admin") == true || _showAdminPreview) + { + + + + + + Role Management + + + + + + Assign Role + + + + } + + + + + + + - @(AuthState?.Identity?.DisplayName?.Substring(0, 2)) + + @((AuthState?.Identity?.DisplayName ?? "?").Substring(0, Math.Min(2, (AuthState?.Identity?.DisplayName ?? "?").Length))) + @AuthState?.Identity?.DisplayName - @foreach (var role in AuthState.Claims.Roles) + @foreach (var role in AuthState?.Claims?.Roles ?? Enumerable.Empty()) { @role @@ -66,7 +183,7 @@ Authenticated - @(AuthState.IsAuthenticated ? "Yes" : "No") + @(AuthState?.IsAuthenticated == true ? "Yes" : "No") @@ -134,6 +251,7 @@ Last Validated At + @* TODO: Validation call should update last validated at *@ @FormatLocalTime(AuthState?.LastValidatedAt) @@ -186,202 +304,121 @@ - - - - - Session + + + + + @GetHealthText() + - - - - Validate - - - - - - Manual Refresh - - - - - - Logout - - - + Lifecycle - + - Account - - Manage Identifiers - - - - Change Password - - - - - - Admin - - - - - - - - @if (AuthState.IsInRole("Admin") || _showAdminPreview) + + + Started + @Diagnostics.StartCount + + @if (Diagnostics.StartedAt is not null) { - - - - Add User - - - - - Assign Role - - - + + + + @FormatRelative(Diagnostics.StartedAt) + + } - - @if (_showAdminPreview) + + + + + Stopped + @Diagnostics.StopCount + + + + + + Terminated + @Diagnostics.TerminatedCount + + @if (Diagnostics.TerminatedAt is not null) { - - Admin operations are shown for preview. Sign in as an Admin to execute them. - + + + + + @FormatRelative(Diagnostics.TerminatedAt) + + + } + - - - - - - @GetHealthText() - + - Lifecycle + + Refresh Metrics + - + - - - Started - @Diagnostics.StartCount + + + Total Attempts + @Diagnostics.RefreshAttemptCount - @if (Diagnostics.StartedAt is not null) - { - - - - - @FormatRelative(Diagnostics.StartedAt) - - - - } - - - Stopped - - @Diagnostics.StopCount - + + + + Success + + @Diagnostics.RefreshSuccessCount - - - Terminated - @Diagnostics.TerminatedCount + + + Automatic + @Diagnostics.AutomaticRefreshCount - @if (Diagnostics.TerminatedAt is not null) - { - - - - - @FormatRelative(Diagnostics.TerminatedAt) - - - - } - - - - Refresh Metrics - - - - - - - Total Attempts - @Diagnostics.RefreshAttemptCount - - - - - - - Success - - @Diagnostics.RefreshSuccessCount - - - - - - Automatic - @Diagnostics.AutomaticRefreshCount - - - - - - Manual - @Diagnostics.ManualRefreshCount - - + + + Manual + @Diagnostics.ManualRefreshCount + + - - - Touched - @Diagnostics.RefreshTouchedCount - - + + + Touched + @Diagnostics.RefreshTouchedCount + + - - - No-Op - @Diagnostics.RefreshNoOpCount - - + + + No-Op + @Diagnostics.RefreshNoOpCount + + - - - Reauth Required - @Diagnostics.RefreshReauthRequiredCount - - - + + + Reauth Required + @Diagnostics.RefreshReauthRequiredCount + + - - + + + - \ No newline at end of file + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs index 38a6f1cc..85e19dcf 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs @@ -1,8 +1,10 @@ using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; +using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.AspNetCore.Components.Authorization; using MudBlazor; using System.Security.Claims; @@ -60,6 +62,11 @@ private async Task Validate() if (result.IsValid) { + if (result.Snapshot?.Identity.UserStatus == UserStatus.SelfSuspended) + { + Snackbar.Add("Your account is suspended by you.", Severity.Warning); + return; + } Snackbar.Add($"Session active • Tenant: {result.Snapshot?.Identity?.Tenant.Value} • User: {result.Snapshot?.Identity?.PrimaryUserName}", Severity.Success); } else @@ -98,10 +105,6 @@ private async Task Validate() } } - private Task CreateUser() => Task.CompletedTask; - private Task AssignRole() => Task.CompletedTask; - private Task ChangePassword() => Task.CompletedTask; - private Color GetHealthColor() { if (Diagnostics.RefreshReauthRequiredCount > 0) @@ -151,12 +154,36 @@ private string GetHealthText() return utc?.ToLocalTime().ToString("dd MMM yyyy • HH:mm:ss"); } - private async Task OpenIdentifierDialog() + private async Task OpenProfileDialog() { + await DialogService.ShowAsync("Manage Profile", GetDialogParameters(), GetDialogOptions()); + } + private async Task OpenIdentifierDialog() + { await DialogService.ShowAsync("Manage Identifiers", GetDialogParameters(), GetDialogOptions()); } + private async Task OpenSessionDialog() + { + await DialogService.ShowAsync("Manage Sessions", GetDialogParameters(), GetDialogOptions()); + } + + private async Task OpenCredentialDialog() + { + await DialogService.ShowAsync("Session Diagnostics", GetDialogParameters(), GetDialogOptions()); + } + + private async Task OpenAccountStatusDialog() + { + await DialogService.ShowAsync("Manage Account", GetDialogParameters(), GetDialogOptions()); + } + + private async Task OpenRoleDialog() + { + await DialogService.ShowAsync("Role Management", GetDialogParameters(), GetDialogOptions()); + } + private DialogOptions GetDialogOptions() { return new DialogOptions @@ -168,13 +195,28 @@ private DialogOptions GetDialogOptions() } private DialogParameters GetDialogParameters() - { + { return new DialogParameters { ["AuthState"] = AuthState }; } + private async Task SetAccountActiveAsync() + { + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfUserStatus.Active }; + var result = await UAuthClient.Users.ChangeStatusSelfAsync(request); + + if (result.IsSuccess) + { + Snackbar.Add("Account activated successfully.", Severity.Success); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Activation failed.", Severity.Error); + } + } + public override void Dispose() { base.Dispose(); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor index 1db4091f..7687854b 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor @@ -7,9 +7,10 @@ @inject ISnackbar Snackbar @inject IUAuthClientProductInfoProvider ClientProductInfoProvider @inject IDeviceIdProvider DeviceIdProvider +@inject IDialogService DialogService - + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor new file mode 100644 index 00000000..e32cc79c --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor @@ -0,0 +1,54 @@ +@page "/register" +@inherits UAuthFlowPageBase + +@implements IDisposable +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IUAuthClientProductInfoProvider ClientProductInfoProvider +@inject IDeviceIdProvider DeviceIdProvider +@inject IDialogService DialogService + + + + + + + + + + + + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor.cs new file mode 100644 index 00000000..82645070 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor.cs @@ -0,0 +1,45 @@ +using CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Users.Contracts; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; + +public partial class Register +{ + private string? _username; + private string? _password; + private string? _passwordCheck; + private string? _email; + private UAuthClientProductInfo? _productInfo; + private MudForm _form = null!; + + protected override async Task OnInitializedAsync() + { + _productInfo = ClientProductInfoProvider.Get(); + } + + private async Task HandleRegisterAsync() + { + await _form.Validate(); + + if (!_form.IsValid) + return; + + var request = new CreateUserRequest + { + UserName = _username, + Password = _password, + Email = _email, + }; + + var result = await UAuthClient.Users.CreateAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("User created succesfully.", Severity.Success); + } + else + { + Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "Failed to create user.", Severity.Error); + } + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor new file mode 100644 index 00000000..753878b8 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor @@ -0,0 +1,18 @@ +@page "/reset" +@inherits UAuthFlowPageBase + +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar + + + + + + + + + Change Password + + + + \ No newline at end of file diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs new file mode 100644 index 00000000..fd66181e --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs @@ -0,0 +1,49 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; + +public partial class ResetCredential +{ + private MudForm _form = null!; + private string? _code; + private string? _newPassword; + private string? _newPasswordCheck; + + private async Task ResetPasswordAsync() + { + await _form.Validate(); + if (!_form.IsValid) + { + Snackbar.Add("Please fix the validation errors.", Severity.Error); + return; + } + + if (_newPassword != _newPasswordCheck) + { + Snackbar.Add("Passwords do not match.", Severity.Error); + return; + } + + var request = new CompleteCredentialResetRequest + { + ResetToken = _code, + NewSecret = _newPassword ?? string.Empty, + Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful + }; + + var result = await UAuthClient.Credentials.CompleteResetMyAsync(request); + + if (result.IsSuccess) + { + Snackbar.Add("Credential reset successfully. Please log in with your new password.", Severity.Success); + Nav.NavigateTo("/login"); + } + else + { + Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "Failed to reset credential. Please try again.", Severity.Error); + } + } + + private string PasswordMatch(string arg) => _newPassword != arg ? "Passwords don't match" : string.Empty; +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index 19af773b..dee4c413 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -1,22 +1,24 @@ +using CodeBeam.UltimateAuth.Authentication.InMemory; using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; +using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Extensions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components; +using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; using CodeBeam.UltimateAuth.Security.Argon2; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; using CodeBeam.UltimateAuth.Users.InMemory.Extensions; using CodeBeam.UltimateAuth.Users.Reference.Extensions; -using CodeBeam.UltimateAuth.Client; +using Microsoft.AspNetCore.HttpOverrides; using MudBlazor.Services; using MudExtensions.Services; using Scalar.AspNetCore; -using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; var builder = WebApplication.CreateBuilder(args); @@ -45,7 +47,7 @@ //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); o.Login.MaxFailedAttempts = 2; o.Login.LockoutDuration = TimeSpan.FromSeconds(10); - o.UserIdentifiers.AllowMultipleUsernames = true; + o.Identifiers.AllowMultipleUsernames = true; }) .AddUltimateAuthUsersInMemory() .AddUltimateAuthUsersReference() @@ -55,16 +57,25 @@ .AddUltimateAuthAuthorizationReference() .AddUltimateAuthInMemorySessions() .AddUltimateAuthInMemoryTokens() + .AddUltimateAuthInMemoryAuthenticationSecurity() .AddUltimateAuthArgon2(); builder.Services.AddUltimateAuthClient(o => { //o.AutoRefresh.Interval = TimeSpan.FromSeconds(5); o.Reauth.Behavior = ReauthBehavior.RaiseEvent; + //o.UAuthStateRefreshMode = UAuthStateRefreshMode.Validate; }); builder.Services.AddScoped(); +builder.Services.Configure(options => +{ + options.ForwardedHeaders = + ForwardedHeaders.XForwardedFor | + ForwardedHeaders.XForwardedProto; +}); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) @@ -82,6 +93,8 @@ await seedRunner.RunAsync(null); } +app.UseForwardedHeaders(); + app.UseHttpsRedirection(); app.UseStaticFiles(); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css index 202b506b..2b9a4745 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css @@ -50,12 +50,6 @@ h1:focus { border-color: #929292; } -.uauth-page { - height: calc(100vh - var(--mud-appbar-height)); - width: 100vw; - margin-top: 64px; -} - .uauth-stack { min-height: 60vh; max-height: calc(100vh - var(--mud-appbar-height)); @@ -68,7 +62,7 @@ h1:focus { } .uauth-login-paper { - min-height: 60vh; + min-height: 70vh; } .uauth-login-paper.mud-theme-primary { @@ -81,13 +75,69 @@ h1:focus { } .uauth-logo-slide { - transition: transform 1s cubic-bezier(.4, 0, .2, 1); -} - -.uauth-login-paper:hover .uauth-logo-slide { - transform: translateY(200px) rotateY(360deg); + animation: uauth-logo-float 30s ease-in-out infinite; } .uauth-text-transform-none .mud-button { text-transform: none; } + +.uauth-dialog { + height: 68vh; + max-height: 68vh; + overflow: auto; +} + +.text-secondary { + color: var(--mud-palette-text-secondary); +} + +.uauth-blur { + backdrop-filter: blur(10px); +} + +.uauth-blur-slight { + backdrop-filter: blur(4px); +} + +@keyframes uauth-logo-float { + 0% { + transform: translateY(0) rotateY(0); + } + + 10% { + transform: translateY(0) rotateY(0); + } + + 15% { + transform: translateY(200px) rotateY(360deg); + } + + 35% { + transform: translateY(200px) rotateY(360deg); + } + + 40% { + transform: translateY(200px) rotateY(720deg); + } + + 60% { + transform: translateY(200px) rotateY(720deg); + } + + 65% { + transform: translateY(0) rotateY(360deg); + } + + 85% { + transform: translateY(0) rotateY(360deg); + } + + 90% { + transform: translateY(0) rotateY(0); + } + + 100% { + transform: translateY(0) rotateY(0); + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs index 55315ca7..6ecdf083 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs @@ -41,12 +41,11 @@ private void OnDiagnosticsChanged() private async Task ProgrammaticLogin() { - var device = await DeviceIdProvider.GetOrCreateAsync(); + var deviceId = await DeviceIdProvider.GetOrCreateAsync(); var request = new LoginRequest { Identifier = "admin", Secret = "admin", - Device = DeviceContext.FromDeviceId(device), }; await UAuthClient.Flows.LoginAsync(request); } diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/IUAuthStateManager.cs similarity index 86% rename from src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs rename to src/CodeBeam.UltimateAuth.Client/AuthState/IUAuthStateManager.cs index f30e0447..c5c28a5c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/IUAuthStateManager.cs @@ -32,4 +32,9 @@ public interface IUAuthStateManager /// Forces state to be cleared and re-validation required. /// void MarkStale(); + + /// + /// Removes all authentication state information managed by the implementation. + /// + void Clear(); } diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticationStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthAuthenticationStateProvider.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticationStateProvider.cs rename to src/CodeBeam.UltimateAuth.Client/AuthState/UAuthAuthenticationStateProvider.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthCascadingStateProvider.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs rename to src/CodeBeam.UltimateAuth.Client/AuthState/UAuthCascadingStateProvider.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs similarity index 77% rename from src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs rename to src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs index 7b106dd0..0dd58c6e 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Users.Contracts; using System.Security.Claims; namespace CodeBeam.UltimateAuth.Client; @@ -27,8 +28,10 @@ private UAuthState() { } public event Action? Changed; + internal Action? RequestRender; public bool IsAuthenticated => Identity is not null; + public bool NeedsValidation => IsAuthenticated && IsStale; public static UAuthState Anonymous() => new(); @@ -43,6 +46,32 @@ internal void ApplySnapshot(AuthStateSnapshot snapshot, DateTimeOffset validated Changed?.Invoke(UAuthStateChangeReason.Authenticated); } + internal void UpdateProfile(UpdateProfileRequest req) + { + if (Identity is null) + return; + + Identity = Identity with + { + DisplayName = req.DisplayName ?? Identity.DisplayName + }; + + Changed?.Invoke(UAuthStateChangeReason.Patched); + } + + internal void UpdateUserStatus(ChangeUserStatusSelfRequest req) + { + if (Identity is null) + return; + + Identity = Identity with + { + UserStatus = UserStatusMapper.ToUserStatus(req.NewStatus) + }; + + Changed?.Invoke(UAuthStateChangeReason.Patched); + } + internal void MarkValidated(DateTimeOffset now) { if (!IsAuthenticated) @@ -63,6 +92,16 @@ internal void MarkStale() Changed?.Invoke(UAuthStateChangeReason.MarkedStale); } + public void Touch(bool updateState = true) + { + if (updateState) + { + IsStale = true; + } + + RequestRender?.Invoke(); + } + internal void Clear() { Identity = null; diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs similarity index 77% rename from src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs rename to src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs index 587115bf..881df3f2 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs @@ -5,5 +5,7 @@ public enum UAuthStateChangeReason Authenticated, Validated, MarkedStale, - Cleared + Cleared, + Touched, + Patched } diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs new file mode 100644 index 00000000..6367d6a1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs @@ -0,0 +1,15 @@ +namespace CodeBeam.UltimateAuth.Client; + +public enum UAuthStateEvent +{ + ValidationCalled, + IdentifiersChanged, + UserStatusChanged, + ProfileChanged, + CredentialsChanged, + CredentialsChangedSelf, + AuthorizationChanged, + SessionRevoked, + UserDeleted, + LogoutVariant +} diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs new file mode 100644 index 00000000..7ec39119 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs @@ -0,0 +1,16 @@ +namespace CodeBeam.UltimateAuth.Client; + +public abstract record UAuthStateEventArgs( + UAuthStateEvent Type, + UAuthStateEventHandlingMode RefreshMode); + +public sealed record UAuthStateEventArgs( + UAuthStateEvent Type, + UAuthStateEventHandlingMode RefreshMode, + TPayload Payload) + : UAuthStateEventArgs(Type, RefreshMode); + +public sealed record UAuthStateEventArgsEmpty( + UAuthStateEvent Type, + UAuthStateEventHandlingMode RefreshMode) + : UAuthStateEventArgs(Type, RefreshMode); diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs new file mode 100644 index 00000000..173f6526 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Client; + +public enum UAuthStateEventHandlingMode +{ + Patch, + Validate, + None +} diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs new file mode 100644 index 00000000..26399d5d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs @@ -0,0 +1,145 @@ +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Authentication; + +internal sealed class UAuthStateManager : IUAuthStateManager, IDisposable +{ + private Task? _ensureTask; + private readonly object _ensureLock = new(); + + private readonly IUAuthClient _client; + private readonly IUAuthClientEvents _events; + private readonly IClock _clock; + + public UAuthState State { get; } = UAuthState.Anonymous(); + + public UAuthStateManager(IUAuthClient client, IUAuthClientEvents events, IClock clock) + { + _client = client; + _events = events; + _clock = clock; + + _events.StateChanged += HandleStateEvent; + } + + public Task EnsureAsync(bool force = false, CancellationToken ct = default) + { + if (!force && State.IsAuthenticated && !State.IsStale) + return Task.CompletedTask; + + lock (_ensureLock) + { + if (_ensureTask != null) + return _ensureTask; + + _ensureTask = EnsureInternalAsync(ct); + return _ensureTask; + } + } + + private async Task EnsureInternalAsync(CancellationToken ct) + { + try + { + var result = await _client.Flows.ValidateAsync(); + + if (!result.IsValid || result.Snapshot == null) + { + if (State.IsAuthenticated) + State.MarkStale(); + else + State.Clear(); + + return; + } + + State.ApplySnapshot(result.Snapshot, _clock.UtcNow); + } + finally + { + lock (_ensureLock) + { + _ensureTask = null; + } + } + } + + private async Task HandleStateEvent(UAuthStateEventArgs args) + { + if (args.RefreshMode == UAuthStateEventHandlingMode.None) + { + return; + } + + switch (args.Type) + { + case UAuthStateEvent.SessionRevoked: + case UAuthStateEvent.CredentialsChanged: + case UAuthStateEvent.UserDeleted: + case UAuthStateEvent.LogoutVariant: + State.Clear(); + return; + } + + switch (args) + { + case UAuthStateEventArgs profile: + State.UpdateProfile(profile.Payload); + return; + + case UAuthStateEventArgs profile: + State.UpdateUserStatus(profile.Payload); + return; + } + + switch (args.RefreshMode) + { + case UAuthStateEventHandlingMode.Validate: + await EnsureAsync(true); + return; + + case UAuthStateEventHandlingMode.Patch: + if (args.Type == UAuthStateEvent.ValidationCalled) + { + State.MarkValidated(_clock.UtcNow); + return; + } + break; + default: + break; + } + + State.Touch(true); + } + + public Task OnLoginAsync() + { + State.MarkStale(); + return Task.CompletedTask; + } + + public Task OnLogoutAsync() + { + State.Clear(); + return Task.CompletedTask; + } + + public void MarkStale() + { + State.MarkStale(); + } + + public void Clear() + { + State.Clear(); + } + + public void Dispose() + { + _events.StateChanged -= HandleStateEvent; + } + + public bool NeedsValidation => !State.IsAuthenticated || State.IsStale; +} diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs deleted file mode 100644 index 0751651d..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs +++ /dev/null @@ -1,58 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; - -namespace CodeBeam.UltimateAuth.Client.Authentication; - -internal sealed class UAuthStateManager : IUAuthStateManager -{ - private readonly IUAuthClient _client; - private readonly IClock _clock; - - public UAuthState State { get; } = UAuthState.Anonymous(); - - public UAuthStateManager(IUAuthClient client, IClock clock) - { - _client = client; - _clock = clock; - } - - public async Task EnsureAsync(bool force = false, CancellationToken ct = default) - { - if (!force && State.IsAuthenticated && !State.IsStale) - return; - - var result = await _client.Flows.ValidateAsync(); - - if (!result.IsValid || result.Snapshot == null) - { - if (State.IsAuthenticated) - { - State.MarkStale(); - return; - } - - State.Clear(); - return; - } - - State.ApplySnapshot(result.Snapshot, _clock.UtcNow); - } - - public Task OnLoginAsync() - { - State.MarkStale(); - return Task.CompletedTask; - } - - public Task OnLogoutAsync() - { - State.Clear(); - return Task.CompletedTask; - } - - public void MarkStale() - { - State.MarkStale(); - } - - public bool NeedsValidation => !State.IsAuthenticated || State.IsStale; -} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs index 2c8c0786..cbddf34c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthApp.razor.cs @@ -23,39 +23,37 @@ protected override async Task OnInitializedAsync() protected override async Task OnAfterRenderAsync(bool firstRender) { - if (!firstRender) - return; + if (firstRender) + { + if (_initialized) + return; - if (_initialized) - return; + _initialized = true; - _initialized = true; + StateManager.State.RequestRender = () => InvokeAsync(StateHasChanged); - await Bootstrapper.EnsureStartedAsync(); - await StateManager.EnsureAsync(); + await Bootstrapper.EnsureStartedAsync(); + await StateManager.EnsureAsync(); - if (StateManager.State.IsAuthenticated) - { - await Coordinator.StartAsync(); - _coordinatorStarted = true; - } + if (StateManager.State.IsAuthenticated) + { + await Coordinator.StartAsync(); + _coordinatorStarted = true; + } - StateManager.State.Changed += OnStateChanged; + StateManager.State.Changed += OnStateChanged; - StateHasChanged(); - } + StateHasChanged(); + } - private void OnStateChanged(UAuthStateChangeReason reason) - { - if (reason == UAuthStateChangeReason.MarkedStale) + if (StateManager.State.NeedsValidation) { - // Causes infinite loop - //_ = InvokeAsync(async () => - //{ - // await StateManager.EnsureAsync(); - //}); + await StateManager.EnsureAsync(true); } + } + private void OnStateChanged(UAuthStateChangeReason reason) + { if (reason == UAuthStateChangeReason.Authenticated) { _ = InvokeAsync(async () => diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs index 34a3ffc6..f4723276 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs @@ -13,6 +13,7 @@ public abstract class UAuthFlowPageBase : UAuthReactiveComponentBase protected AuthFlowPayload? UAuthPayload { get; private set; } protected string? ReturnUrl { get; private set; } protected bool ShouldFocus { get; private set; } + protected string? Identifier { get; private set; } protected virtual bool ClearQueryAfterParse => true; @@ -39,6 +40,7 @@ protected override void OnParametersSet() ShouldFocus = query.TryGetValue("focus", out var focus) && focus == "1"; ReturnUrl = query.TryGetValue("returnUrl", out var ru) ? ru.ToString() : null; + Identifier = query.TryGetValue("identifier", out var id) ? id.ToString() : null; UAuthPayload = null; diff --git a/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs b/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs index 2c529c99..e2173571 100644 --- a/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs +++ b/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs @@ -15,10 +15,17 @@ public BrowserDeviceIdStorage(IBrowserStorage storage) public async ValueTask LoadAsync(CancellationToken ct = default) { - if (!await _storage.ExistsAsync(StorageScope.Local, Key)) - return null; + try + { + if (!await _storage.ExistsAsync(StorageScope.Local, Key)) + return null; - return await _storage.GetAsync(StorageScope.Local, Key); + return await _storage.GetAsync(StorageScope.Local, Key); + } + catch (TaskCanceledException) + { + return null; + } } public ValueTask SaveAsync(string deviceId, CancellationToken ct = default) diff --git a/src/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs b/src/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs new file mode 100644 index 00000000..dfe52ad2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Client.Events; + +public interface IUAuthClientEvents +{ + event Func? StateChanged; + Task PublishAsync(UAuthStateEventArgs args); + Task PublishAsync(UAuthStateEventArgs args); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs b/src/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs new file mode 100644 index 00000000..395126c3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs @@ -0,0 +1,21 @@ +namespace CodeBeam.UltimateAuth.Client.Events; + +internal sealed class UAuthClientEvents : IUAuthClientEvents +{ + public event Func? StateChanged; + + public Task PublishAsync(UAuthStateEventArgs args) + => PublishAsync((UAuthStateEventArgs)args); + + public async Task PublishAsync(UAuthStateEventArgs args) + { + var handlers = StateChanged; + if (handlers == null) + return; + + foreach (var handler in handlers.GetInvocationList()) + { + await ((Func)handler)(args); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs index 5c4e1525..9223b29b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Client.Device; using CodeBeam.UltimateAuth.Client.Devices; using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Events; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Client.Runtime; @@ -66,6 +67,7 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol services.AddSingleton(); services.AddSingleton, UAuthClientOptionsPostConfigure>(); services.TryAddSingleton(); + services.AddSingleton(); services.PostConfigure(o => { @@ -75,6 +77,7 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs index 171bbabd..7e1d5999 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs @@ -26,7 +26,7 @@ public static string Resolve() return _cached = "/login"; if (candidates.Count > 1) - throw new InvalidOperationException("Multiple [UAuthLoginPage] found. Define Navigation.LoginResolver explicitly."); + throw new InvalidOperationException("Multiple [UAuthLoginPage] found. Make sure you only have one login page that attribute defined or define Navigation.LoginResolver explicitly."); var routeAttr = candidates[0].GetCustomAttributes(typeof(RouteAttribute), true).FirstOrDefault() as RouteAttribute; diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs index 96a79bec..a6de47b9 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionCoordinator.cs @@ -2,30 +2,33 @@ using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Client.Diagnostics; using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Client.Infrastructure; +// TODO: Add multi tab single refresh support internal sealed class SessionCoordinator : ISessionCoordinator { private readonly IUAuthClient _client; private readonly NavigationManager _navigation; private readonly UAuthClientOptions _options; private readonly UAuthClientDiagnostics _diagnostics; + private readonly IClock _clock; - private PeriodicTimer? _timer; private CancellationTokenSource? _cts; public event Action? ReauthRequired; - public SessionCoordinator(IUAuthClient client, NavigationManager navigation, IOptions options, UAuthClientDiagnostics diagnostics) + public SessionCoordinator(IUAuthClient client, NavigationManager navigation, IOptions options, UAuthClientDiagnostics diagnostics, IClock clock) { _client = client; _navigation = navigation; _options = options.Value; _diagnostics = diagnostics; + _clock = clock; } public async Task StartAsync(CancellationToken cancellationToken = default) @@ -33,59 +36,32 @@ public async Task StartAsync(CancellationToken cancellationToken = default) if (!_options.AutoRefresh.Enabled) return; - if (_timer is not null) + if (_cts is not null) return; _diagnostics.MarkStarted(); _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var interval = _options.AutoRefresh.Interval ?? TimeSpan.FromMinutes(5); - _timer = new PeriodicTimer(interval); _ = RunAsync(_cts.Token); } private async Task RunAsync(CancellationToken ct) { + var interval = _options.AutoRefresh.Interval ?? TimeSpan.FromMinutes(5); + try { - while (await _timer!.WaitForNextTickAsync(ct)) + while (!ct.IsCancellationRequested) { - _diagnostics.MarkAutomaticRefresh(); - var result = await _client.Flows.RefreshAsync(isAuto: true); - - switch (result.Outcome) - { - case RefreshOutcome.Touched: - break; - - case RefreshOutcome.NoOp: - break; - - case RefreshOutcome.Success: - break; - - case RefreshOutcome.ReauthRequired: - switch (_options.Reauth.Behavior) - { - case ReauthBehavior.Redirect: - _navigation.NavigateTo(_options.Reauth.RedirectPath ?? _options.Endpoints.Login, forceLoad: true); - break; - - case ReauthBehavior.RaiseEvent: - ReauthRequired?.Invoke(); - break; - - case ReauthBehavior.None: - break; - } - _diagnostics.MarkTerminated(CoordinatorTerminationReason.ReauthRequired); - return; - } + await Task.Delay(interval, ct); + await TickAsync(); + + if (_diagnostics.IsTerminated) + return; } } catch (OperationCanceledException) { - // expected } } @@ -93,8 +69,6 @@ public Task StopAsync() { _diagnostics.MarkStopped(); _cts?.Cancel(); - _timer?.Dispose(); - _timer = null; return Task.CompletedTask; } @@ -102,4 +76,29 @@ public async ValueTask DisposeAsync() { await StopAsync(); } + + internal async Task TickAsync() + { + _diagnostics.MarkAutomaticRefresh(); + + var result = await _client.Flows.RefreshAsync(true); + + if (result.Outcome != RefreshOutcome.ReauthRequired) + return; + + if (result.Outcome == RefreshOutcome.ReauthRequired) + { + switch (_options.Reauth.Behavior) + { + case ReauthBehavior.Redirect: _navigation.NavigateTo(_options.Reauth.RedirectPath ?? _options.Endpoints.Login, forceLoad: true); + break; + + case ReauthBehavior.RaiseEvent: + ReauthRequired?.Invoke(); + break; + } + + _diagnostics.MarkTerminated(CoordinatorTerminationReason.ReauthRequired); + } + } } diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs index dab4589d..7931121d 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs @@ -13,6 +13,7 @@ public sealed class UAuthClientOptions /// public string? DefaultReturnUrl { get; set; } + public UAuthStateEventOptions StateEvents { get; set; } = new(); public UAuthClientEndpointOptions Endpoints { get; set; } = new(); public UAuthClientLoginFlowOptions Login { get; set; } = new(); diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs new file mode 100644 index 00000000..51bf9632 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Client.Options; + +public class UAuthStateEventOptions +{ + public UAuthStateEventHandlingMode HandlingMode { get; set; } = UAuthStateEventHandlingMode.Patch; +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs new file mode 100644 index 00000000..ba16baee --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Services; + +public interface IAuthorizationClient +{ + Task> CheckAsync(AuthorizationCheckRequest request); + Task> GetMyRolesAsync(); + Task> GetUserRolesAsync(UserKey userKey); + Task AssignRoleToUserAsync(UserKey userKey, string role); + Task RemoveRoleFromUserAsync(UserKey userKey, string role); + + Task> CreateRoleAsync(CreateRoleRequest request); + Task>> QueryRolesAsync(RoleQuery request); + Task RenameRoleAsync(RoleId roleId, RenameRoleRequest request); + Task SetPermissionsAsync(RoleId roleId, SetPermissionsRequest request); + Task> DeleteRoleAsync(RoleId roleId, DeleteRoleRequest request); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs new file mode 100644 index 00000000..534efb0a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Services; + +public interface ICredentialClient +{ + Task> GetMyAsync(); + Task> AddMyAsync(AddCredentialRequest request); + Task> ChangeMyAsync(ChangeCredentialRequest request); + Task RevokeMyAsync(RevokeCredentialRequest request); + Task> BeginResetMyAsync(BeginCredentialResetRequest request); + Task> CompleteResetMyAsync(CompleteCredentialResetRequest request); + + Task> GetUserAsync(UserKey userKey); + Task> AddUserAsync(UserKey userKey, AddCredentialRequest request); + Task RevokeUserAsync(UserKey userKey, RevokeCredentialRequest request); + Task ActivateUserAsync(UserKey userKey); + Task> BeginResetUserAsync(UserKey userKey, BeginCredentialResetRequest request); + Task> CompleteResetUserAsync(UserKey userKey, CompleteCredentialResetRequest request); + Task DeleteUserAsync(UserKey userKey); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs new file mode 100644 index 00000000..ea759dff --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +// TODO: Add ReauthAsync +namespace CodeBeam.UltimateAuth.Client.Services; + +public interface IFlowClient +{ + Task LoginAsync(LoginRequest request, string? returnUrl = null); + Task LogoutAsync(); + Task RefreshAsync(bool isAuto = false); + //Task ReauthAsync(); + Task ValidateAsync(); + + Task BeginPkceAsync(string? returnUrl = null); + Task CompletePkceLoginAsync(PkceLoginRequest request); + + Task> LogoutDeviceSelfAsync(LogoutDeviceSelfRequest request); + Task LogoutOtherDevicesSelfAsync(); + Task LogoutAllDevicesSelfAsync(); + Task> LogoutDeviceAdminAsync(UserKey userKey, SessionChainId chainId); + Task LogoutOtherDevicesAdminAsync(LogoutOtherDevicesAdminRequest request); + Task LogoutAllDevicesAdminAsync(UserKey userKey); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs new file mode 100644 index 00000000..da156b33 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Services; + +public interface ISessionClient +{ + Task>> GetMyChainsAsync(PageRequest? request = null); + Task> GetMyChainDetailAsync(SessionChainId chainId); + Task> RevokeMyChainAsync(SessionChainId chainId); + Task RevokeMyOtherChainsAsync(); + Task RevokeAllMyChainsAsync(); + + + Task>> GetUserChainsAsync(UserKey userKey); + Task> GetUserChainDetailAsync(UserKey userKey, SessionChainId chainId); + Task RevokeUserSessionAsync(UserKey userKey, AuthSessionId sessionId); + Task RevokeUserChainAsync(UserKey userKey, SessionChainId chainId); + Task RevokeUserRootAsync(UserKey userKey); + Task RevokeAllUserChainsAsync(UserKey userKey); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs similarity index 89% rename from src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs rename to src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs index 272959ef..54dcf6bd 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs @@ -5,6 +5,7 @@ namespace CodeBeam.UltimateAuth.Client; public interface IUAuthClient { IFlowClient Flows { get; } + ISessionClient Sessions { get; } IUserClient Users { get; } IUserIdentifierClient Identifiers { get; } ICredentialClient Credentials { get; } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs similarity index 95% rename from src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs rename to src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs index 88914fff..5c23dfe5 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs @@ -9,6 +9,7 @@ public interface IUserClient Task> CreateAsync(CreateUserRequest request); Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request); Task> ChangeStatusAdminAsync(ChangeUserStatusAdminRequest request); + Task DeleteMeAsync(); Task> DeleteAsync(DeleteUserRequest request); Task> GetMeAsync(); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs similarity index 84% rename from src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs rename to src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs index 7f019423..cc2190e8 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Client.Services; public interface IUserIdentifierClient { - Task>> GetMyIdentifiersAsync(); + Task>> GetMyIdentifiersAsync(PageRequest? request = null); Task AddSelfAsync(AddUserIdentifierRequest request); Task UpdateSelfAsync(UpdateUserIdentifierRequest request); Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request); @@ -14,7 +14,7 @@ public interface IUserIdentifierClient Task VerifySelfAsync(VerifyUserIdentifierRequest request); Task DeleteSelfAsync(DeleteUserIdentifierRequest request); - Task>> GetUserIdentifiersAsync(UserKey userKey); + Task>> GetUserIdentifiersAsync(UserKey userKey, PageRequest? request = null); Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request); Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request); Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs deleted file mode 100644 index 192ef5c0..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs +++ /dev/null @@ -1,18 +0,0 @@ -using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Client.Services; - -public interface IAuthorizationClient -{ - Task> CheckAsync(AuthorizationCheckRequest request); - - Task> GetMyRolesAsync(); - - Task> GetUserRolesAsync(UserKey userKey); - - Task AssignRoleAsync(UserKey userKey, string role); - - Task RemoveRoleAsync(UserKey userKey, string role); -} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs deleted file mode 100644 index eb92db92..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Credentials.Contracts; - -namespace CodeBeam.UltimateAuth.Client.Services; - -public interface ICredentialClient -{ - Task> GetMyAsync(); - Task> AddMyAsync(AddCredentialRequest request); - Task> ChangeMyAsync(CredentialType type, ChangeCredentialRequest request); - Task RevokeMyAsync(CredentialType type, RevokeCredentialRequest request); - Task BeginResetMyAsync(CredentialType type, BeginCredentialResetRequest request); - Task CompleteResetMyAsync(CredentialType type, CompleteCredentialResetRequest request); - - Task> GetUserAsync(UserKey userKey); - Task> AddUserAsync(UserKey userKey, AddCredentialRequest request); - Task RevokeUserAsync(UserKey userKey, CredentialType type, RevokeCredentialRequest request); - Task ActivateUserAsync(UserKey userKey, CredentialType type); - Task BeginResetUserAsync(UserKey userKey, CredentialType type, BeginCredentialResetRequest request); - Task CompleteResetUserAsync(UserKey userKey, CredentialType type, CompleteCredentialResetRequest request); - Task DeleteUserAsync(UserKey userKey, CredentialType type); -} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs deleted file mode 100644 index bec146de..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs +++ /dev/null @@ -1,17 +0,0 @@ -using CodeBeam.UltimateAuth.Client.Contracts; -using CodeBeam.UltimateAuth.Core.Contracts; - -// TODO: Add ReauthAsync -namespace CodeBeam.UltimateAuth.Client.Services; - -public interface IFlowClient -{ - Task LoginAsync(LoginRequest request, string? returnUrl = null); - Task LogoutAsync(); - Task RefreshAsync(bool isAuto = false); - //Task ReauthAsync(); - Task ValidateAsync(); - - Task BeginPkceAsync(string? returnUrl = null); - Task CompletePkceLoginAsync(PkceLoginRequest request); -} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs index 4cfc6689..255e35ad 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Client.Events; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Contracts; @@ -10,11 +12,13 @@ namespace CodeBeam.UltimateAuth.Client.Services; internal sealed class UAuthAuthorizationClient : IAuthorizationClient { private readonly IUAuthRequestClient _request; + private readonly IUAuthClientEvents _events; private readonly UAuthClientOptions _options; - public UAuthAuthorizationClient(IUAuthRequestClient request, IOptions options) + public UAuthAuthorizationClient(IUAuthRequestClient request, IUAuthClientEvents events, IOptions options) { _request = request; + _events = events; _options = options.Value; } @@ -38,9 +42,9 @@ public async Task> GetUserRolesAsync(UserKey user return UAuthResultMapper.FromJson(raw); } - public async Task AssignRoleAsync(UserKey userKey, string role) + public async Task AssignRoleToUserAsync(UserKey userKey, string role) { - var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/post"), new AssignRoleRequest + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/assign"), new AssignRoleRequest { Role = role }); @@ -48,13 +52,64 @@ public async Task AssignRoleAsync(UserKey userKey, string role) return UAuthResultMapper.From(raw); } - public async Task RemoveRoleAsync(UserKey userKey, string role) + public async Task RemoveRoleFromUserAsync(UserKey userKey, string role) { - var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/delete"), new AssignRoleRequest + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/remove"), new AssignRoleRequest { Role = role }); + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.AuthorizationChanged, _options.StateEvents.HandlingMode)); + } + + return result; + } + + public async Task> CreateRoleAsync(CreateRoleRequest request) + { + var raw = await _request.SendJsonAsync(Url("/admin/authorization/roles/create"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task>> QueryRolesAsync(RoleQuery request) + { + var raw = await _request.SendJsonAsync(Url("/admin/authorization/roles/query"), request); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task RenameRoleAsync(RoleId roleId, RenameRoleRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{roleId}/rename"), request); return UAuthResultMapper.From(raw); } + + public async Task SetPermissionsAsync(RoleId roleId, SetPermissionsRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{roleId}/permissions"), request); + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.AuthorizationChanged, _options.StateEvents.HandlingMode)); + } + + return result; + } + + public async Task> DeleteRoleAsync(RoleId roleId, DeleteRoleRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{roleId}/delete"), request); + var result = UAuthResultMapper.FromJson(raw); + + if (result.IsSuccess) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.AuthorizationChanged, _options.StateEvents.HandlingMode)); + } + + return result; + } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs index 40d44f2a..6df7e74d 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs @@ -5,14 +5,16 @@ namespace CodeBeam.UltimateAuth.Client; internal sealed class UAuthClient : IUAuthClient { public IFlowClient Flows { get; } + public ISessionClient Sessions { get; } public IUserClient Users { get; } public IUserIdentifierClient Identifiers { get; } public ICredentialClient Credentials { get; } public IAuthorizationClient Authorization { get; } - public UAuthClient(IFlowClient flows, IUserClient users, IUserIdentifierClient identifiers, ICredentialClient credentials, IAuthorizationClient authorization) + public UAuthClient(IFlowClient flows, ISessionClient session, IUserClient users, IUserIdentifierClient identifiers, ICredentialClient credentials, IAuthorizationClient authorization) { Flows = flows; + Sessions = session; Users = users; Identifiers = identifiers; Credentials = credentials; diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index 1ef35801..63aed3cc 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; @@ -10,11 +11,13 @@ namespace CodeBeam.UltimateAuth.Client.Services; internal sealed class UAuthCredentialClient : ICredentialClient { private readonly IUAuthRequestClient _request; + private readonly IUAuthClientEvents _events; private readonly UAuthClientOptions _options; - public UAuthCredentialClient(IUAuthRequestClient request, IOptions options) + public UAuthCredentialClient(IUAuthRequestClient request, IUAuthClientEvents events, IOptions options) { _request = request; + _events = events; _options = options.Value; } @@ -32,28 +35,40 @@ public async Task> AddMyAsync(AddCredentialRequ return UAuthResultMapper.FromJson(raw); } - public async Task> ChangeMyAsync(CredentialType type, ChangeCredentialRequest request) + public async Task> ChangeMyAsync(ChangeCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/change"), request); + var raw = await _request.SendJsonAsync(Url($"/credentials/change"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChangedSelf, _options.StateEvents.HandlingMode)); + } return UAuthResultMapper.FromJson(raw); } - public async Task RevokeMyAsync(CredentialType type, RevokeCredentialRequest request) + public async Task RevokeMyAsync(RevokeCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/revoke"), request); + var raw = await _request.SendJsonAsync(Url($"/credentials/revoke"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChanged, _options.StateEvents.HandlingMode)); + } return UAuthResultMapper.From(raw); } - public async Task BeginResetMyAsync(CredentialType type, BeginCredentialResetRequest request) + public async Task> BeginResetMyAsync(BeginCredentialResetRequest request) { - var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/reset/begin"), request); - return UAuthResultMapper.From(raw); + var raw = await _request.SendJsonAsync(Url($"/credentials/reset/begin"), request); + return UAuthResultMapper.FromJson(raw); } - public async Task CompleteResetMyAsync(CredentialType type, CompleteCredentialResetRequest request) + public async Task> CompleteResetMyAsync(CompleteCredentialResetRequest request) { - var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/reset/complete"), request); - return UAuthResultMapper.From(raw); + var raw = await _request.SendJsonAsync(Url($"/credentials/reset/complete"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChanged, _options.StateEvents.HandlingMode)); + } + return UAuthResultMapper.FromJson(raw); } @@ -69,33 +84,33 @@ public async Task> AddUserAsync(UserKey userKey return UAuthResultMapper.FromJson(raw); } - public async Task RevokeUserAsync(UserKey userKey, CredentialType type, RevokeCredentialRequest request) + public async Task RevokeUserAsync(UserKey userKey, RevokeCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/revoke"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/revoke"), request); return UAuthResultMapper.From(raw); } - public async Task ActivateUserAsync(UserKey userKey, CredentialType type) + public async Task ActivateUserAsync(UserKey userKey) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/{type}/activate")); + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/activate")); return UAuthResultMapper.From(raw); } - public async Task BeginResetUserAsync(UserKey userKey, CredentialType type, BeginCredentialResetRequest request) + public async Task> BeginResetUserAsync(UserKey userKey, BeginCredentialResetRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/reset/begin"), request); - return UAuthResultMapper.From(raw); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/reset/begin"), request); + return UAuthResultMapper.FromJson(raw); } - public async Task CompleteResetUserAsync(UserKey userKey, CredentialType type, CompleteCredentialResetRequest request) + public async Task> CompleteResetUserAsync(UserKey userKey, CompleteCredentialResetRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/reset/complete"), request); - return UAuthResultMapper.From(raw); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/reset/complete"), request); + return UAuthResultMapper.FromJson(raw); } - public async Task DeleteUserAsync(UserKey userKey, CredentialType type) + public async Task DeleteUserAsync(UserKey userKey) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/{type}/delete")); + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/delete")); return UAuthResultMapper.From(raw); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 3c81d96e..6a1fe4b9 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -1,12 +1,14 @@ using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Client.Diagnostics; using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Client.Events; using CodeBeam.UltimateAuth.Client.Extensions; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; using System.Net; @@ -19,13 +21,15 @@ namespace CodeBeam.UltimateAuth.Client.Services; internal class UAuthFlowClient : IFlowClient { private readonly IUAuthRequestClient _post; + private readonly IUAuthClientEvents _events; private readonly UAuthClientOptions _options; private readonly UAuthClientDiagnostics _diagnostics; private readonly NavigationManager _nav; - public UAuthFlowClient(IUAuthRequestClient post, IOptions options, UAuthClientDiagnostics diagnostics, NavigationManager nav) + public UAuthFlowClient(IUAuthRequestClient post, IUAuthClientEvents events, IOptions options, UAuthClientDiagnostics diagnostics, NavigationManager nav) { _post = post; + _events = events; _options = options.Value; _diagnostics = diagnostics; _nav = nav; @@ -149,7 +153,11 @@ public async Task ValidateAsync() throw new UAuthProtocolException("Malformed validation response."); if (raw.Status == 401 || (raw.Status >= 200 && raw.Status < 300)) + { + // Don't set refresh mode to validate here, it's already validated. + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.ValidationCalled, UAuthStateEventHandlingMode.Patch)); return body; + } if (raw.Status >= 400 && raw.Status < 500) throw new UAuthProtocolException($"Unexpected client error during validation: {raw.Status}"); @@ -227,6 +235,65 @@ public async Task CompletePkceLoginAsync(PkceLoginRequest request) await _post.NavigateAsync(url, payload); } + public async Task> LogoutDeviceSelfAsync(LogoutDeviceSelfRequest request) + { + var raw = await _post.SendJsonAsync(Url($"/logout-device"), request); + + if (raw.Ok) + { + var result = UAuthResultMapper.FromJson(raw); + + if (result.Value?.CurrentChain == true) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.LogoutVariant, _options.StateEvents.HandlingMode)); + } + + return result; + } + + return UAuthResultMapper.FromJson(raw); + } + + public async Task> LogoutDeviceAdminAsync(UserKey userKey, SessionChainId chainId) + { + LogoutDeviceAdminRequest request = new() { UserKey = userKey, ChainId = chainId }; + var raw = await _post.SendJsonAsync(Url($"/admin/users/logout-device/{userKey.Value}"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task LogoutOtherDevicesSelfAsync() + { + var raw = await _post.SendJsonAsync(Url("/logout-others")); + return UAuthResultMapper.From(raw); + } + + public async Task LogoutOtherDevicesAdminAsync(LogoutOtherDevicesAdminRequest request) + { + var raw = await _post.SendJsonAsync(Url($"/admin/users/logout-others/{request.UserKey.Value}"), request); + return UAuthResultMapper.From(raw); + } + + public async Task LogoutAllDevicesSelfAsync() + { + var raw = await _post.SendJsonAsync(Url("/logout-all")); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.LogoutVariant, _options.StateEvents.HandlingMode)); + } + return UAuthResultMapper.From(raw); + } + + public async Task LogoutAllDevicesAdminAsync(UserKey userKey) + { + var raw = await _post.SendJsonAsync(Url($"/admin/users/logout-all/{userKey.Value}")); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.LogoutVariant, _options.StateEvents.HandlingMode)); + } + return UAuthResultMapper.From(raw); + } + + private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string returnUrl) { var hubLoginUrl = Url(_options.Endpoints.HubLoginPath); @@ -242,9 +309,6 @@ private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifi return _post.NavigateAsync(hubLoginUrl, data); } - - // ---------------- PKCE CRYPTO ---------------- - private static string CreateVerifier() { var bytes = RandomNumberGenerator.GetBytes(32); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs new file mode 100644 index 00000000..d181dbc9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs @@ -0,0 +1,108 @@ +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Services; + +internal sealed class UAuthSessionClient : ISessionClient +{ + private readonly IUAuthRequestClient _request; + private readonly UAuthClientOptions _options; + private readonly IUAuthClientEvents _events; + + public UAuthSessionClient(IUAuthRequestClient request, IOptions options, IUAuthClientEvents events) + { + _request = request; + _options = options.Value; + _events = events; + } + + private string Url(string path) + => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); + + + public async Task>> GetMyChainsAsync(PageRequest? request = null) + { + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url("/session/me/chains"), request); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task> GetMyChainDetailAsync(SessionChainId chainId) + { + var raw = await _request.SendFormAsync(Url($"/session/me/chains/{chainId}")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> RevokeMyChainAsync(SessionChainId chainId) + { + var raw = await _request.SendJsonAsync(Url($"/session/me/chains/{chainId}/revoke")); + var result = UAuthResultMapper.FromJson(raw); + + if (result.Value?.CurrentChain == true) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.SessionRevoked, _options.StateEvents.HandlingMode)); + } + + return result; + } + + public async Task RevokeMyOtherChainsAsync() + { + var raw = await _request.SendFormAsync(Url("/session/me/revoke-others")); + return UAuthResultMapper.From(raw); + } + + public async Task RevokeAllMyChainsAsync() + { + var raw = await _request.SendFormAsync(Url("/session/me/revoke-all")); + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.SessionRevoked, _options.StateEvents.HandlingMode)); + } + + return result; + } + + + public async Task>> GetUserChainsAsync(UserKey userKey) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/chains")); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task> GetUserChainDetailAsync(UserKey userKey, SessionChainId chainId) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/chains/{chainId}")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task RevokeUserSessionAsync(UserKey userKey, AuthSessionId sessionId) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/{sessionId}/revoke")); + return UAuthResultMapper.From(raw); + } + + public async Task RevokeUserChainAsync(UserKey userKey, SessionChainId chainId) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/chains/{chainId}/revoke")); + return UAuthResultMapper.From(raw); + } + + public async Task RevokeUserRootAsync(UserKey userKey) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/revoke-root")); + return UAuthResultMapper.From(raw); + } + + public async Task RevokeAllUserChainsAsync(UserKey userKey) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/revoke-all")); + return UAuthResultMapper.From(raw); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs index 3f2c426b..9fd9fb09 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; @@ -10,11 +11,13 @@ namespace CodeBeam.UltimateAuth.Client.Services; internal sealed class UAuthUserClient : IUserClient { private readonly IUAuthRequestClient _request; + private readonly IUAuthClientEvents _events; private readonly UAuthClientOptions _options; - public UAuthUserClient(IUAuthRequestClient request, IOptions options) + public UAuthUserClient(IUAuthRequestClient request, IUAuthClientEvents events, IOptions options) { _request = request; + _events = events; _options = options.Value; } @@ -29,6 +32,20 @@ public async Task> GetMeAsync() public async Task UpdateMeAsync(UpdateProfileRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/update"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.ProfileChanged, _options.StateEvents.HandlingMode, request)); + } + return UAuthResultMapper.From(raw); + } + + public async Task DeleteMeAsync() + { + var raw = await _request.SendJsonAsync(Url("/users/me/delete")); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.UserDeleted, UAuthStateEventHandlingMode.Patch)); + } return UAuthResultMapper.From(raw); } @@ -41,6 +58,10 @@ public async Task> CreateAsync(CreateUserRequest r public async Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/status"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.ProfileChanged, _options.StateEvents.HandlingMode, request)); + } return UAuthResultMapper.FromJson(raw); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs index 7a814c6c..b4ccfa64 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; @@ -10,62 +11,90 @@ namespace CodeBeam.UltimateAuth.Client.Services; internal class UAuthUserIdentifierClient : IUserIdentifierClient { private readonly IUAuthRequestClient _request; + private readonly IUAuthClientEvents _events; private readonly UAuthClientOptions _options; - public UAuthUserIdentifierClient(IUAuthRequestClient request, IOptions options) + public UAuthUserIdentifierClient(IUAuthRequestClient request, IUAuthClientEvents events, IOptions options) { _request = request; + _events = events; _options = options.Value; } private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); - public async Task>> GetMyIdentifiersAsync() + public async Task>> GetMyIdentifiersAsync(PageRequest? request = null) { - var raw = await _request.SendFormAsync(Url("/users/me/identifiers/get")); - return UAuthResultMapper.FromJson>(raw); + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/get"), request); + return UAuthResultMapper.FromJson>(raw); } public async Task AddSelfAsync(AddUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/add"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode, request)); + } return UAuthResultMapper.From(raw); } public async Task UpdateSelfAsync(UpdateUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/update"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode, request)); + } return UAuthResultMapper.From(raw); } public async Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/set-primary"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode)); + } return UAuthResultMapper.From(raw); } public async Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/unset-primary"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode)); + } return UAuthResultMapper.From(raw); } public async Task VerifySelfAsync(VerifyUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/verify"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode)); + } return UAuthResultMapper.From(raw); } public async Task DeleteSelfAsync(DeleteUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/delete"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode)); + } return UAuthResultMapper.From(raw); } - public async Task>> GetUserIdentifiersAsync(UserKey userKey) + public async Task>> GetUserIdentifiersAsync(UserKey userKey, PageRequest? request = null) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/identifiers/get")); - return UAuthResultMapper.FromJson>(raw); + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/identifiers/get"), request); + return UAuthResultMapper.FromJson>(raw); } public async Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request) diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IEntitySnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IEntitySnapshot.cs new file mode 100644 index 00000000..12995e3e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IEntitySnapshot.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IEntitySnapshot +{ + T Snapshot(); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IVersionedEntity.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IVersionedEntity.cs new file mode 100644 index 00000000..893206e2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IVersionedEntity.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IVersionedEntity +{ + long Version { get; set; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs index 71e7a186..0d40c708 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs @@ -4,6 +4,7 @@ /// Provides an abstracted time source for the system. /// Used to improve testability and ensure consistent time handling. /// +// TODO: Add UnixTimeSeconds, TimeZone-aware Now, etc. if needed. public interface IClock { DateTimeOffset UtcNow { get; } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs index 8112e451..1096f980 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs @@ -7,5 +7,5 @@ public interface ITokenHasher { string Hash(string plaintext); - bool Verify(string plaintext, string hash); + bool Verify(string hash, string plaintext); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/INumericCodeGenerator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/INumericCodeGenerator.cs new file mode 100644 index 00000000..73b4cfc8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/INumericCodeGenerator.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface INumericCodeGenerator +{ + string Generate(int digits = 6); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs index f78e4806..b427fb04 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; public interface ISessionIssuer { - Task IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default); + Task IssueSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default); Task RotateSessionAsync(SessionRotationContext context, CancellationToken cancellationToken = default); diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityManager.cs new file mode 100644 index 00000000..1e12f065 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityManager.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAuthenticationSecurityManager +{ + Task GetOrCreateAccountAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task GetOrCreateFactorAsync(TenantKey tenant, UserKey userKey, CredentialType type, CancellationToken ct = default); + Task UpdateAsync(AuthenticationSecurityState updated, long expectedVersion, CancellationToken ct = default); + Task DeleteAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs new file mode 100644 index 00000000..fef7743c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAuthenticationSecurityStateStore +{ + Task GetAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default); + + Task AddAsync(AuthenticationSecurityState state, CancellationToken ct = default); + + Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, CancellationToken ct = default); + + Task DeleteAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs deleted file mode 100644 index 996b5a64..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs +++ /dev/null @@ -1,31 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Abstractions; - -/// -/// Application-level session command API. -/// Represents explicit intent to mutate session state. -/// All operations are authorization- and policy-aware. -/// -public interface IUAuthSessionManager -{ - /// - /// Revokes a single session (logout current device). - /// - Task RevokeSessionAsync(AuthSessionId sessionId, CancellationToken ct = default); - - /// - /// Revokes all sessions in a specific chain (logout a device). - /// - Task RevokeChainAsync(SessionChainId chainId, CancellationToken ct = default); - - /// - /// Revokes all session chains for the current user (logout all devices). - /// - Task RevokeAllChainsAsync(UserKey userKey, SessionChainId? exceptChainId = null, CancellationToken ct = default); - - /// - /// Hard revoke: revokes the entire session root (admin / security action). - /// - Task RevokeRootAsync(UserKey userKey, CancellationToken ct = default); -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs new file mode 100644 index 00000000..53fd7b31 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -0,0 +1,40 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface ISessionStore +{ + Task ExecuteAsync(Func action, CancellationToken ct = default); + Task ExecuteAsync(Func> action, CancellationToken ct = default); + + Task GetSessionAsync(AuthSessionId sessionId, CancellationToken ct = default); + Task SaveSessionAsync(UAuthSession session, long expectedVersion, CancellationToken ct = default); + Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default); + Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default); + public Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default); + public Task RevokeAllSessionsAsync(UserKey user, DateTimeOffset at, CancellationToken ct = default); + Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken ct = default); + + Task GetChainAsync(SessionChainId chainId, CancellationToken ct = default); + Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion, CancellationToken ct = default); + Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = default); + Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default); + Task RevokeOtherChainsAsync(TenantKey tenant, UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default); + Task RevokeAllChainsAsync(TenantKey tenant, UserKey user, DateTimeOffset at, CancellationToken ct = default); + Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default); + Task LogoutChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default); + + Task GetRootByUserAsync(UserKey userKey, CancellationToken ct = default); + Task GetRootByIdAsync(SessionRootId rootId, CancellationToken ct = default); + Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default); + Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default); + Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default); + Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default); + + Task GetChainIdBySessionAsync(AuthSessionId sessionId, CancellationToken ct = default); + Task> GetChainsByUserAsync(UserKey userKey, bool includeHistoricalRoots = false, CancellationToken ct = default); + Task GetChainByDeviceAsync(TenantKey tenant, UserKey userKey, DeviceId deviceId, CancellationToken ct = default); + Task> GetChainsByRootAsync(SessionRootId rootId, CancellationToken ct = default); + Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs similarity index 66% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs index 936731e2..72cd6b3e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs @@ -5,15 +5,15 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; /// /// Provides a factory abstraction for creating tenant-scoped session store /// instances capable of persisting sessions, chains, and session roots. -/// Implementations typically resolve concrete types from the dependency injection container. +/// Implementations typically resolve concrete types from the dependency injection container. /// -public interface ISessionStoreKernelFactory +public interface ISessionStoreFactory { /// /// Creates and returns a session store instance for the specified user ID type within the given tenant context. /// /// - /// An implementation able to perform session persistence operations. + /// An implementation able to perform session persistence operations. /// - ISessionStoreKernel Create(TenantKey tenant); + ISessionStore Create(TenantKey tenant); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs deleted file mode 100644 index 09e130c8..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs +++ /dev/null @@ -1,29 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Abstractions; - -public interface ISessionStoreKernel -{ - Task ExecuteAsync(Func action, CancellationToken ct = default); - Task ExecuteAsync(Func> action, CancellationToken ct = default); - - Task GetSessionAsync(AuthSessionId sessionId); - Task SaveSessionAsync(UAuthSession session); - Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at); - - Task GetChainAsync(SessionChainId chainId); - Task SaveChainAsync(UAuthSessionChain chain); - Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at); - Task GetActiveSessionIdAsync(SessionChainId chainId); - Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId); - - Task GetSessionRootByUserAsync(UserKey userKey); - Task GetSessionRootByIdAsync(SessionRootId rootId); - Task SaveSessionRootAsync(UAuthSessionRoot root); - Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at); - - Task GetChainIdBySessionAsync(AuthSessionId sessionId); - Task> GetChainsByUserAsync(UserKey userKey); - Task> GetSessionsByChainAsync(SessionChainId chainId); - Task DeleteExpiredSessionsAsync(DateTimeOffset at); -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISoftDeleteable.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISoftDeleteable.cs new file mode 100644 index 00000000..ecbf8540 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISoftDeleteable.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface ISoftDeletable +{ + bool IsDeleted { get; } + DateTimeOffset? DeletedAt { get; } + + T MarkDeleted(DateTimeOffset now); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IVersionedStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IVersionedStore.cs new file mode 100644 index 00000000..f7a02f0f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IVersionedStore.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +// TODO: Add IQueryStore +public interface IVersionedStore +{ + Task GetAsync(TKey key, CancellationToken ct = default); + + Task ExistsAsync(TKey key, CancellationToken ct = default); + + Task AddAsync(TEntity entity, CancellationToken ct = default); + + Task SaveAsync(TEntity entity, long expectedVersion, CancellationToken ct = default); + + Task DeleteAsync(TKey key, long expectedVersion, DeleteMode deleteMode, DateTimeOffset now, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs new file mode 100644 index 00000000..db4d54f6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs @@ -0,0 +1,120 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public abstract class InMemoryVersionedStore : IVersionedStore + where TEntity : class, IVersionedEntity, IEntitySnapshot + where TKey : notnull, IEquatable +{ + private readonly ConcurrentDictionary _store = new(); + + protected abstract TKey GetKey(TEntity entity); + protected virtual TEntity Snapshot(TEntity entity) => entity.Snapshot(); + protected virtual void BeforeAdd(TEntity entity) { } + protected virtual void BeforeSave(TEntity entity, TEntity current, long expectedVersion) { } + protected virtual void BeforeDelete(TEntity current, long expectedVersion, DeleteMode mode, DateTimeOffset now) { } + + public Task GetAsync(TKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_store.TryGetValue(key, out var entity)) + return Task.FromResult(null); + + return Task.FromResult(Snapshot(entity)); + } + + public Task ExistsAsync(TKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return Task.FromResult(_store.ContainsKey(key)); + } + + public virtual Task AddAsync(TEntity entity, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = GetKey(entity); + var snapshot = Snapshot(entity); + + BeforeAdd(snapshot); + + if (!_store.TryAdd(key, snapshot)) + throw new UAuthConflictException($"{typeof(TEntity).Name} already exists."); + + return Task.CompletedTask; + } + + public virtual Task SaveAsync(TEntity entity, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = GetKey(entity); + + if (!_store.TryGetValue(key, out var current)) + throw new UAuthNotFoundException($"{typeof(TEntity).Name} not found."); + + if (current.Version != expectedVersion) + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} version conflict."); + + var next = Snapshot(entity); + next.Version = expectedVersion + 1; + + BeforeSave(next, current, expectedVersion); + + if (!_store.TryUpdate(key, next, current)) + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} update conflict."); + + return Task.CompletedTask; + } + + public Task DeleteAsync(TKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_store.TryGetValue(key, out var current)) + throw new UAuthNotFoundException($"{typeof(TEntity).Name} not found."); + + if (current.Version != expectedVersion) + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} version conflict."); + + BeforeDelete(current, expectedVersion, mode, now); + + if (mode == DeleteMode.Hard) + { + if (!_store.TryRemove(new KeyValuePair(key, current))) + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} delete conflict."); + + return Task.CompletedTask; + } + + var next = Snapshot(current); + + if (next is not ISoftDeletable soft) + throw new UAuthConflictException($"{typeof(TEntity).Name} does not support soft delete."); + + next = soft.MarkDeleted(now); + next.Version = expectedVersion + 1; + + if (!_store.TryUpdate(key, next, current)) + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} delete conflict."); + + return Task.CompletedTask; + } + + /// + /// Returns a read-only list containing the current snapshot of all entities stored in memory. + /// + protected IReadOnlyList Values() => _store.Values.Select(Snapshot).ToList().AsReadOnly(); + + /// + /// Returns an enumerable collection of all entities currently stored in memory. + /// Useful in hooks when change entity value directly. + /// + protected IEnumerable InternalValues() => _store.Values; + + protected bool TryGet(TKey key, out TEntity? entity) => _store.TryGetValue(key, out entity); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs b/src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs index e7bb1179..9906aec8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs +++ b/src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs @@ -2,6 +2,17 @@ public static class UAuthConstants { + public static class Access + { + public const string Permissions = "permissions"; + } + + public static class Claims + { + public const string Tenant = "uauth:tenant"; + public const string Permission = "uauth:permission"; + } + public static class HttpItems { public const string SessionContext = "__UAuth.SessionContext"; diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs new file mode 100644 index 00000000..0a881359 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum ActionScope +{ + Anonymous, + Self, + Admin, + System +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs index 1c07c5ad..4f3812bf 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs @@ -14,4 +14,5 @@ public sealed record AuthIdentitySnapshot public DateTimeOffset? AuthenticatedAt { get; init; } public SessionState? SessionState { get; init; } public string? TimeZone { get; init; } + public UserStatus UserStatus { get; set; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index 320faef7..e526683e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -11,6 +11,7 @@ public sealed class AccessContext public TenantKey ActorTenant { get; init; } public bool IsAuthenticated { get; init; } public bool IsSystemActor { get; init; } + public SessionChainId? ActorChainId { get; } // Target public string? Resource { get; init; } @@ -38,7 +39,8 @@ internal AccessContext( TenantKey actorTenant, bool isAuthenticated, bool isSystemActor, - string resource, + SessionChainId? actorChainId, + string? resource, UserKey? targetUserKey, TenantKey resourceTenant, string action, @@ -48,6 +50,7 @@ internal AccessContext( ActorTenant = actorTenant; IsAuthenticated = isAuthenticated; IsSystemActor = isSystemActor; + ActorChainId = actorChainId; Resource = resource; TargetUserKey = targetUserKey; @@ -56,6 +59,27 @@ internal AccessContext( Action = action; Attributes = attributes; } + + public AccessContext WithAttribute(string key, object value) + { + var merged = new Dictionary(Attributes) + { + [key] = value + }; + + return new AccessContext( + ActorUserKey, + ActorTenant, + IsAuthenticated, + IsSystemActor, + ActorChainId, + Resource, + TargetUserKey, + ResourceTenant, + Action, + merged + ); + } } internal sealed class EmptyAttributes : IReadOnlyDictionary diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs index 38760414..10c097d5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs @@ -6,11 +6,11 @@ public sealed class DeviceInfo { public required DeviceId DeviceId { get; init; } + // TODO: Implement device type and device limits /// - /// High-level platform classification (web, mobile, desktop, iot). - /// Used for analytics and policy decisions. + /// Device type that can be used for device limits. Sends with header "X-Device-Type" or form field "device_type". Examples: "web", "mobile", "desktop", "tablet", "iot". /// - public string? Platform { get; init; } + public string? DeviceType { get; init; } /// /// Operating system information (e.g. iOS 17, Android 14, Windows 11). @@ -22,6 +22,12 @@ public sealed class DeviceInfo /// public string? Browser { get; init; } + /// + /// High-level platform classification (web, mobile, desktop, iot). + /// Used for analytics and policy decisions. + /// + public string? Platform { get; init; } + /// /// Raw user-agent string (optional). /// diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/CaseHandling.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/CaseHandling.cs new file mode 100644 index 00000000..08e57fe5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/CaseHandling.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum CaseHandling +{ + Preserve, + ToLower, + ToUpper +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs new file mode 100644 index 00000000..b236c0e5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs @@ -0,0 +1,30 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public class PageRequest +{ + public int PageNumber { get; init; } = 1; + public int PageSize { get; init; } = 250; + + public string? SortBy { get; init; } + public bool Descending { get; init; } + + public int MaxPageSize { get; init; } = 1000; + + public PageRequest Normalize() + { + var page = PageNumber <= 0 ? 1 : PageNumber; + var size = PageSize <= 0 ? 250 : PageSize; + + if (size > MaxPageSize) + size = MaxPageSize; + + return new PageRequest + { + PageNumber = page, + PageSize = size, + SortBy = SortBy, + Descending = Descending, + MaxPageSize = MaxPageSize + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs index e3b92ad3..854eafa4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs @@ -2,12 +2,22 @@ public sealed class PagedResult { - public IReadOnlyList Items { get; } - public int TotalCount { get; } + public IReadOnlyList Items { get; init; } + public int TotalCount { get; init; } + public int PageNumber { get; init; } + public int PageSize { get; init; } + public string? SortBy { get; init; } + public bool Descending { get; init; } - public PagedResult(IReadOnlyList items, int totalCount) + public bool HasNext => PageNumber * PageSize < TotalCount; + + public PagedResult(IReadOnlyList items, int totalCount, int pageNumber, int pageSize, string? sortBy, bool descending) { Items = items; TotalCount = totalCount; + PageNumber = pageNumber; + PageSize = pageSize; + SortBy = sortBy; + Descending = descending; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs index be87ad8b..c446070c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs @@ -4,6 +4,8 @@ public class UAuthResult { public bool IsSuccess { get; init; } public int Status { get; init; } + public string? CorrelationId { get; init; } + public string? TraceId { get; init; } public UAuthProblem? Problem { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthValidationError.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthValidationError.cs new file mode 100644 index 00000000..08a742da --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthValidationError.cs @@ -0,0 +1,5 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record UAuthValidationError( + string Code, + string? Field = null); \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs index 23769bec..c85b9b92 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs @@ -8,8 +8,8 @@ public sealed record LoginRequest public TenantKey Tenant { get; init; } public string Identifier { get; init; } = default!; public string Secret { get; init; } = default!; + public CredentialType Factor { get; init; } = CredentialType.Password; public DateTimeOffset? At { get; init; } - public required DeviceContext Device { get; init; } public IReadOnlyDictionary? Metadata { get; init; } /// @@ -17,7 +17,4 @@ public sealed record LoginRequest /// Server policy may still ignore this. /// public bool RequestTokens { get; init; } = true; - - // Optional - public SessionChainId? ChainId { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Revoke/RevokeResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Revoke/RevokeResult.cs new file mode 100644 index 00000000..0a980aca --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Revoke/RevokeResult.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record RevokeResult + { + public bool CurrentChain { get; init; } + public bool RootRevoked { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs new file mode 100644 index 00000000..edea1d4b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs @@ -0,0 +1,28 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class SessionChainDetailDto +{ + public SessionChainId ChainId { get; init; } + + public string? DeviceType { get; init; } + public string? OperatingSystem { get; init; } + public string? Platform { get; init; } + public string? Browser { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset LastSeenAt { get; init; } + + public SessionChainState State { get; init; } + + public int RotationCount { get; init; } + public int TouchCount { get; init; } + + public bool IsRevoked { get; init; } + public DateTimeOffset? RevokedAt { get; init; } + + public AuthSessionId? ActiveSessionId { get; init; } + + public IReadOnlyList Sessions { get; init; } = []; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs new file mode 100644 index 00000000..51debfc8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record SessionChainSummaryDto +{ + public required SessionChainId ChainId { get; init; } + public string? DeviceType { get; init; } + public string? OperatingSystem { get; init; } + public string? Platform { get; init; } + public string? Browser { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? LastSeenAt { get; init; } + public int RotationCount { get; init; } + public int TouchCount { get; init; } + public bool IsRevoked { get; init; } + public DateTimeOffset? RevokedAt { get; init; } + public AuthSessionId? ActiveSessionId { get; init; } + public bool IsCurrentDevice { get; init; } + public SessionChainState State { get; set; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfoDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfoDto.cs new file mode 100644 index 00000000..cbf966e1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfoDto.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record SessionInfoDto( + AuthSessionId SessionId, + DateTimeOffset CreatedAt, + DateTimeOffset ExpiresAt, + bool IsRevoked + ); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserStatus.cs similarity index 73% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/User/UserStatus.cs index 9aff5b20..f822f02c 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserStatus.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; +namespace CodeBeam.UltimateAuth.Core.Contracts; public enum UserStatus { @@ -16,5 +16,5 @@ public enum UserStatus PendingActivation = 60, PendingVerification = 70, - Deactivated = 80, + Unknown = 99 } diff --git a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs similarity index 51% rename from src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs rename to src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs index 8220ca7e..49a86c51 100644 --- a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs @@ -1,10 +1,55 @@ -namespace CodeBeam.UltimateAuth.Server.Defaults; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Defaults; public static class UAuthActions { + public static string Create(string resource, string operation, ActionScope scope, string? subResource = null) + { + if (string.IsNullOrWhiteSpace(resource)) + throw new ArgumentException("resource required"); + + if (string.IsNullOrWhiteSpace(operation)) + throw new ArgumentException("operation required"); + + var scopePart = scope.ToString().ToLowerInvariant(); + + return subResource is null + ? $"{resource}.{operation}.{scopePart}" + : $"{resource}.{subResource}.{operation}.{scopePart}"; + } + + public static class Flows + { + public const string LogoutSelf = "flows.logout.self"; + public const string LogoutDeviceSelf = "flows.logoutdevice.self"; + public const string LogoutDeviceAdmin = "flows.logoutdevice.admin"; + public const string LogoutOthersSelf = "flows.logoutothers.self"; + public const string LogoutOthersAdmin = "flows.logoutothers.admin"; + public const string LogoutAllSelf = "flows.logoutall.self"; + public const string LogoutAllAdmin = "flows.logoutall.admin"; + } + + public static class Sessions + { + public const string GetChainSelf = "sessions.getchain.self"; + public const string GetChainAdmin = "sessions.getchain.admin"; + public const string ListChainsSelf = "sessions.listchains.self"; + public const string ListChainsAdmin = "sessions.listchains.admin"; + public const string RevokeChainSelf = "sessions.revokechain.self"; + public const string RevokeChainAdmin = "sessions.revokechain.admin"; + public const string RevokeAllChainsSelf = "sessions.revokeallchains.self"; + public const string RevokeAllChainsAdmin = "sessions.revokeallchains.admin"; + public const string RevokeOtherChainsSelf = "sessions.revokeotherchains.self"; + public const string RevokeSessionAdmin = "sessions.revoke.admin"; + public const string RevokeRootAdmin = "sessions.revokeroot.admin"; + } + public static class Users { - public const string Create = "users.create"; + public const string CreateAnonymous = "users.create.anonymous"; + public const string CreateAdmin = "users.create.admin"; + public const string DeleteSelf = "users.delete.self"; public const string DeleteAdmin = "users.delete.admin"; public const string ChangeStatusSelf = "users.status.change.self"; public const string ChangeStatusAdmin = "users.status.change.admin"; @@ -47,10 +92,9 @@ public static class Credentials public const string RevokeSelf = "credentials.revoke.self"; public const string RevokeAdmin = "credentials.revoke.admin"; public const string ActivateSelf = "credentials.activate.self"; - public const string ActivateAdmin = "credentials.activate.admin"; - public const string BeginResetSelf = "credentials.beginreset.self"; + public const string BeginResetAnonymous = "credentials.beginreset.anonymous"; public const string BeginResetAdmin = "credentials.beginreset.admin"; - public const string CompleteResetSelf = "credentials.completereset.self"; + public const string CompleteResetAnonymous = "credentials.completereset.anonymous"; public const string CompleteResetAdmin = "credentials.completereset.admin"; public const string DeleteAdmin = "credentials.delete.admin"; } @@ -63,6 +107,11 @@ public static class Roles public const string ReadAdmin = "authorization.roles.read.admin"; public const string AssignAdmin = "authorization.roles.assign.admin"; public const string RemoveAdmin = "authorization.roles.remove.admin"; + public const string CreateAdmin = "authorization.roles.create.admin"; + public const string RenameAdmin = "authorization.roles.rename.admin"; + public const string DeleteAdmin = "authorization.roles.delete.admin"; + public const string SetPermissionsAdmin = "authorization.roles.permissions.admin"; + public const string QueryAdmin = "authorization.roles.query.admin"; } } } diff --git a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthSchemeDefaults.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthSchemeDefaults.cs similarity index 67% rename from src/CodeBeam.UltimateAuth.Server/Defaults/UAuthSchemeDefaults.cs rename to src/CodeBeam.UltimateAuth.Core/Defaults/UAuthSchemeDefaults.cs index bfcea56c..98c5d1fb 100644 --- a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthSchemeDefaults.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthSchemeDefaults.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Server.Defaults; +namespace CodeBeam.UltimateAuth.Core.Defaults; public static class UAuthSchemeDefaults { diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs index fcc44dde..23372748 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs @@ -3,21 +3,64 @@ public sealed class DeviceContext { public DeviceId? DeviceId { get; init; } + public string? DeviceType { get; init; } + public string? OperatingSystem { get; init; } + public string? Platform { get; init; } + public string? Browser { get; init; } + public string? IpAddress { get; init; } public bool HasDeviceId => DeviceId is not null; - private DeviceContext(DeviceId? deviceId) + private DeviceContext( + DeviceId? deviceId, + string? deviceType, + string? platform, + string? operatingSystem, + string? browser, + string? ipAddress) { DeviceId = deviceId; + DeviceType = deviceType; + Platform = platform; + OperatingSystem = operatingSystem; + Browser = browser; + IpAddress = ipAddress; } - public static DeviceContext Anonymous() => new(null); + public static DeviceContext Anonymous() + => new( + deviceId: null, + deviceType: null, + platform: null, + operatingSystem: null, + browser: null, + ipAddress: null); - public static DeviceContext FromDeviceId(DeviceId deviceId) => new(deviceId); + public static DeviceContext Create( + DeviceId deviceId, + string? deviceType, + string? platform, + string? operatingSystem, + string? browser, + string? ipAddress) + { + return new DeviceContext( + deviceId, + Normalize(deviceType), + Normalize(platform), + Normalize(operatingSystem), + Normalize(browser), + Normalize(ipAddress)); + } + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) + ? null + : value.Trim().ToLowerInvariant(); // DeviceInfo is a transport object. // AuthFlowContextFactory changes it to a useable DeviceContext // DeviceContext doesn't have fields like IsTrusted etc. It's authority layer's responsibility. - // IP, Geo, Fingerprint, Platform, UA will be added here. + // Geo and Fingerprint will be added here. } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/CredentialKind.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/GrantKind.cs similarity index 78% rename from src/CodeBeam.UltimateAuth.Core/Domain/Principals/CredentialKind.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/Principals/GrantKind.cs index 38e076be..601b18f8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/CredentialKind.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/GrantKind.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Core.Domain; -public enum CredentialKind +public enum GrantKind { Session, AccessToken, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryGrantKind.cs similarity index 70% rename from src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryGrantKind.cs index e5ddc547..9e12f09d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryGrantKind.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Core.Domain; -public enum PrimaryCredentialKind +public enum PrimaryGrantKind { Stateful, Stateless diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityScope.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityScope.cs new file mode 100644 index 00000000..cd0c6c31 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityScope.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Domain; + +public enum AuthenticationSecurityScope +{ + Account = 0, + Factor = 1 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs new file mode 100644 index 00000000..501db9ae --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs @@ -0,0 +1,428 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Security; + +// TODO: Do not store reset token hash in db. +public sealed class AuthenticationSecurityState +{ + public Guid Id { get; } + public TenantKey Tenant { get; } + public UserKey UserKey { get; } + public AuthenticationSecurityScope Scope { get; } + public CredentialType? CredentialType { get; } + + public int FailedAttempts { get; } + public DateTimeOffset? LastFailedAt { get; } + public DateTimeOffset? LockedUntil { get; } + public bool RequiresReauthentication { get; } + + public DateTimeOffset? ResetRequestedAt { get; } + public DateTimeOffset? ResetExpiresAt { get; } + public DateTimeOffset? ResetConsumedAt { get; } + public string? ResetTokenHash { get; } + public int ResetAttempts { get; } + + public long SecurityVersion { get; } + + public bool IsLocked(DateTimeOffset now) => LockedUntil.HasValue && LockedUntil > now; + public bool HasResetRequest => ResetRequestedAt is not null; + + private AuthenticationSecurityState( + Guid id, + TenantKey tenant, + UserKey userKey, + AuthenticationSecurityScope scope, + CredentialType? credentialType, + int failedAttempts, + DateTimeOffset? lastFailedAt, + DateTimeOffset? lockedUntil, + bool requiresReauthentication, + DateTimeOffset? resetRequestedAt, + DateTimeOffset? resetExpiresAt, + DateTimeOffset? resetConsumedAt, + string? resetTokenHash, + int resetAttempts, + long securityVersion) + { + if (id == Guid.Empty) + throw new UAuthValidationException("security_state_id_required"); + + if (scope == AuthenticationSecurityScope.Account && credentialType is not null) + throw new UAuthValidationException("account_scope_must_not_have_credential_type"); + + if (scope == AuthenticationSecurityScope.Factor && credentialType is null) + throw new UAuthValidationException("factor_scope_requires_credential_type"); + + Id = id; + Tenant = tenant; + UserKey = userKey; + Scope = scope; + CredentialType = credentialType; + FailedAttempts = failedAttempts < 0 ? 0 : failedAttempts; + LastFailedAt = lastFailedAt; + LockedUntil = lockedUntil; + RequiresReauthentication = requiresReauthentication; + ResetRequestedAt = resetRequestedAt; + ResetExpiresAt = resetExpiresAt; + ResetConsumedAt = resetConsumedAt; + ResetTokenHash = resetTokenHash; + ResetAttempts = resetAttempts; + SecurityVersion = securityVersion < 0 ? 0 : securityVersion; + } + + public static AuthenticationSecurityState CreateAccount(TenantKey tenant, UserKey userKey, Guid? id = null) + => new( + id ?? Guid.NewGuid(), + tenant, + userKey, + AuthenticationSecurityScope.Account, + credentialType: null, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: null, + requiresReauthentication: false, + resetRequestedAt: null, + resetExpiresAt: null, + resetConsumedAt: null, + resetTokenHash: null, + resetAttempts: 0, + securityVersion: 0); + + public static AuthenticationSecurityState CreateFactor(TenantKey tenant, UserKey userKey, CredentialType type, Guid? id = null) + => new( + id ?? Guid.NewGuid(), + tenant, + userKey, + AuthenticationSecurityScope.Factor, + credentialType: type, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: null, + requiresReauthentication: false, + resetRequestedAt: null, + resetExpiresAt: null, + resetConsumedAt: null, + resetTokenHash: null, + resetAttempts: 0, + securityVersion: 0); + + /// + /// Resets failures if the last failure is outside the given window. + /// Keeps lock and reauth flags untouched by default. + /// + public AuthenticationSecurityState ResetFailuresIfWindowExpired(DateTimeOffset now, TimeSpan window) + { + if (window <= TimeSpan.Zero) + return this; + + if (LastFailedAt is not DateTimeOffset last) + return this; + + if (now - last <= window) + return this; + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: LockedUntil, + requiresReauthentication: RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, + securityVersion: SecurityVersion + 1); + } + + /// + /// Registers a failed authentication attempt. Optionally locks until now + duration when threshold reached. + /// If already locked, may extend lock depending on extendLock. + /// + public AuthenticationSecurityState RegisterFailure(DateTimeOffset now, int threshold, TimeSpan lockoutDuration, bool extendLock = true) + { + if (threshold < 0) + throw new UAuthValidationException(nameof(threshold)); + + var nextCount = FailedAttempts + 1; + + DateTimeOffset? nextLockedUntil = LockedUntil; + + if (threshold > 0 && nextCount >= threshold) + { + var candidate = now.Add(lockoutDuration); + + if (nextLockedUntil is null) + nextLockedUntil = candidate; + else if (extendLock && candidate > nextLockedUntil) + nextLockedUntil = candidate; + } + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + failedAttempts: nextCount, + lastFailedAt: now, + lockedUntil: nextLockedUntil, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, + securityVersion: SecurityVersion + 1); + } + + /// + /// Registers a successful authentication: clears failures and lock. + /// + public AuthenticationSecurityState RegisterSuccess() + => new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: null, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, + securityVersion: SecurityVersion + 1); + + /// + /// Admin/system unlock: clears lock and failures. + /// + public AuthenticationSecurityState Unlock() + => new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: null, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, + securityVersion: SecurityVersion + 1); + + public AuthenticationSecurityState LockUntil(DateTimeOffset until, bool overwriteIfShorter = false) + { + DateTimeOffset? next = LockedUntil; + + if (next is null) + next = until; + else if (overwriteIfShorter || until > next) + next = until; + + if (next == LockedUntil) + return this; + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + lockedUntil: next, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, + SecurityVersion + 1); + } + + public AuthenticationSecurityState RequireReauthentication() + { + if (RequiresReauthentication) + return this; + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + requiresReauthentication: true, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, + securityVersion: SecurityVersion + 1); + } + + public AuthenticationSecurityState ClearReauthentication() + { + if (!RequiresReauthentication) + return this; + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + requiresReauthentication: false, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, + securityVersion: SecurityVersion + 1); + } + + public bool HasActiveReset(DateTimeOffset now) + { + if (ResetRequestedAt is null) + return false; + + if (ResetConsumedAt is not null) + return false; + + if (ResetExpiresAt is not null && ResetExpiresAt <= now) + return false; + + return true; + } + + public bool IsResetExpired(DateTimeOffset now) + { + return ResetExpiresAt is not null && ResetExpiresAt <= now; + } + + public AuthenticationSecurityState BeginReset(string tokenHash, DateTimeOffset now, TimeSpan validity) + { + if (string.IsNullOrWhiteSpace(tokenHash)) + throw new UAuthValidationException("reset_token_required"); + + if (HasActiveReset(now)) + throw new UAuthConflictException("reset_already_active"); + + var expires = now.Add(validity); + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + RequiresReauthentication, + resetRequestedAt: now, + resetExpiresAt: expires, + resetConsumedAt: null, + resetTokenHash: tokenHash, + resetAttempts: 0, + securityVersion: SecurityVersion + 1); + } + + public AuthenticationSecurityState RegisterResetFailure(DateTimeOffset now, int maxAttempts) + { + if (IsResetExpired(now)) + return ClearReset(); + + var next = ResetAttempts + 1; + + if (next >= maxAttempts) + { + return ClearReset(); + } + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + next, + securityVersion: SecurityVersion + 1); + } + + public AuthenticationSecurityState ConsumeReset(DateTimeOffset now) + { + if (ResetRequestedAt is null) + throw new UAuthConflictException("reset_not_requested"); + + if (ResetConsumedAt is not null) + throw new UAuthConflictException("reset_already_used"); + + if (IsResetExpired(now)) + throw new UAuthConflictException("reset_expired"); + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + now, + null, + ResetAttempts, + securityVersion: SecurityVersion + 1); + } + + public AuthenticationSecurityState ClearReset() + { + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + RequiresReauthentication, + null, + null, + null, + null, + 0, + securityVersion: SecurityVersion + 1); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/CredentialType.cs similarity index 85% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialType.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/Security/CredentialType.cs index 9b87807a..35226f1b 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/CredentialType.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +namespace CodeBeam.UltimateAuth.Core.Domain; public enum CredentialType { diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/ResetCodeType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/ResetCodeType.cs new file mode 100644 index 00000000..50d23e2c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/ResetCodeType.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Domain; + +public enum ResetCodeType +{ + Token = 0, + Code = 10 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs index b978f3d4..4bc6988f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Core.Domain; // AuthSessionId is a opaque token, because it's more sensitive data. SessionChainId and SessionRootId are Guid. [JsonConverter(typeof(AuthSessionIdJsonConverter))] -public readonly record struct AuthSessionId +public readonly record struct AuthSessionId : IParsable { public string Value { get; } @@ -16,19 +16,44 @@ private AuthSessionId(string value) public static bool TryCreate(string? raw, out AuthSessionId id) { - if (string.IsNullOrWhiteSpace(raw)) + if (IsValid(raw)) { - id = default; - return false; + id = new AuthSessionId(raw!); + return true; } - if (raw.Length < 32) + id = default; + return false; + } + + public static AuthSessionId Parse(string s, IFormatProvider? provider) + { + if (TryParse(s, provider, out var id)) + return id; + + throw new FormatException("Invalid AuthSessionId."); + } + + public static bool TryParse(string? s, IFormatProvider? provider, out AuthSessionId result) + { + if (IsValid(s)) { - id = default; - return false; + result = new AuthSessionId(s!); + return true; } - id = new AuthSessionId(raw); + result = default; + return false; + } + + private static bool IsValid(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + if (value.Length < 32) + return false; + return true; } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs index c733e991..cd104b31 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Domain; [JsonConverter(typeof(SessionChainIdJsonConverter))] -public readonly record struct SessionChainId(Guid Value) +public readonly record struct SessionChainId(Guid Value) : IParsable { public static SessionChainId New() => new(Guid.NewGuid()); @@ -32,5 +32,27 @@ public static bool TryCreate(string raw, out SessionChainId id) return false; } + public static SessionChainId Parse(string s, IFormatProvider? provider) + { + if (TryParse(s, provider, out var id)) + return id; + + throw new FormatException("Invalid SessionChainId."); + } + + public static bool TryParse(string? s, IFormatProvider? provider, out SessionChainId result) + { + if (!string.IsNullOrWhiteSpace(s) && + Guid.TryParse(s, out var guid) && + guid != Guid.Empty) + { + result = new SessionChainId(guid); + return true; + } + + result = default; + return false; + } + public override string ToString() => Value.ToString("N"); } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainState.cs new file mode 100644 index 00000000..dde7b81d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainState.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Domain; + +public enum SessionChainState +{ + Active = 0, + Passive = 10, + Revoked = 20 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs index 60a85cfb..20e0b713 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Domain; [JsonConverter(typeof(SessionRootIdJsonConverter))] -public readonly record struct SessionRootId(Guid Value) +public readonly record struct SessionRootId(Guid Value) : IParsable { public static SessionRootId New() => new(Guid.NewGuid()); @@ -25,5 +25,27 @@ public static bool TryCreate(string raw, out SessionRootId id) return false; } + public static SessionRootId Parse(string s, IFormatProvider? provider) + { + if (TryParse(s, provider, out var id)) + return id; + + throw new FormatException("Invalid SessionRootId."); + } + + public static bool TryParse(string? s, IFormatProvider? provider, out SessionRootId result) + { + if (!string.IsNullOrWhiteSpace(s) && + Guid.TryParse(s, out var guid) && + guid != Guid.Empty) + { + result = new SessionRootId(guid); + return true; + } + + result = default; + return false; + } + public override string ToString() => Value.ToString("N"); } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index bcb8b2a2..806a9c5b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -1,8 +1,10 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Domain; -public sealed class UAuthSession +// TODO: Add ISoftDeleteable +public sealed class UAuthSession : IVersionedEntity { public AuthSessionId SessionId { get; } public TenantKey Tenant { get; } @@ -10,28 +12,26 @@ public sealed class UAuthSession public SessionChainId ChainId { get; } public DateTimeOffset CreatedAt { get; } public DateTimeOffset ExpiresAt { get; } - public DateTimeOffset? LastSeenAt { get; } public bool IsRevoked { get; } public DateTimeOffset? RevokedAt { get; } public long SecurityVersionAtCreation { get; } - public DeviceContext Device { get; } public ClaimsSnapshot Claims { get; } public SessionMetadata Metadata { get; } + public long Version { get; set; } private UAuthSession( - AuthSessionId sessionId, - TenantKey tenant, - UserKey userKey, - SessionChainId chainId, - DateTimeOffset createdAt, - DateTimeOffset expiresAt, - DateTimeOffset? lastSeenAt, - bool isRevoked, - DateTimeOffset? revokedAt, - long securityVersionAtCreation, - DeviceContext device, - ClaimsSnapshot claims, - SessionMetadata metadata) + AuthSessionId sessionId, + TenantKey tenant, + UserKey userKey, + SessionChainId chainId, + DateTimeOffset createdAt, + DateTimeOffset expiresAt, + bool isRevoked, + DateTimeOffset? revokedAt, + long securityVersionAtCreation, + ClaimsSnapshot claims, + SessionMetadata metadata, + long version) { SessionId = sessionId; Tenant = tenant; @@ -39,13 +39,12 @@ private UAuthSession( ChainId = chainId; CreatedAt = createdAt; ExpiresAt = expiresAt; - LastSeenAt = lastSeenAt; IsRevoked = isRevoked; RevokedAt = revokedAt; SecurityVersionAtCreation = securityVersionAtCreation; - Device = device; Claims = claims; Metadata = metadata; + Version = version; } public static UAuthSession Create( @@ -55,7 +54,7 @@ public static UAuthSession Create( SessionChainId chainId, DateTimeOffset now, DateTimeOffset expiresAt, - DeviceContext device, + long securityVersion, ClaimsSnapshot? claims, SessionMetadata metadata) { @@ -66,54 +65,12 @@ public static UAuthSession Create( chainId, createdAt: now, expiresAt: expiresAt, - lastSeenAt: now, isRevoked: false, revokedAt: null, - securityVersionAtCreation: 0, - device: device, + securityVersionAtCreation: securityVersion, claims: claims ?? ClaimsSnapshot.Empty, - metadata: metadata - ); - } - - public UAuthSession WithSecurityVersion(long version) - { - if (SecurityVersionAtCreation == version) - return this; - - return new UAuthSession( - SessionId, - Tenant, - UserKey, - ChainId, - CreatedAt, - ExpiresAt, - LastSeenAt, - IsRevoked, - RevokedAt, - version, - Device, - Claims, - Metadata - ); - } - - public UAuthSession Touch(DateTimeOffset at) - { - return new UAuthSession( - SessionId, - Tenant, - UserKey, - ChainId, - CreatedAt, - ExpiresAt, - at, - IsRevoked, - RevokedAt, - SecurityVersionAtCreation, - Device, - Claims, - Metadata + metadata: metadata, + version: 0 ); } @@ -128,13 +85,12 @@ public UAuthSession Revoke(DateTimeOffset at) ChainId, CreatedAt, ExpiresAt, - LastSeenAt, true, at, SecurityVersionAtCreation, - Device, Claims, - Metadata + Metadata, + Version + 1 ); } @@ -145,13 +101,12 @@ internal static UAuthSession FromProjection( SessionChainId chainId, DateTimeOffset createdAt, DateTimeOffset expiresAt, - DateTimeOffset? lastSeenAt, bool isRevoked, DateTimeOffset? revokedAt, long securityVersionAtCreation, - DeviceContext device, ClaimsSnapshot claims, - SessionMetadata metadata) + SessionMetadata metadata, + long version) { return new UAuthSession( sessionId, @@ -160,17 +115,16 @@ internal static UAuthSession FromProjection( chainId, createdAt, expiresAt, - lastSeenAt, isRevoked, revokedAt, securityVersionAtCreation, - device, claims, - metadata + metadata, + version ); } - public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout) + public SessionState GetState(DateTimeOffset at) { if (IsRevoked) return SessionState.Revoked; @@ -178,9 +132,6 @@ public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout) if (at >= ExpiresAt) return SessionState.Expired; - if (idleTimeout.HasValue && at - LastSeenAt >= idleTimeout.Value) - return SessionState.Expired; - return SessionState.Active; } @@ -196,14 +147,12 @@ public UAuthSession WithChain(SessionChainId chainId) chainId: chainId, createdAt: CreatedAt, expiresAt: ExpiresAt, - lastSeenAt: LastSeenAt, isRevoked: IsRevoked, revokedAt: RevokedAt, securityVersionAtCreation: SecurityVersionAtCreation, - device: Device, claims: Claims, - metadata: Metadata + metadata: Metadata, + version: Version + 1 ); } - } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index dfecbcb3..7d9139cc 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -1,42 +1,65 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using static CodeBeam.UltimateAuth.Core.Defaults.UAuthActions; namespace CodeBeam.UltimateAuth.Core.Domain; -public sealed class UAuthSessionChain +public sealed class UAuthSessionChain : IVersionedEntity { public SessionChainId ChainId { get; } public SessionRootId RootId { get; } public TenantKey Tenant { get; } public UserKey UserKey { get; } - public int RotationCount { get; } - public long SecurityVersionAtCreation { get; } + + public DateTimeOffset CreatedAt { get; } + public DateTimeOffset LastSeenAt { get; } + public DateTimeOffset? AbsoluteExpiresAt { get; } + public DeviceContext Device { get; } public ClaimsSnapshot ClaimsSnapshot { get; } public AuthSessionId? ActiveSessionId { get; } - public bool IsRevoked { get; } + public int RotationCount { get; } + public int TouchCount { get; } + public long SecurityVersionAtCreation { get; } + public DateTimeOffset? RevokedAt { get; } + public long Version { get; set; } + + + public bool IsRevoked => RevokedAt is not null; + public SessionChainState State => IsRevoked ? SessionChainState.Revoked : ActiveSessionId is null ? SessionChainState.Passive : SessionChainState.Active; private UAuthSessionChain( SessionChainId chainId, SessionRootId rootId, TenantKey tenant, UserKey userKey, - int rotationCount, - long securityVersionAtCreation, + DateTimeOffset createdAt, + DateTimeOffset lastSeenAt, + DateTimeOffset? absoluteExpiresAt, + DeviceContext device, ClaimsSnapshot claimsSnapshot, AuthSessionId? activeSessionId, - bool isRevoked, - DateTimeOffset? revokedAt) + int rotationCount, + int touchCount, + long securityVersionAtCreation, + DateTimeOffset? revokedAt, + long version) { ChainId = chainId; RootId = rootId; Tenant = tenant; UserKey = userKey; - RotationCount = rotationCount; - SecurityVersionAtCreation = securityVersionAtCreation; + CreatedAt = createdAt; + LastSeenAt = lastSeenAt; + AbsoluteExpiresAt = absoluteExpiresAt; + Device = device; ClaimsSnapshot = claimsSnapshot; ActiveSessionId = activeSessionId; - IsRevoked = isRevoked; + RotationCount = rotationCount; + TouchCount = touchCount; + SecurityVersionAtCreation = securityVersionAtCreation; RevokedAt = revokedAt; + Version = version; } public static UAuthSessionChain Create( @@ -44,26 +67,37 @@ public static UAuthSessionChain Create( SessionRootId rootId, TenantKey tenant, UserKey userKey, - long securityVersion, - ClaimsSnapshot claimsSnapshot) + DateTimeOffset createdAt, + DateTimeOffset? expiresAt, + DeviceContext device, + ClaimsSnapshot claimsSnapshot, + long securityVersion) { return new UAuthSessionChain( chainId, rootId, tenant, userKey, + createdAt, + createdAt, + expiresAt, + device, + claimsSnapshot, + null, rotationCount: 0, + touchCount: 0, securityVersionAtCreation: securityVersion, - claimsSnapshot: claimsSnapshot, - activeSessionId: null, - isRevoked: false, - revokedAt: null + revokedAt: null, + version: 0 ); } - public UAuthSessionChain AttachSession(AuthSessionId sessionId) + public UAuthSessionChain AttachSession(AuthSessionId sessionId, DateTimeOffset now) { - if (IsRevoked) + if (IsRevoked || IsExpired(now)) + return this; + + if (ActiveSessionId.HasValue && ActiveSessionId.Value.Equals(sessionId)) return this; return new UAuthSessionChain( @@ -71,18 +105,50 @@ public UAuthSessionChain AttachSession(AuthSessionId sessionId) RootId, Tenant, UserKey, + CreatedAt, + lastSeenAt: now, + AbsoluteExpiresAt, + Device, + ClaimsSnapshot, + activeSessionId: sessionId, RotationCount, // Unchanged on first attach + TouchCount, SecurityVersionAtCreation, + RevokedAt, + Version + 1 + ); + } + + public UAuthSessionChain DetachSession(DateTimeOffset now) + { + if (ActiveSessionId is null) + return this; + + return new UAuthSessionChain( + ChainId, + RootId, + Tenant, + UserKey, + CreatedAt, + lastSeenAt: now, + AbsoluteExpiresAt, + Device, ClaimsSnapshot, - activeSessionId: sessionId, - isRevoked: false, - revokedAt: null + activeSessionId: null, + RotationCount, // Unchanged on first attach + TouchCount, + SecurityVersionAtCreation, + RevokedAt, + Version + 1 ); } - public UAuthSessionChain RotateSession(AuthSessionId sessionId) + public UAuthSessionChain RotateSession(AuthSessionId sessionId, DateTimeOffset now, ClaimsSnapshot? claimsSnapshot = null) { - if (IsRevoked) + if (IsRevoked || IsExpired(now)) + return this; + + if (ActiveSessionId.HasValue && ActiveSessionId.Value.Equals(sessionId)) return this; return new UAuthSessionChain( @@ -90,18 +156,23 @@ public UAuthSessionChain RotateSession(AuthSessionId sessionId) RootId, Tenant, UserKey, + CreatedAt, + lastSeenAt: now, + AbsoluteExpiresAt, + Device, + claimsSnapshot ?? ClaimsSnapshot, + activeSessionId: sessionId, RotationCount + 1, + TouchCount, SecurityVersionAtCreation, - ClaimsSnapshot, - activeSessionId: sessionId, - isRevoked: false, - revokedAt: null + RevokedAt, + Version + 1 ); } - public UAuthSessionChain Revoke(DateTimeOffset at) + public UAuthSessionChain Touch(DateTimeOffset now, ClaimsSnapshot? claimsSnapshot = null) { - if (IsRevoked) + if (IsRevoked || IsExpired(now)) return this; return new UAuthSessionChain( @@ -109,12 +180,41 @@ public UAuthSessionChain Revoke(DateTimeOffset at) RootId, Tenant, UserKey, + CreatedAt, + lastSeenAt: now, + AbsoluteExpiresAt, + Device, + claimsSnapshot ?? ClaimsSnapshot, + ActiveSessionId, RotationCount, + TouchCount + 1, SecurityVersionAtCreation, + RevokedAt, + Version + 1 + ); + } + + public UAuthSessionChain Revoke(DateTimeOffset now) + { + if (IsRevoked) + return this; + + return new UAuthSessionChain( + ChainId, + RootId, + Tenant, + UserKey, + CreatedAt, + now, + AbsoluteExpiresAt, + Device, ClaimsSnapshot, ActiveSessionId, - isRevoked: true, - revokedAt: at + RotationCount, + TouchCount, + SecurityVersionAtCreation, + revokedAt: now, + Version + 1 ); } @@ -123,25 +223,50 @@ internal static UAuthSessionChain FromProjection( SessionRootId rootId, TenantKey tenant, UserKey userKey, - int rotationCount, - long securityVersionAtCreation, + DateTimeOffset createdAt, + DateTimeOffset lastSeenAt, + DateTimeOffset? expiresAt, + DeviceContext device, ClaimsSnapshot claimsSnapshot, AuthSessionId? activeSessionId, - bool isRevoked, - DateTimeOffset? revokedAt) + int rotationCount, + int touchCount, + long securityVersionAtCreation, + DateTimeOffset? revokedAt, + long version) { return new UAuthSessionChain( - chainId, - rootId, - tenant, - userKey, - rotationCount, - securityVersionAtCreation, - claimsSnapshot, - activeSessionId, - isRevoked, - revokedAt - ); + chainId, + rootId, + tenant, + userKey, + createdAt, + lastSeenAt, + expiresAt, + device, + claimsSnapshot, + activeSessionId, + rotationCount, + touchCount, + securityVersionAtCreation, + revokedAt, + version + ); } + private bool IsExpired(DateTimeOffset at) => AbsoluteExpiresAt.HasValue && at >= AbsoluteExpiresAt.Value; + + public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout) + { + if (IsRevoked) + return SessionState.Revoked; + + if (IsExpired(at)) + return SessionState.Expired; + + if (idleTimeout.HasValue && at - LastSeenAt >= idleTimeout.Value) + return SessionState.Expired; + + return SessionState.Active; + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs index 3eb85942..0ae69287 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs @@ -1,73 +1,79 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Domain; -public sealed class UAuthSessionRoot +public sealed class UAuthSessionRoot : IVersionedEntity { public SessionRootId RootId { get; } - public UserKey UserKey { get; } public TenantKey Tenant { get; } + public UserKey UserKey { get; } + + public DateTimeOffset CreatedAt { get; } + public DateTimeOffset? UpdatedAt { get; } + public bool IsRevoked { get; } public DateTimeOffset? RevokedAt { get; } + public long SecurityVersion { get; } - public IReadOnlyList Chains { get; } - public DateTimeOffset LastUpdatedAt { get; } + public long Version { get; set; } private UAuthSessionRoot( SessionRootId rootId, TenantKey tenant, UserKey userKey, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt, bool isRevoked, DateTimeOffset? revokedAt, long securityVersion, - IReadOnlyList chains, - DateTimeOffset lastUpdatedAt) + long version) { RootId = rootId; Tenant = tenant; UserKey = userKey; + CreatedAt = createdAt; + UpdatedAt = updatedAt; IsRevoked = isRevoked; RevokedAt = revokedAt; SecurityVersion = securityVersion; - Chains = chains; - LastUpdatedAt = lastUpdatedAt; + Version = version; } public static UAuthSessionRoot Create( TenantKey tenant, UserKey userKey, - DateTimeOffset issuedAt) + DateTimeOffset at) { return new UAuthSessionRoot( SessionRootId.New(), tenant, userKey, - isRevoked: false, - revokedAt: null, - securityVersion: 0, - chains: Array.Empty(), - lastUpdatedAt: issuedAt + at, + null, + false, + null, + 0, + 0 ); } - public UAuthSessionRoot Revoke(DateTimeOffset at) + public UAuthSessionRoot IncreaseSecurityVersion(DateTimeOffset at) { - if (IsRevoked) - return this; - return new UAuthSessionRoot( RootId, Tenant, UserKey, - isRevoked: true, - revokedAt: at, - securityVersion: SecurityVersion, - chains: Chains, - lastUpdatedAt: at + CreatedAt, + at, + IsRevoked, + RevokedAt, + SecurityVersion + 1, + Version + 1 ); } - public UAuthSessionRoot AttachChain(UAuthSessionChain chain, DateTimeOffset at) + public UAuthSessionRoot Revoke(DateTimeOffset at) { if (IsRevoked) return this; @@ -76,11 +82,12 @@ public UAuthSessionRoot AttachChain(UAuthSessionChain chain, DateTimeOffset at) RootId, Tenant, UserKey, - IsRevoked, - RevokedAt, - SecurityVersion, - Chains.Concat(new[] { chain }).ToArray(), - at + CreatedAt, + at, + true, + at, + SecurityVersion + 1, + Version + 1 ); } @@ -88,23 +95,23 @@ internal static UAuthSessionRoot FromProjection( SessionRootId rootId, TenantKey tenant, UserKey userKey, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt, bool isRevoked, DateTimeOffset? revokedAt, long securityVersion, - IReadOnlyList chains, - DateTimeOffset lastUpdatedAt) + long version) { return new UAuthSessionRoot( rootId, tenant, userKey, + createdAt, + updatedAt, isRevoked, revokedAt, securityVersion, - chains, - lastUpdatedAt + version ); } - - } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs index 31cb67eb..bd21a678 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.ComponentModel.DataAnnotations.Schema; namespace CodeBeam.UltimateAuth.Core.Domain; @@ -7,7 +8,7 @@ namespace CodeBeam.UltimateAuth.Core.Domain; /// Represents a persisted refresh token bound to a session. /// Stored as a hashed value for security reasons. /// -public sealed record StoredRefreshToken +public sealed record StoredRefreshToken : IVersionedEntity { public string TokenHash { get; init; } = default!; @@ -24,6 +25,8 @@ public sealed record StoredRefreshToken public string? ReplacedByTokenHash { get; init; } + public long Version { get; set; } + [NotMapped] public bool IsRevoked => RevokedAt.HasValue; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs index 6e042d46..8629eb13 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs @@ -4,6 +4,7 @@ public sealed record UserRuntimeRecord { public UserKey UserKey { get; init; } public bool IsActive { get; init; } + public bool CanAuthenticate { get; init; } public bool IsDeleted { get; init; } public bool Exists { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConcurrencyException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConcurrencyException.cs new file mode 100644 index 00000000..c5d03c9d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConcurrencyException.cs @@ -0,0 +1,14 @@ +namespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthConcurrencyException : UAuthRuntimeException +{ + public override int StatusCode => 409; + + public override string Title => "The resource was modified by another process."; + + public override string TypePrefix => "https://docs.ultimateauth.com/errors/concurrency"; + + public UAuthConcurrencyException(string code = "concurrency_conflict") : base(code, code) + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs new file mode 100644 index 00000000..cc2468a0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs @@ -0,0 +1,14 @@ +namespace CodeBeam.UltimateAuth.Core.Errors; + +public class UAuthNotFoundException : UAuthRuntimeException +{ + public override int StatusCode => 400; + + public override string Title => "The resource is not found."; + + public override string TypePrefix => "https://docs.ultimateauth.com/errors/notfound"; + + public UAuthNotFoundException(string code = "resource_not_found") : base(code, code) + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthNotFoundException.cs deleted file mode 100644 index 7adef0fa..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthNotFoundException.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Errors; - -public sealed class UAuthNotFoundException : UAuthRuntimeException -{ - public UAuthNotFoundException(string code) : base(code, "Resource not found.") - { - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs index 18bebe32..f0c18bf8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs @@ -29,13 +29,24 @@ public sealed class UAuthLoginOptions /// /// This property defines the window of time used to evaluate consecutive failed login attempts. /// If the number of failures within this window exceeds the configured threshold, the account may be locked out. - /// Adjusting this value affects how quickly lockout conditions are triggered. + /// Adjusting this value affects how quickly lockout conditions are triggered. + /// public TimeSpan FailureWindow { get; set; } = TimeSpan.FromMinutes(15); + /// + /// Gets or sets a value indicating whether the lock should be extended when a login attempt fails during lockout. + /// + /// Set this property to to automatically extend the lock duration after + /// each failed login attempt. This can help prevent repeated unauthorized access attempts by increasing the lockout period. + /// + public bool ExtendLockOnFailure { get; set; } = false; + internal UAuthLoginOptions Clone() => new() { MaxFailedAttempts = MaxFailedAttempts, LockoutDuration = LockoutDuration, - IncludeFailureDetails = IncludeFailureDetails + IncludeFailureDetails = IncludeFailureDetails, + FailureWindow = FailureWindow, + ExtendLockOnFailure = ExtendLockOnFailure }; } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs index 007cad76..1514da03 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs @@ -59,10 +59,6 @@ public sealed class UAuthSessionOptions /// /// Maximum number of session rotations within a single chain. /// Used for cleanup, replay protection, and analytics. - /// - /// NOTE: - /// Enforcement is not active in v0.0.1. - /// This option is reserved for future security policies. /// public int MaxSessionsPerChain { get; set; } = 100; diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs index 11e5e962..b364a558 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Server.Abstractions; public interface ICredentialResponseWriter { - void Write(HttpContext context, CredentialKind kind, AuthSessionId sessionId); - void Write(HttpContext context, CredentialKind kind, AccessToken accessToken); - void Write(HttpContext context, CredentialKind kind, RefreshToken refreshToken); + void Write(HttpContext context, GrantKind kind, AuthSessionId sessionId); + void Write(HttpContext context, GrantKind kind, AccessToken accessToken); + void Write(HttpContext context, GrantKind kind, RefreshToken refreshToken); } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs index 52d54c7e..d4bfbfca 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs @@ -5,5 +5,5 @@ namespace CodeBeam.UltimateAuth.Server.Abstractions; public interface IPrimaryCredentialResolver { - PrimaryCredentialKind Resolve(HttpContext context); + PrimaryGrantKind Resolve(HttpContext context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs index 84bdbcb1..76e977f3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs @@ -6,13 +6,15 @@ namespace CodeBeam.UltimateAuth.Server.Auth { internal sealed class AuthStateSnapshotFactory : IAuthStateSnapshotFactory { - private readonly IPrimaryUserIdentifierProvider _identifierProvider; - private readonly IUserProfileSnapshotProvider _profileSnapshotProvider; + private readonly IPrimaryUserIdentifierProvider _identifier; + private readonly IUserProfileSnapshotProvider _profile; + private readonly IUserLifecycleSnapshotProvider _lifecycle; - public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifierProvider, IUserProfileSnapshotProvider profileSnapshotProvider) + public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifier, IUserProfileSnapshotProvider profile, IUserLifecycleSnapshotProvider lifecycle) { - _identifierProvider = identifierProvider; - _profileSnapshotProvider = profileSnapshotProvider; + _identifier = identifier; + _profile = profile; + _lifecycle = lifecycle; } public async Task CreateAsync(SessionValidationResult validation, CancellationToken ct = default) @@ -20,8 +22,9 @@ public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifierProvide if (!validation.IsValid || validation.UserKey is null) return null; - var identifiers = await _identifierProvider.GetAsync(validation.Tenant, validation.UserKey.Value, ct); - var profile = await _profileSnapshotProvider.GetAsync(validation.Tenant, validation.UserKey.Value, ct); + var identifiers = await _identifier.GetAsync(validation.Tenant, validation.UserKey.Value, ct); + var profile = await _profile.GetAsync(validation.Tenant, validation.UserKey.Value, ct); + var lifecycle = await _lifecycle.GetAsync(validation.Tenant, validation.UserKey.Value, ct); var identity = new AuthIdentitySnapshot { @@ -33,7 +36,8 @@ public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifierProvide DisplayName = profile?.DisplayName, TimeZone = profile?.TimeZone, AuthenticatedAt = validation.AuthenticatedAt, - SessionState = validation.State + SessionState = validation.State, + UserStatus = lifecycle?.Status ?? UserStatus.Unknown }; return new AuthStateSnapshot diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs index cd5c4e55..df660fde 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs @@ -45,8 +45,10 @@ private async Task CreateInternalAsync(AuthFlowContext authFlow, if (authFlow.IsAuthenticated && authFlow.UserKey is not null) { - var roles = await _roleStore.GetRolesAsync(authFlow.Tenant, authFlow.UserKey.Value, ct); - attrs["roles"] = roles; + var assignments = await _roleStore.GetAssignmentsAsync(authFlow.Tenant, authFlow.UserKey.Value, ct); + var roleIds = assignments.Select(x => x.RoleId).ToArray(); + + attrs["roles"] = roleIds; } UserKey? targetUserKey = null; @@ -67,6 +69,7 @@ private async Task CreateInternalAsync(AuthFlowContext authFlow, actorTenant: authFlow.Tenant, isAuthenticated: authFlow.IsAuthenticated, isSystemActor: authFlow.Tenant.IsSystem, + actorChainId: authFlow.Session?.ChainId, resource: resource, targetUserKey: targetUserKey, resourceTenant: resourceTenant, diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs index cc3da3d8..acf78ac1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs @@ -26,21 +26,21 @@ private static UAuthResponseOptions PureOpaque(AuthFlowType flow) SessionIdDelivery = new() { Name = "uas", - Kind = CredentialKind.Session, + Kind = GrantKind.Session, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Cookie, }, AccessTokenDelivery = new() { Name = "uat", - Kind = CredentialKind.AccessToken, + Kind = GrantKind.AccessToken, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.None }, RefreshTokenDelivery = new() { Name = "uar", - Kind = CredentialKind.RefreshToken, + Kind = GrantKind.RefreshToken, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.None }, @@ -62,21 +62,21 @@ private static UAuthResponseOptions Hybrid(AuthFlowType flow) SessionIdDelivery = new() { Name = "uas", - Kind = CredentialKind.Session, + Kind = GrantKind.Session, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Cookie }, AccessTokenDelivery = new() { Name = "uat", - Kind = CredentialKind.AccessToken, + Kind = GrantKind.AccessToken, TokenFormat = TokenFormat.Jwt, Mode = TokenResponseMode.Header }, RefreshTokenDelivery = new() { Name = "uar", - Kind = CredentialKind.RefreshToken, + Kind = GrantKind.RefreshToken, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Cookie }, @@ -98,21 +98,21 @@ private static UAuthResponseOptions SemiHybrid(AuthFlowType flow) SessionIdDelivery = new() { Name = "uas", - Kind = CredentialKind.Session, + Kind = GrantKind.Session, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.None }, AccessTokenDelivery = new() { Name = "uat", - Kind = CredentialKind.AccessToken, + Kind = GrantKind.AccessToken, TokenFormat = TokenFormat.Jwt, Mode = TokenResponseMode.Header }, RefreshTokenDelivery = new() { Name = "uar", - Kind = CredentialKind.RefreshToken, + Kind = GrantKind.RefreshToken, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Header }, @@ -134,21 +134,21 @@ private static UAuthResponseOptions PureJwt(AuthFlowType flow) SessionIdDelivery = new() { Name = "uas", - Kind = CredentialKind.Session, + Kind = GrantKind.Session, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.None }, AccessTokenDelivery = new() { Name = "uat", - Kind = CredentialKind.AccessToken, + Kind = GrantKind.AccessToken, TokenFormat = TokenFormat.Jwt, Mode = TokenResponseMode.Header }, RefreshTokenDelivery = new() { Name = "uar", - Kind = CredentialKind.RefreshToken, + Kind = GrantKind.RefreshToken, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Header }, diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs index 8ae5fcbd..7d5bedd6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs @@ -54,9 +54,9 @@ private static CredentialResponseOptions Bind(CredentialResponseOptions delivery var cookie = delivery.Kind switch { - CredentialKind.Session => server.Cookie.Session, - CredentialKind.AccessToken => server.Cookie.AccessToken, - CredentialKind.RefreshToken => server.Cookie.RefreshToken, + GrantKind.Session => server.Cookie.Session, + GrantKind.AccessToken => server.Cookie.AccessToken, + GrantKind.RefreshToken => server.Cookie.RefreshToken, _ => throw new InvalidOperationException($"Unsupported credential kind: {delivery.Kind}") }; diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs index a9726933..cb64e072 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs @@ -14,9 +14,9 @@ public UAuthResponseOptions Adapt(UAuthResponseOptions template, UAuthClientProf return new UAuthResponseOptions { - SessionIdDelivery = AdaptCredential(template.SessionIdDelivery, CredentialKind.Session, clientProfile), - AccessTokenDelivery = AdaptCredential(template.AccessTokenDelivery, CredentialKind.AccessToken, clientProfile), - RefreshTokenDelivery = AdaptCredential(template.RefreshTokenDelivery, CredentialKind.RefreshToken, clientProfile), + SessionIdDelivery = AdaptCredential(template.SessionIdDelivery, GrantKind.Session, clientProfile), + AccessTokenDelivery = AdaptCredential(template.AccessTokenDelivery, GrantKind.AccessToken, clientProfile), + RefreshTokenDelivery = AdaptCredential(template.RefreshTokenDelivery, GrantKind.RefreshToken, clientProfile), Login = MergeLogin(template.Login, configured.Login), Logout = MergeLogout(template.Logout, configured.Logout) @@ -27,7 +27,7 @@ public UAuthResponseOptions Adapt(UAuthResponseOptions template, UAuthClientProf // effectiveMode and effectiveOptions are intentionally passed // to keep this adapter policy-extensible. // They will be used for future mode/option based response enforcement. - private static CredentialResponseOptions AdaptCredential(CredentialResponseOptions original, CredentialKind kind, UAuthClientProfile clientProfile) + private static CredentialResponseOptions AdaptCredential(CredentialResponseOptions original, GrantKind kind, UAuthClientProfile clientProfile) { if (clientProfile == UAuthClientProfile.Maui && original.Mode == TokenResponseMode.Cookie) { diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs rename to src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs index 59463779..5a8e66ff 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Server.Defaults; +using CodeBeam.UltimateAuth.Core.Defaults; using Microsoft.AspNetCore.Authentication; namespace CodeBeam.UltimateAuth.Server.Authentication; diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs similarity index 99% rename from src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs rename to src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs index 4072266a..716f01d0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs @@ -3,7 +3,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; -using CodeBeam.UltimateAuth.Server.Defaults; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationSchemeOptions.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationSchemeOptions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationSchemeOptions.cs rename to src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationSchemeOptions.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs new file mode 100644 index 00000000..5c531ec4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs @@ -0,0 +1,61 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Security; + +internal sealed class AuthenticationSecurityManager : IAuthenticationSecurityManager +{ + private readonly IAuthenticationSecurityStateStore _store; + private readonly UAuthServerOptions _options; + + public AuthenticationSecurityManager(IAuthenticationSecurityStateStore store, IOptions options) + { + _store = store; + _options = options.Value; + } + + public async Task GetOrCreateAccountAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var state = await _store.GetAsync(tenant, userKey, AuthenticationSecurityScope.Account, credentialType: null, ct); + + if (state is not null) + return state; + + var created = AuthenticationSecurityState.CreateAccount(tenant, userKey); + await _store.AddAsync(created, ct); + return created; + } + + public async Task GetOrCreateFactorAsync(TenantKey tenant, UserKey userKey, CredentialType type, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var state = await _store.GetAsync(tenant, userKey, AuthenticationSecurityScope.Factor, type, ct); + + if (state is not null) + return state; + + var created = AuthenticationSecurityState.CreateFactor(tenant, userKey, type); + await _store.AddAsync(created, ct); + return created; + } + + public Task UpdateAsync(AuthenticationSecurityState updated, long expectedVersion, CancellationToken ct = default) + { + return _store.UpdateAsync(updated, expectedVersion, ct); + } + + public Task DeleteAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return _store.DeleteAsync(tenant, userKey, scope, credentialType, ct); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs index db88f962..8370bce1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs +++ b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs @@ -15,7 +15,7 @@ public static IServiceCollection Build(this UltimateAuthServerBuilder builder) //if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(IUAuthUserStore<>)))) // throw new InvalidOperationException("No credential store registered."); - if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(ISessionStoreKernel)))) + if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(ISessionStore)))) throw new InvalidOperationException("No session store registered."); return services; diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs index 1d7a15d0..98c509f8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Server.Contracts; public sealed record ResolvedCredential { - public PrimaryCredentialKind Kind { get; init; } + public PrimaryGrantKind Kind { get; init; } /// /// Raw credential value (session id / jwt / opaque) diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs index 2844105f..43c65375 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Endpoints; @@ -10,4 +11,10 @@ public interface IAuthorizationEndpointHandler Task GetUserRolesAsync(UserKey userKey, HttpContext ctx); Task AssignRoleAsync(UserKey userKey, HttpContext ctx); Task RemoveRoleAsync(UserKey userKey, HttpContext ctx); + + Task CreateRoleAsync(HttpContext ctx); + Task RenameRoleAsync(RoleId roleId, HttpContext ctx); + Task DeleteRoleAsync(RoleId roleId, HttpContext ctx); + Task SetRolePermissionsAsync(RoleId roleId, HttpContext ctx); + Task QueryRolesAsync(HttpContext ctx); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs index a788a7b5..50a318d2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs @@ -7,16 +7,15 @@ public interface ICredentialEndpointHandler { Task GetAllAsync(HttpContext ctx); Task AddAsync(HttpContext ctx); - Task ChangeAsync(string type, HttpContext ctx); - Task RevokeAsync(string type, HttpContext ctx); - Task BeginResetAsync(string type, HttpContext ctx); - Task CompleteResetAsync(string type, HttpContext ctx); + Task ChangeSecretAsync(HttpContext ctx); + Task RevokeAsync(HttpContext ctx); + Task BeginResetAsync(HttpContext ctx); + Task CompleteResetAsync(HttpContext ctx); Task GetAllAdminAsync(UserKey userKey, HttpContext ctx); Task AddAdminAsync(UserKey userKey, HttpContext ctx); - Task RevokeAdminAsync(UserKey userKey, string type, HttpContext ctx); - Task ActivateAdminAsync(UserKey userKey, string type, HttpContext ctx); - Task DeleteAdminAsync(UserKey userKey, string type, HttpContext ctx); - Task BeginResetAdminAsync(UserKey userKey, string type, HttpContext ctx); - Task CompleteResetAdminAsync(UserKey userKey, string type, HttpContext ctx); + Task RevokeAdminAsync(UserKey userKey, HttpContext ctx); + Task DeleteAdminAsync(UserKey userKey, HttpContext ctx); + Task BeginResetAdminAsync(UserKey userKey, HttpContext ctx); + Task CompleteResetAdminAsync(UserKey userKey, HttpContext ctx); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs index 424560f2..583229f0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs @@ -1,8 +1,16 @@ -using Microsoft.AspNetCore.Http; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Endpoints; public interface ILogoutEndpointHandler { Task LogoutAsync(HttpContext ctx); + Task LogoutDeviceSelfAsync(HttpContext ctx); + Task LogoutOthersSelfAsync(HttpContext ctx); + Task LogoutAllSelfAsync(HttpContext ctx); + + Task LogoutDeviceAdminAsync(HttpContext ctx, UserKey userKey); + Task LogoutOthersAdminAsync(HttpContext ctx, UserKey userKey); + Task LogoutAllAdminAsync(HttpContext ctx, UserKey userKey); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionEndpointHandler.cs new file mode 100644 index 00000000..31681539 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionEndpointHandler.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface ISessionEndpointHandler +{ + Task GetMyChainsAsync(HttpContext ctx); + Task GetMyChainDetailAsync(SessionChainId chainId, HttpContext ctx); + Task RevokeMyChainAsync(SessionChainId chainId, HttpContext ctx); + Task RevokeOtherChainsAsync(HttpContext ctx); + Task RevokeAllMyChainsAsync(HttpContext ctx); + + Task GetUserChainsAsync(UserKey userKey, HttpContext ctx); + Task GetUserChainDetailAsync(UserKey userKey, SessionChainId chainId, HttpContext ctx); + Task RevokeUserSessionAsync(UserKey userKey, AuthSessionId sessionId, HttpContext ctx); + Task RevokeUserChainAsync(UserKey userKey, SessionChainId chainId, HttpContext ctx); + Task RevokeAllChainsAsync(UserKey userKey, SessionChainId? exceptChainId, HttpContext ctx); + Task RevokeRootAsync(UserKey userKey, HttpContext ctx); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs deleted file mode 100644 index a4bcd598..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints; - -public interface ISessionManagementHandler -{ - Task GetCurrentSessionAsync(HttpContext ctx); - Task GetAllSessionsAsync(HttpContext ctx); - Task RevokeSessionAsync(string sessionId, HttpContext ctx); - Task RevokeAllAsync(HttpContext ctx); -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs index e543a959..df97b972 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs @@ -8,6 +8,7 @@ public interface IUserEndpointHandler Task CreateAsync(HttpContext ctx); Task ChangeStatusSelfAsync(HttpContext ctx); Task ChangeStatusAdminAsync(UserKey userKey, HttpContext ctx); + Task DeleteMeAsync(HttpContext ctx); Task DeleteAsync(UserKey userKey, HttpContext ctx); Task GetMeAsync(HttpContext ctx); @@ -17,6 +18,7 @@ public interface IUserEndpointHandler Task UpdateUserAsync(UserKey userKey, HttpContext ctx); Task GetMyIdentifiersAsync(HttpContext ctx); + Task IdentifierExistsSelfAsync(HttpContext ctx); Task AddUserIdentifierSelfAsync(HttpContext ctx); Task UpdateUserIdentifierSelfAsync(HttpContext ctx); Task SetPrimaryUserIdentifierSelfAsync(HttpContext ctx); @@ -25,6 +27,7 @@ public interface IUserEndpointHandler Task DeleteUserIdentifierSelfAsync(HttpContext ctx); Task GetUserIdentifiersAsync(UserKey userKey, HttpContext ctx); + Task IdentifierExistsAdminAsync(UserKey userKey, HttpContext ctx); Task AddUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); Task UpdateUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); Task SetPrimaryUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs index 4a499d2d..e3b0f3e6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs @@ -58,7 +58,6 @@ public async Task LoginAsync(HttpContext ctx) Secret = secret, Tenant = authFlow.Tenant, At = _clock.UtcNow, - Device = authFlow.Device, RequestTokens = authFlow.AllowsTokenIssuance }; @@ -75,17 +74,17 @@ public async Task LoginAsync(HttpContext ctx) if (result.SessionId is AuthSessionId sessionId) { - _credentialResponseWriter.Write(ctx, CredentialKind.Session, sessionId); + _credentialResponseWriter.Write(ctx, GrantKind.Session, sessionId); } if (result.AccessToken is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken); + _credentialResponseWriter.Write(ctx, GrantKind.AccessToken, result.AccessToken); } if (result.RefreshToken is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); + _credentialResponseWriter.Write(ctx, GrantKind.RefreshToken, result.RefreshToken); } var decision = _redirectResolver.ResolveSuccess(authFlow, ctx); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs index 15d01de7..69de30c4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs @@ -1,10 +1,14 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Endpoints; @@ -13,14 +17,18 @@ public sealed class LogoutEndpointHandler : ILogoutEndpointHandler { private readonly IAuthFlowContextAccessor _authContext; private readonly IUAuthFlowService _flow; + private readonly IAccessContextFactory _accessContextFactory; + private readonly ISessionApplicationService _sessionApplicationService; private readonly IClock _clock; private readonly IUAuthCookieManager _cookieManager; private readonly IAuthRedirectResolver _redirectResolver; - public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IClock clock, IUAuthCookieManager cookieManager, IAuthRedirectResolver redirectResolver) + public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IAccessContextFactory accessContextFactory, ISessionApplicationService sessionApplicationService, IClock clock, IUAuthCookieManager cookieManager, IAuthRedirectResolver redirectResolver) { _authContext = authContext; _flow = flow; + _accessContextFactory = accessContextFactory; + _sessionApplicationService = sessionApplicationService; _clock = clock; _cookieManager = cookieManager; _redirectResolver = redirectResolver; @@ -53,6 +61,120 @@ public async Task LogoutAsync(HttpContext ctx) : Results.Ok(new LogoutResponse { Success = true }); } + public async Task LogoutDeviceSelfAsync(HttpContext ctx) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + if (flow.UserKey is not UserKey userKey) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutDeviceSelf, + resource: "flows", + resourceId: userKey.Value); + + var result = await _sessionApplicationService.LogoutDeviceAsync(access, request.ChainId, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task LogoutDeviceAdminAsync(HttpContext ctx, UserKey userKey) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutDeviceAdmin, + resource: "flows", + resourceId: request.UserKey.Value); + + await _sessionApplicationService.LogoutDeviceAsync(access, request.ChainId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task LogoutOthersSelfAsync(HttpContext ctx) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + if (flow.UserKey is not UserKey userKey) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutOthersSelf, + resource: "flows", + resourceId: userKey.Value); + + if (access.ActorChainId is not SessionChainId chainId) + return Results.Unauthorized(); + + await _sessionApplicationService.LogoutOtherDevicesAsync(access, userKey, chainId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task LogoutOthersAdminAsync(HttpContext ctx, UserKey userKey) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutOthersAdmin, + resource: "flows", + resourceId: request.UserKey.Value); + + await _sessionApplicationService.LogoutOtherDevicesAsync(access, userKey, request.CurrentChainId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task LogoutAllSelfAsync(HttpContext ctx) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + if (flow.UserKey is not UserKey userKey) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutAllSelf, + resource: "flows", + resourceId: userKey); + + await _sessionApplicationService.LogoutAllDevicesAsync(access, userKey, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task LogoutAllAdminAsync(HttpContext ctx, UserKey userKey) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutAllAdmin, + resource: "flows", + resourceId: userKey.Value); + + await _sessionApplicationService.LogoutAllDevicesAsync(access, userKey, ctx.RequestAborted); + return Results.Ok(); + } + private void DeleteIfCookie(HttpContext ctx, CredentialResponseOptions delivery) { if (delivery.Mode != TokenResponseMode.Cookie) diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs index 195ae018..b5bbd9e3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs @@ -129,7 +129,6 @@ public async Task CompleteAsync(HttpContext ctx) Secret = request.Secret, Tenant = authContext.Tenant, At = _clock.UtcNow, - Device = authContext.Device, RequestTokens = authContext.AllowsTokenIssuance }; @@ -145,17 +144,17 @@ public async Task CompleteAsync(HttpContext ctx) if (result.SessionId is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.Session, result.SessionId.Value); + _credentialResponseWriter.Write(ctx, GrantKind.Session, result.SessionId.Value); } if (result.AccessToken is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken); + _credentialResponseWriter.Write(ctx, GrantKind.AccessToken, result.AccessToken); } if (result.RefreshToken is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); + _credentialResponseWriter.Write(ctx, GrantKind.RefreshToken, result.RefreshToken); } var decision = _redirectResolver.ResolveSuccess(authContext, ctx); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs index d4c3df38..1d3c3f3a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs @@ -59,18 +59,18 @@ public async Task RefreshAsync(HttpContext ctx) var primary = _refreshPolicy.SelectPrimary(flow, request, result); - if (primary == CredentialKind.Session && result.SessionId is not null) + if (primary == GrantKind.Session && result.SessionId is not null) { - _credentialWriter.Write(ctx, CredentialKind.Session, result.SessionId.Value); + _credentialWriter.Write(ctx, GrantKind.Session, result.SessionId.Value); } - else if (primary == CredentialKind.AccessToken && result.AccessToken is not null) + else if (primary == GrantKind.AccessToken && result.AccessToken is not null) { - _credentialWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken); + _credentialWriter.Write(ctx, GrantKind.AccessToken, result.AccessToken); } if (_refreshPolicy.WriteRefreshToken(flow) && result.RefreshToken is not null) { - _credentialWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); + _credentialWriter.Write(ctx, GrantKind.RefreshToken, result.RefreshToken); } if (flow.OriginalOptions.Diagnostics.EnableRefreshDetails) diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs new file mode 100644 index 00000000..3fb62d3e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs @@ -0,0 +1,204 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Services; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +internal sealed class SessionEndpointHandler : ISessionEndpointHandler +{ + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAccessContextFactory _accessContextFactory; + private readonly ISessionApplicationService _sessions; + + public SessionEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, ISessionApplicationService sessions) + { + _authFlow = authFlow; + _accessContextFactory = accessContextFactory; + _sessions = sessions; + } + + public async Task GetMyChainsAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted) ?? new PageRequest(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.ListChainsSelf, + resource: "sessions", + resourceId: flow.UserKey?.Value); + + var result = await _sessions.GetUserChainsAsync(access, flow.UserKey!.Value, request, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task GetMyChainDetailAsync(SessionChainId chainId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.GetChainSelf, + resource: "sessions", + resourceId: flow.UserKey?.Value); + + var result = await _sessions.GetUserChainDetailAsync(access, flow.UserKey!.Value, chainId, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task RevokeMyChainAsync(SessionChainId chainId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeChainSelf, + resource: "sessions", + resourceId: flow.UserKey?.Value); + + var result = await _sessions.RevokeUserChainAsync(access, flow.UserKey!.Value, chainId, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task RevokeOtherChainsAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated || flow.Session == null) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeOtherChainsSelf, + resource: "sessions", + resourceId: flow.UserKey?.Value); + + await _sessions.RevokeOtherChainsAsync(access, flow.UserKey!.Value, flow.Session.ChainId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task RevokeAllMyChainsAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeAllChainsSelf, + resource: "sessions", + resourceId: flow.UserKey?.Value); + + await _sessions.RevokeAllChainsAsync(access, flow.UserKey!.Value, null, ctx.RequestAborted); + return Results.Ok(); + } + + + public async Task GetUserChainsAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted) ?? new PageRequest(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.ListChainsAdmin, + resource: "sessions", + resourceId: userKey.Value); + + var result = await _sessions.GetUserChainsAsync(access, userKey, request, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task GetUserChainDetailAsync(UserKey userKey, SessionChainId chainId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.GetChainAdmin, + resource: "sessions", + resourceId: userKey.Value); + + var result = await _sessions.GetUserChainDetailAsync(access, userKey, chainId, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task RevokeUserSessionAsync(UserKey userKey, AuthSessionId sessionId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeSessionAdmin, + resource: "sessions", + resourceId: userKey.Value); + + await _sessions.RevokeUserSessionAsync(access, userKey, sessionId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task RevokeUserChainAsync(UserKey userKey, SessionChainId chainId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeChainAdmin, + resource: "sessions", + resourceId: userKey.Value); + + var result = await _sessions.RevokeUserChainAsync(access, userKey, chainId, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task RevokeAllChainsAsync(UserKey userKey, SessionChainId? exceptChainId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeAllChainsAdmin, + resource: "sessions", + resourceId: userKey.Value); + + await _sessions.RevokeAllChainsAsync(access, userKey, exceptChainId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task RevokeRootAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeRootAdmin, + resource: "sessions", + resourceId: userKey.Value); + + await _sessions.RevokeRootAsync(access, userKey, ctx.RequestAborted); + return Results.Ok(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 929cd276..14604617 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -1,10 +1,14 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Server.Endpoints; @@ -12,6 +16,13 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints; // TODO: Add endpoint based guards public class UAuthEndpointRegistrar : IAuthEndpointRegistrar { + private readonly UAuthServerEndpointOptions _options; + public UAuthEndpointRegistrar(IOptions options) + { + _options = options.Value.Endpoints; + } + + bool Enabled(string action) => !_options.DisabledActions.Contains(action); // NOTE: // All endpoints intentionally use POST to avoid caching, @@ -28,6 +39,10 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options group.AddEndpointFilter(); + //var user = group.MapGroup(""); + var users = group.MapGroup("/users"); + var adminUsers = group.MapGroup("/admin/users"); + if (options.Endpoints.Login != false) { group.MapPost("/login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) @@ -36,14 +51,40 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options group.MapPost("/validate", async ([FromServices] IValidateEndpointHandler h, HttpContext ctx) => await h.ValidateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.ValidateSession)); - group.MapPost("/logout", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.Flows.LogoutSelf)) + group.MapPost("/logout", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) => await h.LogoutAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + if (Enabled(UAuthActions.Flows.LogoutDeviceSelf)) + group.MapPost("/logout-device", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) + => await h.LogoutDeviceSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + + if (Enabled(UAuthActions.Flows.LogoutOthersSelf)) + group.MapPost("/logout-others", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) + => await h.LogoutOthersSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + + if (Enabled(UAuthActions.Flows.LogoutAllSelf)) + group.MapPost("/logout-all", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) + => await h.LogoutAllSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + group.MapPost("/refresh", async ([FromServices] IRefreshEndpointHandler h, HttpContext ctx) => await h.RefreshAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshSession)); group.MapPost("/reauth", async ([FromServices] IReauthEndpointHandler h, HttpContext ctx) => await h.ReauthAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Reauthentication)); + + + if (Enabled(UAuthActions.Flows.LogoutDeviceAdmin)) + adminUsers.MapPost("/logout-device/{userKey}", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.LogoutDeviceAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + + if (Enabled(UAuthActions.Flows.LogoutOthersAdmin)) + adminUsers.MapPost("/logout-others/{userKey}", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.LogoutOthersAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + + if (Enabled(UAuthActions.Flows.LogoutAllAdmin)) + adminUsers.MapPost("/logout-all/{userKey}", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.LogoutAllAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); } if (options.Endpoints.Pkce != false) @@ -57,43 +98,72 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.CompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); } - if (options.Endpoints.Token != false) - { - var token = group.MapGroup(""); + //if (options.Endpoints.Token != false) + //{ + // var token = group.MapGroup(""); - token.MapPost("/token", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) - => await h.GetTokenAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.IssueToken)); + // token.MapPost("/token", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) + // => await h.GetTokenAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.IssueToken)); - token.MapPost("/refresh-token", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) - => await h.RefreshTokenAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshToken)); + // token.MapPost("/refresh-token", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) + // => await h.RefreshTokenAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshToken)); - token.MapPost("/introspect", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) - => await h.IntrospectAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.IntrospectToken)); + // token.MapPost("/introspect", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) + // => await h.IntrospectAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.IntrospectToken)); - token.MapPost("/revoke", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) - => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeToken)); - } + // token.MapPost("/revoke", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) + // => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeToken)); + //} if (options.Endpoints.Session != false) { var session = group.MapGroup("/session"); - session.MapPost("/current", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) - => await h.GetCurrentSessionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + if(Enabled(UAuthActions.Sessions.ListChainsSelf)) + session.MapPost("/me/chains", async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) + => await h.GetMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); - session.MapPost("/list", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) - => await h.GetAllSessionsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + if (Enabled(UAuthActions.Sessions.GetChainSelf)) + session.MapPost("/me/chains/{chainId}", async ([FromServices] ISessionEndpointHandler h, SessionChainId chainId, HttpContext ctx) + => await h.GetMyChainDetailAsync(chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); - session.MapPost("/revoke/{sessionId}", async ([FromServices] ISessionManagementHandler h, string sessionId, HttpContext ctx) - => await h.RevokeSessionAsync(sessionId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + if (Enabled(UAuthActions.Sessions.RevokeChainSelf)) + session.MapPost("/me/chains/{chainId}/revoke", async ([FromServices] ISessionEndpointHandler h, SessionChainId chainId, HttpContext ctx) + => await h.RevokeMyChainAsync(chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); - session.MapPost("/revoke-all", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) - => await h.RevokeAllAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); - } + if (Enabled(UAuthActions.Sessions.RevokeOtherChainsSelf)) + session.MapPost("/me/revoke-others",async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) + => await h.RevokeOtherChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); - //var user = group.MapGroup(""); - var users = group.MapGroup("/users"); - var adminUsers = group.MapGroup("/admin/users"); + if (Enabled(UAuthActions.Sessions.RevokeAllChainsSelf)) + session.MapPost("/me/revoke-all", async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) + => await h.RevokeAllMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + + + if (Enabled(UAuthActions.Sessions.ListChainsAdmin)) + adminUsers.MapPost("/{userKey}/sessions/chains", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetUserChainsAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + + if (Enabled(UAuthActions.Sessions.GetChainAdmin)) + adminUsers.MapPost("/{userKey}/sessions/chains/{chainId}", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, SessionChainId chainId, HttpContext ctx) + => await h.GetUserChainDetailAsync(userKey, chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + + if (Enabled(UAuthActions.Sessions.RevokeSessionAdmin)) + adminUsers.MapPost("/{userKey}/sessions/{sessionId}/revoke", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, AuthSessionId sessionId, HttpContext ctx) + => await h.RevokeUserSessionAsync(userKey, sessionId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + + if (Enabled(UAuthActions.Sessions.RevokeChainAdmin)) + adminUsers.MapPost("/{userKey}/sessions/chains/{chainId}/revoke", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, SessionChainId chainId, HttpContext ctx) + => await h.RevokeUserChainAsync(userKey, chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + + if (Enabled(UAuthActions.Sessions.RevokeRootAdmin)) + adminUsers.MapPost("/{userKey}/sessions/revoke-root", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.RevokeRootAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + + if (Enabled(UAuthActions.Sessions.RevokeAllChainsAdmin)) + adminUsers.MapPost("/{userKey}/sessions/revoke-all", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.RevokeAllChainsAsync(userKey, null, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + } //if (options.EnableUserInfoEndpoints != false) //{ @@ -109,78 +179,105 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (options.Endpoints.UserLifecycle != false) { - users.MapPost("/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.Users.CreateAnonymous)) + users.MapPost("/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); - users.MapPost("/me/status", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.Users.ChangeStatusSelf)) + users.MapPost("/me/status", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.ChangeStatusSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); - adminUsers.MapPost("/{userKey}/status", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + if (Enabled(UAuthActions.Users.DeleteSelf)) + users.MapPost("/me/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.DeleteMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + + + if (Enabled(UAuthActions.Users.ChangeStatusAdmin)) + adminUsers.MapPost("/{userKey}/status", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.ChangeStatusAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); - // Post is intended for Auth - adminUsers.MapPost("/{userKey}/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + if (Enabled(UAuthActions.Users.DeleteAdmin)) + adminUsers.MapPost("/{userKey}/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.DeleteAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); } if (options.Endpoints.UserProfile != false) { - users.MapPost("/me/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.UserProfiles.GetSelf)) + users.MapPost("/me/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.GetMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); - users.MapPost("/me/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.UserProfiles.UpdateSelf)) + users.MapPost("/me/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.UpdateMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); - adminUsers.MapPost("/{userKey}/profile/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + + if (Enabled(UAuthActions.UserProfiles.GetAdmin)) + adminUsers.MapPost("/{userKey}/profile/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.GetUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); - adminUsers.MapPost("/{userKey}/profile/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + if (Enabled(UAuthActions.UserProfiles.UpdateAdmin)) + adminUsers.MapPost("/{userKey}/profile/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.UpdateUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); } if (options.Endpoints.UserIdentifier != false) { - users.MapPost("/me/identifiers/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.GetSelf)) + users.MapPost("/me/identifiers/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.GetMyIdentifiersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - users.MapPost("/me/identifiers/add", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.AddSelf)) + users.MapPost("/me/identifiers/add", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.AddUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - users.MapPost("/me/identifiers/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.UpdateSelf)) + users.MapPost("/me/identifiers/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.UpdateUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - users.MapPost("/me/identifiers/set-primary",async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.SetPrimarySelf)) + users.MapPost("/me/identifiers/set-primary",async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.SetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - users.MapPost("/me/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.UnsetPrimarySelf)) + users.MapPost("/me/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.UnsetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - users.MapPost("/me/identifiers/verify", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.VerifySelf)) + users.MapPost("/me/identifiers/verify", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.VerifyUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - users.MapPost("/me/identifiers/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.DeleteSelf)) + users.MapPost("/me/identifiers/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.DeleteUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - adminUsers.MapPost("/{userKey}/identifiers/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.GetAdmin)) + adminUsers.MapPost("/{userKey}/identifiers/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.GetUserIdentifiersAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - adminUsers.MapPost("/{userKey}/identifiers/add", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.AddAdmin)) + adminUsers.MapPost("/{userKey}/identifiers/add", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.AddUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - adminUsers.MapPost("/{userKey}/identifiers/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.UpdateAdmin)) + adminUsers.MapPost("/{userKey}/identifiers/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.UpdateUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - adminUsers.MapPost("/{userKey}/identifiers/set-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.SetPrimaryAdmin)) + adminUsers.MapPost("/{userKey}/identifiers/set-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.SetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - adminUsers.MapPost("/{userKey}/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.UnsetPrimaryAdmin)) + adminUsers.MapPost("/{userKey}/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.UnsetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - adminUsers.MapPost("/{userKey}/identifiers/verify", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.VerifyAdmin)) + adminUsers.MapPost("/{userKey}/identifiers/verify", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.VerifyUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - adminUsers.MapPost("/{userKey}/identifiers/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + if (Enabled(UAuthActions.UserIdentifiers.DeleteAdmin)) + adminUsers.MapPost("/{userKey}/identifiers/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.DeleteUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); } @@ -189,45 +286,46 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options var credentials = group.MapGroup("/credentials"); var adminCredentials = group.MapGroup("/admin/users/{userKey}/credentials"); - credentials.MapPost("/get", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) - => await h.GetAllAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - - credentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.Credentials.AddSelf)) + credentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) => await h.AddAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/{type}/change", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.ChangeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + if (Enabled(UAuthActions.Credentials.ChangeSelf)) + credentials.MapPost("/change", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.ChangeSecretAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.RevokeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + if (Enabled(UAuthActions.Credentials.RevokeSelf)) + credentials.MapPost("/revoke", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/{type}/reset/begin", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.BeginResetAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + if (Enabled(UAuthActions.Credentials.BeginResetAnonymous)) + credentials.MapPost("/reset/begin", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.BeginResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/{type}/reset/complete", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.CompleteResetAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + if (Enabled(UAuthActions.Credentials.CompleteResetAnonymous)) + credentials.MapPost("/reset/complete", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.CompleteResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/get", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetAllAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - - adminCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + if (Enabled(UAuthActions.Credentials.AddAdmin)) + adminCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.AddAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.RevokeAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - - adminCredentials.MapPost("/{type}/activate", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.ActivateAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + if (Enabled(UAuthActions.Credentials.RevokeAdmin)) + adminCredentials.MapPost("/revoke", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.RevokeAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/{type}/reset/begin", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.BeginResetAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + if (Enabled(UAuthActions.Credentials.BeginResetAdmin)) + adminCredentials.MapPost("/reset/begin", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.BeginResetAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/{type}/reset/complete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.CompleteResetAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + if (Enabled(UAuthActions.Credentials.CompleteResetAdmin)) + adminCredentials.MapPost("/reset/complete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.CompleteResetAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/{type}/delete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.DeleteAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + if (Enabled(UAuthActions.Credentials.DeleteAdmin)) + adminCredentials.MapPost("/delete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.DeleteAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); } if (options.Endpoints.Authorization != false) @@ -235,21 +333,46 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options var authz = group.MapGroup("/authorization"); var adminAuthz = group.MapGroup("/admin/authorization"); - authz.MapPost("/check", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + // TODO: Add enabled actions here + authz.MapPost("/check", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) => await h.CheckAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - authz.MapPost("/users/me/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.Authorization.Roles.ReadSelf)) + authz.MapPost("/users/me/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) => await h.GetMyRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - adminAuthz.MapPost("/users/{userKey}/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + if (Enabled(UAuthActions.Authorization.Roles.ReadAdmin)) + adminAuthz.MapPost("/users/{userKey}/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.GetUserRolesAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - adminAuthz.MapPost("/users/{userKey}/roles/post", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + if (Enabled(UAuthActions.Authorization.Roles.AssignAdmin)) + adminAuthz.MapPost("/users/{userKey}/roles/assign", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.AssignRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - adminAuthz.MapPost("/users/{userKey}/roles/delete", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + if (Enabled(UAuthActions.Authorization.Roles.RemoveAdmin)) + adminAuthz.MapPost("/users/{userKey}/roles/remove", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.RemoveRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + if (Enabled(UAuthActions.Authorization.Roles.CreateAdmin)) + adminAuthz.MapPost("/roles/create",async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + => await h.CreateRoleAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + if (Enabled(UAuthActions.Authorization.Roles.QueryAdmin)) + adminAuthz.MapPost("/roles/query", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + => await h.QueryRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + if (Enabled(UAuthActions.Authorization.Roles.RenameAdmin)) + adminAuthz.MapPost("/roles/{roleId}/rename", async ([FromServices] IAuthorizationEndpointHandler h, RoleId roleId, HttpContext ctx) + => await h.RenameRoleAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + if (Enabled(UAuthActions.Authorization.Roles.SetPermissionsAdmin)) + adminAuthz.MapPost("/roles/{roleId}/permissions", async ([FromServices] IAuthorizationEndpointHandler h, RoleId roleId, HttpContext ctx) + => await h.SetRolePermissionsAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + if (Enabled(UAuthActions.Authorization.Roles.DeleteAdmin)) + adminAuthz.MapPost("/roles/{roleId}/delete", async ([FromServices] IAuthorizationEndpointHandler h, RoleId roleId, HttpContext ctx) + => await h.DeleteRoleAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); } // IMPORTANT: diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs index b8f09be6..808c8717 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs @@ -46,7 +46,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken ); } - if (credential.Kind == PrimaryCredentialKind.Stateful) + if (credential.Kind == PrimaryGrantKind.Stateful) { if (!AuthSessionId.TryCreate(credential.Value, out var sessionId)) { diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs new file mode 100644 index 00000000..262f8aee --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class HttpContextJsonExtensions +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + public static async Task ReadJsonAsync(this HttpContext ctx, CancellationToken ct = default) + { + var request = ctx.Request; + + if (!request.HasJsonContentType()) + throw new InvalidOperationException("Request content type must be application/json."); + + if (request.Body == null || request.ContentLength == 0) + throw new InvalidOperationException("Request body is empty."); + + request.EnableBuffering(); + + request.Body.Position = 0; + + try + { + var result = await JsonSerializer.DeserializeAsync(request.Body, JsonOptions, ct); + + request.Body.Position = 0; + + if (result == null) + throw new InvalidOperationException("Request body could not be deserialized."); + + return result; + } + catch (JsonException) + { + throw new InvalidOperationException("Invalid JSON"); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs rename to src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextSessionExtensions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs rename to src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextSessionExtensions.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextTenantExtensions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs rename to src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextTenantExtensions.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextUserExtensions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs rename to src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextUserExtensions.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs deleted file mode 100644 index c429e7c5..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Text.Json; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Extensions; - -public static class HttpContextJsonExtensions -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - - public static async Task ReadJsonAsync(this HttpContext ctx, CancellationToken ct = default) - { - if (!ctx.Request.HasJsonContentType()) - throw new InvalidOperationException("Request content type must be application/json."); - - if (ctx.Request.Body is null) - throw new InvalidOperationException("Request body is empty."); - - var result = await JsonSerializer.DeserializeAsync(ctx.Request.Body, JsonOptions, ct); - - if (result is null) - throw new InvalidOperationException("Request body could not be deserialized."); - - return result; - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 4c55fe37..4b94858b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Core.Runtime; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Policies.Abstractions; using CodeBeam.UltimateAuth.Policies.Defaults; @@ -16,13 +17,13 @@ using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Authentication; -using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Flows; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.MultiTenancy; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Runtime; +using CodeBeam.UltimateAuth.Server.Security; using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Server.Stores; using CodeBeam.UltimateAuth.Users.Contracts; @@ -31,6 +32,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; +using CodeBeam.UltimateAuth.Users; namespace CodeBeam.UltimateAuth.Server.Extensions; @@ -70,6 +72,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddScoped(sp => @@ -136,9 +139,9 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); services.TryAddSingleton(); @@ -190,6 +193,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -202,12 +206,14 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddSingleton(); services.TryAddScoped(); + services.TryAddSingleton(); services.TryAddScoped(); @@ -217,19 +223,11 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddSingleton(); - //services.TryAddScoped>(); + services.TryAddScoped(); services.TryAddScoped(); - - //services.TryAddScoped(); services.TryAddScoped(); - - //services.TryAddScoped>(); services.TryAddScoped(); - - //services.TryAddScoped(); services.TryAddScoped(); - - //services.TryAddScoped>(); services.TryAddScoped(); // ------------------------------ @@ -252,7 +250,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.Configure(opt => { - opt.AllowedBuiltIns = new HashSet + opt.AllowedTypes = new HashSet { UserIdentifierType.Username, UserIdentifierType.Email @@ -302,6 +300,7 @@ internal static IServiceCollection AddUsersInternal(IServiceCollection services) { services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); services.TryAddScoped(); + services.TryAddScoped(); return services; } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs index a41344b2..29a97238 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs @@ -18,7 +18,7 @@ public LoginDecision Decide(LoginDecisionContext context) var state = context.SecurityState; if (state is not null) { - if (state.IsLocked) + if (state.IsLocked(DateTimeOffset.UtcNow)) return LoginDecision.Deny(AuthFailureReason.LockedOut); if (state.RequiresReauthentication) diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs index 72a53af6..c88a9aa4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Core.Security; namespace CodeBeam.UltimateAuth.Server.Flows; @@ -33,7 +33,8 @@ public sealed class LoginDecisionContext /// /// Gets the user security state if the user could be resolved. /// - public IUserSecurityState? SecurityState { get; init; } + //public IUserSecurityState? SecurityState { get; init; } + public AuthenticationSecurityState? SecurityState { get; init; } /// /// Indicates whether the user exists. diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 4486152b..694def16 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -2,7 +2,9 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.Events; +using CodeBeam.UltimateAuth.Core.Security; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Auth; @@ -26,8 +28,8 @@ internal sealed class LoginOrchestrator : ILoginOrchestrator private readonly ISessionOrchestrator _sessionOrchestrator; private readonly ITokenIssuer _tokens; private readonly IUserClaimsProvider _claimsProvider; - private readonly IUserSecurityStateWriter _securityWriter; - private readonly IUserSecurityStateProvider _securityStateProvider; // runtime risk + private readonly ISessionStoreFactory _storeFactory; + private readonly IAuthenticationSecurityManager _authenticationSecurityManager; // runtime risk private readonly UAuthEventDispatcher _events; private readonly UAuthServerOptions _options; @@ -40,8 +42,8 @@ public LoginOrchestrator( ISessionOrchestrator sessionOrchestrator, ITokenIssuer tokens, IUserClaimsProvider claimsProvider, - IUserSecurityStateWriter securityWriter, - IUserSecurityStateProvider securityStateProvider, + ISessionStoreFactory storeFactory, + IAuthenticationSecurityManager authenticationSecurityManager, UAuthEventDispatcher events, IOptions options) { @@ -53,8 +55,8 @@ public LoginOrchestrator( _sessionOrchestrator = sessionOrchestrator; _tokens = tokens; _claimsProvider = claimsProvider; - _securityWriter = securityWriter; - _securityStateProvider = securityStateProvider; + _storeFactory = storeFactory; + _authenticationSecurityManager = authenticationSecurityManager; _events = events; _options = options.Value; } @@ -63,48 +65,43 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req { ct.ThrowIfCancellationRequested(); - var now = request.At ?? DateTimeOffset.UtcNow; + if (flow.Device.DeviceId is not DeviceId deviceId) + throw new UAuthConflictException("Device id could not resolved."); + var now = request.At ?? DateTimeOffset.UtcNow; var resolution = await _identifierResolver.ResolveAsync(request.Tenant, request.Identifier, ct); - var userKey = resolution?.UserKey; bool userExists = false; bool credentialsValid = false; - IUserSecurityState? securityState = null; - - DateTimeOffset? lockoutUntilUtc = null; - int? remainingAttempts = null; + AuthenticationSecurityState? accountState = null; + AuthenticationSecurityState? factorState = null; if (userKey is not null) { var user = await _users.GetAsync(request.Tenant, userKey.Value, ct); - if (user is not null && user.IsActive && !user.IsDeleted) + if (user is not null && user.CanAuthenticate && !user.IsDeleted) { userExists = true; - securityState = await _securityStateProvider.GetAsync(request.Tenant, userKey.Value, ct); + accountState = await _authenticationSecurityManager.GetOrCreateAccountAsync(request.Tenant, userKey.Value, ct); - if (securityState?.LastFailedAt is DateTimeOffset lastFail && _options.Login.FailureWindow is { } window && now - lastFail > window) + if (accountState.IsLocked(now)) { - await _securityWriter.ResetFailuresAsync(request.Tenant, userKey.Value, ct); - securityState = null; + return LoginResult.Failed(AuthFailureReason.LockedOut, accountState.LockedUntil, remainingAttempts: 0); } - if (securityState?.LockedUntil is DateTimeOffset until && until <= now) - { - await _securityWriter.ResetFailuresAsync(request.Tenant, userKey.Value, ct); - securityState = null; - } + factorState = await _authenticationSecurityManager.GetOrCreateFactorAsync(request.Tenant, userKey.Value, request.Factor, ct); - if (securityState?.LockedUntil is DateTimeOffset stillLocked && stillLocked > now) + if (factorState.IsLocked(now)) { - return LoginResult.Failed(AuthFailureReason.LockedOut, stillLocked, 0); + return LoginResult.Failed(AuthFailureReason.LockedOut, factorState.LockedUntil, 0); } var credentials = await _credentialStore.GetByUserAsync(request.Tenant, userKey.Value, ct); + // TODO: Add .Where(c => c.Type == request.Factor) when we support multiple factors per user foreach (var credential in credentials.OfType()) { if (!credential.Security.IsUsable(now)) @@ -121,6 +118,19 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req } } + // TODO: Add create-time uniqueness guard for chain id for concurrency + var kernel = _storeFactory.Create(request.Tenant); + SessionChainId? chainId = null; + + if (userKey is not null) + { + var chain = await kernel.GetChainByDeviceAsync(request.Tenant, userKey.Value, deviceId, ct); + + if (chain is not null && !chain.IsRevoked) + chainId = chain.ChainId; + } + + // TODO: Add accountState here, currently it only checks factor state var decisionContext = new LoginDecisionContext { Tenant = request.Tenant, @@ -128,8 +138,8 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req CredentialsValid = credentialsValid, UserExists = userExists, UserKey = userKey, - SecurityState = securityState, - IsChained = request.ChainId is not null + SecurityState = factorState, + IsChained = chainId is not null }; var decision = _authority.Decide(decisionContext); @@ -138,46 +148,40 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req if (decision.Kind == LoginDecisionKind.Deny) { - if (userKey is not null && userExists) + if (userKey is not null && userExists && factorState is not null) { - var isCurrentlyLocked = - securityState?.IsLocked == true && - securityState?.LockedUntil is DateTimeOffset until && - until > now; + var securityVersion = factorState.SecurityVersion; + factorState = factorState.RegisterFailure(now, _options.Login.MaxFailedAttempts, _options.Login.LockoutDuration, _options.Login.ExtendLockOnFailure); + await _authenticationSecurityManager.UpdateAsync(factorState, securityVersion, ct); - if (!isCurrentlyLocked) - { - await _securityWriter.RecordFailedLoginAsync(request.Tenant, userKey.Value, now, ct); - - var currentFailures = securityState?.FailedLoginAttempts ?? 0; - var nextCount = currentFailures + 1; + DateTimeOffset? lockedUntil = null; + int? remainingAttempts = null; - if (max > 0) + if (_options.Login.IncludeFailureDetails) + { + if (factorState.IsLocked(now)) { - if (nextCount >= max) - { - lockoutUntilUtc = now.Add(_options.Login.LockoutDuration); - await _securityWriter.LockUntilAsync(request.Tenant, userKey.Value, lockoutUntilUtc.Value, ct); - remainingAttempts = 0; - - return LoginResult.Failed(AuthFailureReason.LockedOut, lockoutUntilUtc, remainingAttempts); - } - else - { - remainingAttempts = max - nextCount; - } + lockedUntil = factorState.LockedUntil; + remainingAttempts = 0; + } + else if (_options.Login.MaxFailedAttempts > 0) + { + remainingAttempts = _options.Login.MaxFailedAttempts - factorState.FailedAttempts; } } - else - { - lockoutUntilUtc = securityState!.LockedUntil; - remainingAttempts = 0; - } + + return LoginResult.Failed( + factorState.IsLocked(now) + ? AuthFailureReason.LockedOut + : decision.FailureReason, + lockedUntil, + remainingAttempts); } - return LoginResult.Failed(decision.FailureReason, lockoutUntilUtc, remainingAttempts); + return LoginResult.Failed(decision.FailureReason); } + if (decision.Kind == LoginDecisionKind.Challenge) { return LoginResult.Continue(new LoginContinuation @@ -190,7 +194,12 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req return LoginResult.Failed(AuthFailureReason.InvalidCredentials); // After this point, the login is successful. We can reset any failure counts and proceed to create a session. - await _securityWriter.ResetFailuresAsync(request.Tenant, userKey.Value, ct); + if (factorState is not null) + { + var version = factorState.SecurityVersion; + factorState = factorState.RegisterSuccess(); + await _authenticationSecurityManager.UpdateAsync(factorState, version, ct); + } var claims = await _claimsProvider.GetClaimsAsync(request.Tenant, userKey.Value, ct); @@ -199,9 +208,9 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req Tenant = request.Tenant, UserKey = userKey.Value, Now = now, - Device = request.Device, + Device = flow.Device, Claims = claims, - ChainId = request.ChainId, + ChainId = chainId, Metadata = SessionMetadata.Empty, Mode = flow.EffectiveMode }; @@ -218,7 +227,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req Tenant = request.Tenant, UserKey = userKey.Value, SessionId = issuedSession.Session.SessionId, - ChainId = request.ChainId, + ChainId = issuedSession.Session.ChainId, Claims = claims.AsDictionary() }; @@ -230,7 +239,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req } await _events.DispatchAsync( - new UserLoggedInContext(request.Tenant, userKey.Value, now, request.Device, issuedSession.Session.SessionId)); + new UserLoggedInContext(request.Tenant, userKey.Value, now, flow.Device, issuedSession.Session.SessionId)); return LoginResult.Success(issuedSession.Session.SessionId, tokens); } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponsePolicy.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponsePolicy.cs index fbb34c10..9d8e1515 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponsePolicy.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponsePolicy.cs @@ -6,6 +6,6 @@ namespace CodeBeam.UltimateAuth.Server.Flows; public interface IRefreshResponsePolicy { - CredentialKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result); + GrantKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result); bool WriteRefreshToken(AuthFlowContext flow); } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponsePolicy.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponsePolicy.cs index 6e909cdb..e29bc768 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponsePolicy.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponsePolicy.cs @@ -8,28 +8,28 @@ namespace CodeBeam.UltimateAuth.Server.Flows; internal class RefreshResponsePolicy : IRefreshResponsePolicy { - public CredentialKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result) + public GrantKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result) { if (flow.EffectiveMode == UAuthMode.PureOpaque) - return CredentialKind.Session; + return GrantKind.Session; if (flow.EffectiveMode == UAuthMode.PureJwt) - return CredentialKind.AccessToken; + return GrantKind.AccessToken; if (!string.IsNullOrWhiteSpace(request.RefreshToken) && request.SessionId == null) { - return CredentialKind.AccessToken; + return GrantKind.AccessToken; } if (request.SessionId != null) { - return CredentialKind.Session; + return GrantKind.Session; } if (flow.ClientProfile == UAuthClientProfile.Api) - return CredentialKind.AccessToken; + return GrantKind.AccessToken; - return CredentialKind.Session; + return GrantKind.Session; } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs index 5df517b3..2b29016e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs @@ -5,9 +5,9 @@ namespace CodeBeam.UltimateAuth.Server.Flows; public sealed class SessionTouchService : ISessionTouchService { - private readonly ISessionStoreKernelFactory _kernelFactory; + private readonly ISessionStoreFactory _kernelFactory; - public SessionTouchService(ISessionStoreKernelFactory kernelFactory) + public SessionTouchService(ISessionStoreFactory kernelFactory) { _kernelFactory = kernelFactory; } @@ -16,11 +16,11 @@ public SessionTouchService(ISessionStoreKernelFactory kernelFactory) // That's why the service access store direcly: There is no security flow here, only validate and touch session. public async Task RefreshAsync(SessionValidationResult validation, SessionTouchPolicy policy, SessionTouchMode sessionTouchMode, DateTimeOffset now, CancellationToken ct = default) { - if (!validation.IsValid || validation.SessionId is null) + if (!validation.IsValid || validation.ChainId is null) return SessionRefreshResult.ReauthRequired(); if (!policy.TouchInterval.HasValue) - return SessionRefreshResult.Success(validation.SessionId.Value, didTouch: false); + return SessionRefreshResult.Success(validation.SessionId!.Value, didTouch: false); var kernel = _kernelFactory.Create(validation.Tenant); @@ -28,18 +28,21 @@ public async Task RefreshAsync(SessionValidationResult val await kernel.ExecuteAsync(async _ => { - var session = await kernel.GetSessionAsync(validation.SessionId.Value); - if (session is null || session.IsRevoked) + var chain = await kernel.GetChainAsync(validation.ChainId.Value); + + if (chain is null || chain.IsRevoked) return; - if (sessionTouchMode == SessionTouchMode.IfNeeded && now - session.LastSeenAt < policy.TouchInterval.Value) + if (now - chain.LastSeenAt < policy.TouchInterval.Value) return; - var touched = session.Touch(now); - await kernel.SaveSessionAsync(touched); + var expectedVersion = chain.Version; + var touched = chain.Touch(now); + + await kernel.SaveChainAsync(touched, expectedVersion); didTouch = true; }, ct); - return SessionRefreshResult.Success(validation.SessionId.Value, didTouch); + return SessionRefreshResult.Success(validation.SessionId!.Value, didTouch); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs index 18d3f160..786498ed 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs @@ -7,5 +7,5 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public interface IUAuthCookiePolicyBuilder { - CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, CredentialKind kind); + CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, GrantKind kind); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs index 9d75487a..78b0af0a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs @@ -8,7 +8,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; internal sealed class UAuthCookiePolicyBuilder : IUAuthCookiePolicyBuilder { - public CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, CredentialKind kind) + public CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, GrantKind kind) { if (response.Cookie is null) throw new InvalidOperationException("Cookie policy requested but Cookie options are null."); @@ -43,7 +43,7 @@ private static SameSiteMode ResolveSameSite(UAuthCookieOptions cookie, AuthFlowC }; } - private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, AuthFlowContext context, CredentialKind kind) + private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, AuthFlowContext context, GrantKind kind) { var buffer = src.Lifetime.IdleBuffer ?? TimeSpan.Zero; var baseLifetime = ResolveBaseLifetime(context, kind, src); @@ -54,7 +54,7 @@ private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, } } - private static TimeSpan? ResolveBaseLifetime(AuthFlowContext context, CredentialKind kind, UAuthCookieOptions src) + private static TimeSpan? ResolveBaseLifetime(AuthFlowContext context, GrantKind kind, UAuthCookieOptions src) { if (src.MaxAge is not null) return src.MaxAge; @@ -64,9 +64,9 @@ private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, return kind switch { - CredentialKind.Session => ResolveSessionLifetime(context), - CredentialKind.RefreshToken => context.EffectiveOptions.Options.Token.RefreshTokenLifetime, - CredentialKind.AccessToken => context.EffectiveOptions.Options.Token.AccessTokenLifetime, + GrantKind.Session => ResolveSessionLifetime(context), + GrantKind.RefreshToken => context.EffectiveOptions.Options.Token.RefreshTokenLifetime, + GrantKind.AccessToken => context.EffectiveOptions.Options.Token.AccessTokenLifetime, _ => null }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs index add44279..6c43139c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs @@ -27,16 +27,16 @@ public CredentialResponseWriter( _headerPolicy = headerPolicy; } - public void Write(HttpContext context, CredentialKind kind, AuthSessionId sessionId) + public void Write(HttpContext context, GrantKind kind, AuthSessionId sessionId) => WriteInternal(context, kind, sessionId.ToString()); - public void Write(HttpContext context, CredentialKind kind, AccessToken token) + public void Write(HttpContext context, GrantKind kind, AccessToken token) => WriteInternal(context, kind, token.Token); - public void Write(HttpContext context, CredentialKind kind, RefreshToken token) + public void Write(HttpContext context, GrantKind kind, RefreshToken token) => WriteInternal(context, kind, token.Token); - public void WriteInternal(HttpContext context, CredentialKind kind, string value) + public void WriteInternal(HttpContext context, GrantKind kind, string value) { var auth = _authContext.Current; var delivery = ResolveDelivery(auth.Response, kind); @@ -61,7 +61,7 @@ public void WriteInternal(HttpContext context, CredentialKind kind, string value } } - private void WriteCookie(HttpContext context, CredentialKind kind, string value, CredentialResponseOptions options, AuthFlowContext auth) + private void WriteCookie(HttpContext context, GrantKind kind, string value, CredentialResponseOptions options, AuthFlowContext auth) { if (options.Cookie is null) throw new InvalidOperationException($"Cookie options missing for credential '{kind}'."); @@ -78,12 +78,12 @@ private void WriteHeader(HttpContext context, string value, CredentialResponseOp context.Response.Headers[headerName] = formatted; } - private static CredentialResponseOptions ResolveDelivery(EffectiveAuthResponse response, CredentialKind kind) + private static CredentialResponseOptions ResolveDelivery(EffectiveAuthResponse response, GrantKind kind) => kind switch { - CredentialKind.Session => response.SessionIdDelivery, - CredentialKind.AccessToken => response.AccessTokenDelivery, - CredentialKind.RefreshToken => response.RefreshTokenDelivery, + GrantKind.Session => response.SessionIdDelivery, + GrantKind.AccessToken => response.AccessTokenDelivery, + GrantKind.RefreshToken => response.RefreshTokenDelivery, _ => throw new ArgumentOutOfRangeException(nameof(kind)) }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs index ffe1092d..8aa3f572 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs @@ -23,8 +23,8 @@ public FlowCredentialResolver(IPrimaryCredentialResolver primaryResolver) return kind switch { - PrimaryCredentialKind.Stateful => ResolveSession(context, response), - PrimaryCredentialKind.Stateless => ResolveAccessToken(context, response), + PrimaryGrantKind.Stateful => ResolveSession(context, response), + PrimaryGrantKind.Stateless => ResolveAccessToken(context, response), _ => null }; @@ -49,7 +49,7 @@ public FlowCredentialResolver(IPrimaryCredentialResolver primaryResolver) return new ResolvedCredential { - Kind = PrimaryCredentialKind.Stateful, + Kind = PrimaryGrantKind.Stateful, Value = raw.Trim(), Tenant = context.GetTenant(), Device = context.GetDevice() @@ -81,7 +81,7 @@ public FlowCredentialResolver(IPrimaryCredentialResolver primaryResolver) return new ResolvedCredential { - Kind = PrimaryCredentialKind.Stateless, + Kind = PrimaryGrantKind.Stateless, Value = value, Tenant = context.GetTenant(), Device = context.GetDevice() diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs index 595f7aae..3ba31678 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs @@ -16,7 +16,7 @@ public PrimaryCredentialResolver(IOptions options) _options = options.Value; } - public PrimaryCredentialKind Resolve(HttpContext context) + public PrimaryGrantKind Resolve(HttpContext context) { if (IsApiRequest(context)) return _options.PrimaryCredential.Api; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs index 0a3a41b6..e1e7dccd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs @@ -7,9 +7,16 @@ internal sealed class DeviceContextFactory : IDeviceContextFactory { public DeviceContext Create(DeviceInfo device) { - if (string.IsNullOrWhiteSpace(device.DeviceId.Value)) + if (device is null || string.IsNullOrWhiteSpace(device.DeviceId.Value)) return DeviceContext.Anonymous(); - return DeviceContext.FromDeviceId(device.DeviceId); + return DeviceContext.Create( + deviceId: device.DeviceId, + deviceType: device.DeviceType, + platform: device.Platform, + operatingSystem: device.OperatingSystem, + browser: device.Browser, + ipAddress: device.IpAddress + ); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs index 3ac067a1..20022912 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs @@ -7,6 +7,8 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; +// TODO: This is a very basic implementation. +// Consider creating a seperate package with a library like UA Parser, WURFL or DeviceAtlas for more accurate device detection. (Add IDeviceInfoParser) public sealed class DeviceResolver : IDeviceResolver { public DeviceInfo Resolve(HttpContext context) @@ -14,17 +16,24 @@ public DeviceInfo Resolve(HttpContext context) var request = context.Request; var rawDeviceId = ResolveRawDeviceId(context); - DeviceId.TryCreate(rawDeviceId, out var deviceId); + if (!DeviceId.TryCreate(rawDeviceId, out var deviceId)) + { + //throw new InvalidOperationException("device_id_required"); + } - return new DeviceInfo + var ua = request.Headers.UserAgent.ToString(); + var deviceInfo = new DeviceInfo { DeviceId = deviceId, - Platform = ResolvePlatform(request), - UserAgent = request.Headers.UserAgent.ToString(), - IpAddress = context.Connection.RemoteIpAddress?.ToString() + Platform = ResolvePlatform(ua), + OperatingSystem = ResolveOperatingSystem(ua), + Browser = ResolveBrowser(ua), + UserAgent = ua, + IpAddress = ResolveIp(context) }; - } + return deviceInfo; + } private static string? ResolveRawDeviceId(HttpContext context) { @@ -42,16 +51,70 @@ public DeviceInfo Resolve(HttpContext context) return null; } - private static string? ResolvePlatform(HttpRequest request) + private static string? ResolvePlatform(string ua) + { + var s = ua.ToLowerInvariant(); + + if (s.Contains("ipad") || s.Contains("tablet") || s.Contains("sm-t") /* bazı samsung tabletler */) + return "tablet"; + + if (s.Contains("mobi") || s.Contains("iphone") || s.Contains("android")) + return "mobile"; + + return "desktop"; + } + + private static string? ResolveOperatingSystem(string ua) + { + var s = ua.ToLowerInvariant(); + + if (s.Contains("iphone") || s.Contains("ipad") || s.Contains("cpu os") || s.Contains("ios")) + return "ios"; + + if (s.Contains("android")) + return "android"; + + if (s.Contains("windows nt")) + return "windows"; + + if (s.Contains("mac os x") || s.Contains("macintosh")) + return "macos"; + + if (s.Contains("linux")) + return "linux"; + + return "unknown"; + } + + private static string? ResolveBrowser(string ua) + { + var s = ua.ToLowerInvariant(); + + if (s.Contains("edg/")) + return "edge"; + + if (s.Contains("opr/") || s.Contains("opera")) + return "opera"; + + if (s.Contains("chrome/") && !s.Contains("chromium/")) + return "chrome"; + + if (s.Contains("safari/") && !s.Contains("chrome/") && !s.Contains("crios/")) + return "safari"; + + if (s.Contains("firefox/")) + return "firefox"; + + return "unknown"; + } + + private static string? ResolveIp(HttpContext context) { - var ua = request.Headers.UserAgent.ToString().ToLowerInvariant(); + var forwarded = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); - if (ua.Contains("android")) return "android"; - if (ua.Contains("iphone") || ua.Contains("ipad")) return "ios"; - if (ua.Contains("windows")) return "windows"; - if (ua.Contains("mac os")) return "macos"; - if (ua.Contains("linux")) return "linux"; + if (!string.IsNullOrWhiteSpace(forwarded)) + return forwarded.Split(',')[0].Trim(); - return "web"; + return context.Connection.RemoteIpAddress?.ToString(); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/JwtTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/JwtTokenGenerator.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/NumericCodeGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/NumericCodeGenerator.cs new file mode 100644 index 00000000..52547057 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/NumericCodeGenerator.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using System.Security.Cryptography; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class NumericCodeGenerator : INumericCodeGenerator +{ + public string Generate(int digits = 6) + { + var max = (int)Math.Pow(10, digits); + var number = RandomNumberGenerator.GetInt32(max); + + return number.ToString($"D{digits}"); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/OpaqueTokenGenerator.cs similarity index 79% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/OpaqueTokenGenerator.cs index ba7d89f8..9dee1407 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/OpaqueTokenGenerator.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Options; using System.Security.Cryptography; @@ -17,5 +18,5 @@ public OpaqueTokenGenerator(IOptions options) public string Generate() => GenerateBytes(_options.OpaqueIdBytes); public string GenerateJwtId() => GenerateBytes(16); - private static string GenerateBytes(int bytes) => Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)); + private static string GenerateBytes(int bytes) => WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(bytes)); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs index 31b36020..941c23d1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs @@ -24,12 +24,12 @@ public string Hash(string plaintext) return Convert.ToBase64String(hash); } - public bool Verify(string plaintext, string hash) + public bool Verify(string hash, string plaintext) { var computed = Hash(plaintext); return CryptographicOperations.FixedTimeEquals( - Convert.FromBase64String(computed), - Convert.FromBase64String(hash)); + Convert.FromBase64String(hash), + Convert.FromBase64String(computed)); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index 4975109f..dc9ce475 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.Extensions.Options; @@ -11,18 +12,18 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class UAuthSessionIssuer : ISessionIssuer { - private readonly ISessionStoreKernelFactory _kernelFactory; + private readonly ISessionStoreFactory _storeFactory; private readonly IOpaqueTokenGenerator _opaqueGenerator; private readonly UAuthServerOptions _options; - public UAuthSessionIssuer(ISessionStoreKernelFactory kernelFactory, IOpaqueTokenGenerator opaqueGenerator, IOptions options) + public UAuthSessionIssuer(ISessionStoreFactory storeFactory, IOpaqueTokenGenerator opaqueGenerator, IOptions options) { - _kernelFactory = kernelFactory; + _storeFactory = storeFactory; _opaqueGenerator = opaqueGenerator; _options = options.Value; } - public async Task IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken ct = default) + public async Task IssueSessionAsync(AuthenticatedSessionContext context, CancellationToken ct = default) { // Defensive guard — enforcement belongs to Authority if (context.Mode == UAuthMode.PureJwt) @@ -44,38 +45,36 @@ public async Task IssueLoginSessionAsync(AuthenticatedSessionCont expiresAt = absoluteExpiry; } - var session = UAuthSession.Create( - sessionId: sessionId, - tenant: context.Tenant, - userKey: context.UserKey, - chainId: SessionChainId.Unassigned, - now: now, - expiresAt: expiresAt, - claims: context.Claims, - device: context.Device, - metadata: context.Metadata - ); - - var issued = new IssuedSession - { - Session = session, - OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid - }; + var kernel = _storeFactory.Create(context.Tenant); - var kernel = _kernelFactory.Create(context.Tenant); + IssuedSession? issued = null; await kernel.ExecuteAsync(async _ => { - var root = await kernel.GetSessionRootByUserAsync(context.UserKey) - ?? UAuthSessionRoot.Create(context.Tenant, context.UserKey, now); + var root = await kernel.GetRootByUserAsync(context.UserKey); + + if (root is null) + { + root = UAuthSessionRoot.Create(context.Tenant, context.UserKey, now); + await kernel.CreateRootAsync(root); + } + else if (root.IsRevoked) + { + throw new UAuthValidationException("Session root revoked."); + } UAuthSessionChain chain; if (context.ChainId is not null) { chain = await kernel.GetChainAsync(context.ChainId.Value) - ?? throw new SecurityException("Chain not found."); + ?? throw new UAuthNotFoundException("Chain not found."); + + if (chain.IsRevoked) + throw new UAuthValidationException("Chain revoked."); + + if (chain.UserKey != context.UserKey || chain.Tenant != context.Tenant) + throw new UAuthValidationException("Invalid chain ownership."); } else { @@ -84,26 +83,65 @@ await kernel.ExecuteAsync(async _ => root.RootId, context.Tenant, context.UserKey, - root.SecurityVersion, - ClaimsSnapshot.Empty); + now, + expiresAt, + context.Device, + ClaimsSnapshot.Empty, + root.SecurityVersion + ); + + await kernel.CreateChainAsync(chain); + } - await kernel.SaveChainAsync(chain); - root = root.AttachChain(chain, now); + var sessions = await kernel.GetSessionsByChainAsync(chain.ChainId); + + if (sessions.Count >= _options.Session.MaxSessionsPerChain) + { + var toDelete = sessions + .Where(s => s.SessionId != chain.ActiveSessionId) + .OrderBy(x => x.CreatedAt) + .Take(sessions.Count - _options.Session.MaxSessionsPerChain + 1) + .ToList(); + + foreach (var old in toDelete) + { + await kernel.RemoveSessionAsync(old.SessionId); + } } - var boundSession = session.WithChain(chain.ChainId); + var session = UAuthSession.Create( + sessionId: sessionId, + tenant: context.Tenant, + userKey: context.UserKey, + chainId: chain.ChainId, + now: now, + expiresAt: expiresAt, + securityVersion: root.SecurityVersion, + claims: context.Claims, + metadata: context.Metadata + ); + + await kernel.CreateSessionAsync(session); - await kernel.SaveSessionAsync(boundSession); - await kernel.SetActiveSessionIdAsync(chain.ChainId, boundSession.SessionId); - await kernel.SaveSessionRootAsync(root); + var updatedChain = chain.AttachSession(session.SessionId, now); + await kernel.SaveChainAsync(updatedChain, chain.Version); + + issued = new IssuedSession + { + Session = session, + OpaqueSessionId = opaqueSessionId, + IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid + }; }, ct); + if (issued == null) + throw new InvalidCastException("Issue failed."); return issued; } public async Task RotateSessionAsync(SessionRotationContext context, CancellationToken ct = default) { - var kernel = _kernelFactory.Create(context.Tenant); + var kernel = _storeFactory.Create(context.Tenant); var now = context.Now; var opaqueSessionId = _opaqueGenerator.Generate(); @@ -118,59 +156,93 @@ public async Task RotateSessionAsync(SessionRotationContext conte expiresAt = absoluteExpiry; } - var issued = new IssuedSession - { - Session = UAuthSession.Create( - sessionId: newSessionId, - tenant: context.Tenant, - userKey: context.UserKey, - chainId: SessionChainId.Unassigned, - now: now, - expiresAt: expiresAt, - device: context.Device, - claims: context.Claims, - metadata: context.Metadata - ), - OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid - }; + IssuedSession? issued = null; await kernel.ExecuteAsync(async _ => { + var root = await kernel.GetRootByUserAsync(context.UserKey); + if (root == null) + throw new SecurityException("Session root not found"); + + if (root.IsRevoked) + throw new SecurityException("Session root is revoked"); + var oldSession = await kernel.GetSessionAsync(context.CurrentSessionId) ?? throw new SecurityException("Session not found"); if (oldSession.IsRevoked || oldSession.ExpiresAt <= now) throw new SecurityException("Session is not valid"); + if (oldSession.SecurityVersionAtCreation != root.SecurityVersion) + throw new SecurityException("Security version mismatch"); + var chain = await kernel.GetChainAsync(oldSession.ChainId) ?? throw new SecurityException("Chain not found"); - var bound = issued.Session.WithChain(chain.ChainId); + if (chain.IsRevoked) + throw new SecurityException("Chain is revoked"); + + if (chain.Tenant != context.Tenant || chain.UserKey != context.UserKey) + throw new SecurityException("Chain does not belong to the current user/tenant."); + + var newSessionUnbound = UAuthSession.Create( + sessionId: newSessionId, + tenant: context.Tenant, + userKey: context.UserKey, + chainId: SessionChainId.Unassigned, + now: now, + expiresAt: expiresAt, + securityVersion: root.SecurityVersion, + claims: context.Claims, + metadata: context.Metadata + ); + + issued = new IssuedSession + { + Session = newSessionUnbound, + OpaqueSessionId = opaqueSessionId, + IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid + }; + + var newSession = issued.Session.WithChain(chain.ChainId); + + await kernel.CreateSessionAsync(newSession); + var chainExpected = chain.Version; + var updatedChain = chain.RotateSession(newSession.SessionId, now, context.Claims); + await kernel.SaveChainAsync(updatedChain, chainExpected); - await kernel.SaveSessionAsync(bound); - await kernel.SetActiveSessionIdAsync(chain.ChainId, bound.SessionId); - await kernel.RevokeSessionAsync(oldSession.SessionId, now); + var expected = oldSession.Version; + var revokedOld = oldSession.Revoke(now); + await kernel.SaveSessionAsync(revokedOld, expected); }, ct); + if (issued == null) + throw new InvalidCastException("Can't issued session."); return issued; } public async Task RevokeSessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) { - var kernel = _kernelFactory.Create(tenant); + var kernel = _storeFactory.Create(tenant); return await kernel.ExecuteAsync(_ => kernel.RevokeSessionAsync(sessionId, at), ct); } public async Task RevokeChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) { - var kernel = _kernelFactory.Create(tenant); - await kernel.ExecuteAsync(_ => kernel.RevokeChainAsync(chainId, at), ct); + var kernel = _storeFactory.Create(tenant); + await kernel.ExecuteAsync(async _ => + { + var chain = await kernel.GetChainAsync(chainId); + if (chain is null) + return; + + await kernel.RevokeChainCascadeAsync(chainId, at); + }, ct); } public async Task RevokeAllChainsAsync(TenantKey tenant, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) { - var kernel = _kernelFactory.Create(tenant); + var kernel = _storeFactory.Create(tenant); await kernel.ExecuteAsync(async _ => { var chains = await kernel.GetChainsByUserAsync(userKey); @@ -180,21 +252,21 @@ await kernel.ExecuteAsync(async _ => if (exceptChainId.HasValue && chain.ChainId == exceptChainId.Value) continue; - if (!chain.IsRevoked) - await kernel.RevokeChainAsync(chain.ChainId, at); - - var activeSessionId = await kernel.GetActiveSessionIdAsync(chain.ChainId); - if (activeSessionId is not null) - await kernel.RevokeSessionAsync(activeSessionId.Value, at); + await kernel.RevokeChainCascadeAsync(chain.ChainId, at); } }, ct); } - // TODO: Discuss revoking chains/sessions when root is revoked public async Task RevokeRootAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) { - var kernel = _kernelFactory.Create(tenant); - await kernel.ExecuteAsync(_ => kernel.RevokeSessionRootAsync(userKey, at), ct); - } + var kernel = _storeFactory.Create(tenant); + await kernel.ExecuteAsync(async _ => + { + var root = await kernel.GetRootByUserAsync(userKey); + if (root is null) + return; + await kernel.RevokeRootCascadeAsync(userKey, at); + }, ct); + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IIdentifierNormalizer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IIdentifierNormalizer.cs new file mode 100644 index 00000000..80f15028 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IIdentifierNormalizer.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IIdentifierNormalizer +{ + NormalizedIdentifier Normalize(UserIdentifierType type, string value); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IdentifierNormalizer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IdentifierNormalizer.cs new file mode 100644 index 00000000..0fd00be7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IdentifierNormalizer.cs @@ -0,0 +1,132 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.Extensions.Options; +using System.Globalization; +using System.Text; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class IdentifierNormalizer : IIdentifierNormalizer +{ + private readonly UAuthIdentifierNormalizationOptions _options; + + public IdentifierNormalizer(IOptions options) + { + _options = options.Value.LoginIdentifiers.Normalization; + } + + public NormalizedIdentifier Normalize(UserIdentifierType type, string value) + { + if (string.IsNullOrWhiteSpace(value)) + return new(value, string.Empty, false, "identifier_empty"); + + var raw = value; + var normalized = BasicNormalize(value); + + return type switch + { + UserIdentifierType.Email => NormalizeEmail(raw, normalized), + UserIdentifierType.Phone => NormalizePhone(raw, normalized), + UserIdentifierType.Username => NormalizeUsername(raw, normalized), + _ => NormalizeCustom(raw, normalized) + }; + } + + private static string BasicNormalize(string value) + { + var form = value.Normalize(NormalizationForm.FormKC).Trim(); + + var sb = new StringBuilder(form.Length); + foreach (var ch in form) + { + if (char.IsControl(ch)) + continue; + + if (ch is '\u200B' or '\u200C' or '\u200D' or '\uFEFF') + continue; + + sb.Append(ch); + } + + return sb.ToString(); + } + + private NormalizedIdentifier NormalizeUsername(string raw, string value) + { + if (value.Length < 3 || value.Length > 256) + return new(raw, value, false, "username_invalid_length"); + + value = ApplyCasePolicy(value, _options.UsernameCase); + + return new(raw, value, true, null); + } + + private NormalizedIdentifier NormalizeEmail(string raw, string value) + { + var atIndex = value.IndexOf('@'); + if (atIndex <= 0 || atIndex != value.LastIndexOf('@')) + return new(raw, value, false, "email_invalid_format"); + + var local = value[..atIndex]; + var domain = value[(atIndex + 1)..]; + + if (string.IsNullOrWhiteSpace(domain) || !domain.Contains('.')) + return new(raw, value, false, "email_invalid_domain"); + + try + { + var idn = new IdnMapping(); + domain = idn.GetAscii(domain); + } + catch + { + return new(raw, value, false, "email_invalid_domain"); + } + + var normalized = $"{local}@{domain}"; + normalized = ApplyCasePolicy(normalized, _options.EmailCase); + + return new(raw, normalized, true, null); + } + + private NormalizedIdentifier NormalizePhone(string raw, string value) + { + var sb = new StringBuilder(); + + foreach (var ch in value) + { + if (char.IsDigit(ch)) + sb.Append(ch); + else if (ch == '+' && sb.Length == 0) + sb.Append(ch); + } + + var digits = sb.ToString(); + + if (digits.Length < 7) + return new(raw, digits, false, "phone_invalid_length"); + + return new(raw, digits, true, null); + } + + private NormalizedIdentifier NormalizeCustom(string raw, string value) + { + value = ApplyCasePolicy(value, _options.CustomCase); + + if (value.Length == 0) + return new(raw, value, false, "identifier_invalid"); + + return new(raw, value, true, null); + } + + private static string ApplyCasePolicy(string value, CaseHandling policy) + { + return policy switch + { + CaseHandling.ToLower => value.ToLowerInvariant(), + CaseHandling.ToUpper => value.ToUpperInvariant(), + _ => value + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/NormalizedIdentifier.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/NormalizedIdentifier.cs new file mode 100644 index 00000000..9bf3d7b8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/NormalizedIdentifier.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public readonly record struct NormalizedIdentifier( + string Raw, + string Normalized, + bool IsValid, + string? ErrorCode); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/AccessCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/AccessCommand.cs new file mode 100644 index 00000000..69588e26 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/AccessCommand.cs @@ -0,0 +1,25 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class AccessCommand : IAccessCommand +{ + private readonly Func _execute; + + public AccessCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} + +public sealed class AccessCommand : IAccessCommand +{ + private readonly Func> _execute; + + public AccessCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs index 795307ce..8252f34c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs @@ -7,6 +7,6 @@ internal sealed record CreateLoginSessionCommand(AuthenticatedSessionContext Log { public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) { - return issuer.IssueLoginSessionAsync(LoginContext, ct); + return issuer.IssueSessionAsync(LoginContext, ct); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs deleted file mode 100644 index 4fc9fcb5..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure; - -public sealed class RevokeAllUserSessionsCommand : ISessionCommand -{ - public UserKey UserKey { get; } - - public RevokeAllUserSessionsCommand(UserKey userKey) - { - UserKey = userKey; - } - - // TODO: This method should call its own logic. Not revoke root. - public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) - { - await issuer.RevokeRootAsync(context.Tenant, UserKey, context.At, ct); - return Unit.Value; - } - -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs index 094fa5f2..26b1e085 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs @@ -1,4 +1,7 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Policies.Abstractions; @@ -9,17 +12,21 @@ public sealed class UAuthAccessOrchestrator : IAccessOrchestrator { private readonly IAccessAuthority _authority; private readonly IAccessPolicyProvider _policyProvider; + private readonly IUserPermissionStore _permissions; - public UAuthAccessOrchestrator(IAccessAuthority authority, IAccessPolicyProvider policyProvider) + public UAuthAccessOrchestrator(IAccessAuthority authority, IAccessPolicyProvider policyProvider, IUserPermissionStore permissions) { _authority = authority; _policyProvider = policyProvider; + _permissions = permissions; } public async Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); + context = await EnrichAsync(context, ct); + var policies = _policyProvider.GetPolicies(context); var decision = _authority.Decide(context, policies); @@ -36,6 +43,8 @@ public async Task ExecuteAsync(AccessContext context, IAccessC { ct.ThrowIfCancellationRequested(); + context = await EnrichAsync(context, ct); + var policies = _policyProvider.GetPolicies(context); var decision = _authority.Decide(context, policies); @@ -47,4 +56,14 @@ public async Task ExecuteAsync(AccessContext context, IAccessC return await command.ExecuteAsync(ct); } + + private async Task EnrichAsync(AccessContext context, CancellationToken ct) + { + if (context.ActorUserKey is null) + return context; + + var perms = await _permissions.GetPermissionsAsync(context.ResourceTenant, context.ActorUserKey.Value, ct); + var compiled = new CompiledPermissionSet(perms); + return context.WithAttribute(UAuthConstants.Access.Permissions, compiled); + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs index bc55c658..e541fb7e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs @@ -39,5 +39,4 @@ public async Task ExecuteAsync(AuthContext authContext, ISessi return await command.ExecuteAsync(authContext, _issuer, ct); } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs index 191aadf7..52d649b4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs @@ -10,10 +10,10 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class UAuthUserAccessor : IUserAccessor { - private readonly ISessionStoreKernelFactory _kernelFactory; + private readonly ISessionStoreFactory _kernelFactory; private readonly IUserIdConverter _userIdConverter; - public UAuthUserAccessor(ISessionStoreKernelFactory kernelFactory, IUserIdConverterResolver converterResolver) + public UAuthUserAccessor(ISessionStoreFactory kernelFactory, IUserIdConverterResolver converterResolver) { _kernelFactory = kernelFactory; _userIdConverter = converterResolver.GetConverter(); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs new file mode 100644 index 00000000..21243698 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IIdentifierValidator +{ + Task ValidateAsync(AccessContext context, UserIdentifierDto identifier, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserCreateValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserCreateValidator.cs new file mode 100644 index 00000000..8157aeee --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserCreateValidator.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IUserCreateValidator +{ + Task ValidateAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IdentifierValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IdentifierValidator.cs new file mode 100644 index 00000000..3fb399b3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IdentifierValidator.cs @@ -0,0 +1,103 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.Extensions.Options; +using System.Text.RegularExpressions; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class IdentifierValidator : IIdentifierValidator +{ + private readonly UAuthIdentifierValidationOptions _options; + + public IdentifierValidator(IOptions options) + { + _options = options.Value.IdentifierValidation; + } + + public Task ValidateAsync(AccessContext context, UserIdentifierDto identifier, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var errors = new List(); + + if (string.IsNullOrWhiteSpace(identifier.Value)) + { + errors.Add(new("identifier_empty")); + return Task.FromResult(IdentifierValidationResult.Failed(errors)); + } + + identifier.Value = identifier.Value.Trim(); + + switch (identifier.Type) + { + case UserIdentifierType.Username: + ValidateUsername(identifier.Value, errors); + break; + + case UserIdentifierType.Email: + ValidateEmail(identifier.Value, errors); + break; + + case UserIdentifierType.Phone: + ValidatePhone(identifier.Value, errors); + break; + } + + if (errors.Count == 0) + return Task.FromResult(IdentifierValidationResult.Success()); + + return Task.FromResult(IdentifierValidationResult.Failed(errors)); + } + + private void ValidateUsername(string username, List errors) + { + var rule = _options.UserName; + + if (!rule.Enabled) + return; + + if (username.Length < rule.MinLength) + errors.Add(new("username_too_short", "username")); + + if (username.Length > rule.MaxLength) + errors.Add(new("username_too_long", "username")); + + if (!string.IsNullOrWhiteSpace(rule.AllowedRegex)) + { + if (!Regex.IsMatch(username, rule.AllowedRegex)) + errors.Add(new("username_invalid_format", "username")); + } + } + + private void ValidateEmail(string email, List errors) + { + var rule = _options.Email; + + if (!rule.Enabled) + return; + + if (email.Length < rule.MinLength) + errors.Add(new("email_too_short", "email")); + + if (email.Length > rule.MaxLength) + errors.Add(new("email_too_long", "email")); + + if (!email.Contains('@')) + errors.Add(new("email_invalid_format", "email")); + } + + private void ValidatePhone(string phone, List errors) + { + var rule = _options.Phone; + + if (!rule.Enabled) + return; + + if (phone.Length < rule.MinLength) + errors.Add(new("phone_too_short", "phone")); + + if (phone.Length > rule.MaxLength) + errors.Add(new("phone_too_long", "phone")); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs new file mode 100644 index 00000000..b08b2150 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs @@ -0,0 +1,64 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class UserCreateValidator : IUserCreateValidator +{ + private readonly IIdentifierValidator _identifierValidator; + + public UserCreateValidator(IIdentifierValidator identifierValidator) + { + _identifierValidator = identifierValidator; + } + + public async Task ValidateAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(request.UserName) && + string.IsNullOrWhiteSpace(request.Email) && + string.IsNullOrWhiteSpace(request.Phone)) + { + errors.Add(new("identifier_required")); + } + + if (!string.IsNullOrWhiteSpace(request.UserName)) + { + var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierDto() + { + Type = UserIdentifierType.Username, + Value = request.UserName + }, ct); + + errors.AddRange(r.Errors); + } + + if (!string.IsNullOrWhiteSpace(request.Email)) + { + var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierDto() + { + Type = UserIdentifierType.Email, + Value = request.Email + }, ct); + + errors.AddRange(r.Errors); + } + + if (!string.IsNullOrWhiteSpace(request.Phone)) + { + var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierDto() + { + Type = UserIdentifierType.Phone, + Value = request.Phone + }, ct); + + errors.AddRange(r.Errors); + } + + if (errors.Count == 0) + return UserCreateValidatorResult.Success(); + + return UserCreateValidatorResult.Failed(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs index a62c5c7b..e1245421 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Server.Options; public sealed class CredentialResponseOptions { - public CredentialKind Kind { get; init; } + public GrantKind Kind { get; init; } public TokenResponseMode Mode { get; set; } = TokenResponseMode.None; /// @@ -48,7 +48,7 @@ public CredentialResponseOptions WithCookie(UAuthCookieOptions cookie) }; } - public static CredentialResponseOptions Disabled(CredentialKind kind) + public static CredentialResponseOptions Disabled(GrantKind kind) => new() { Kind = kind, diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierOptions.cs similarity index 91% rename from src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs rename to src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierOptions.cs index fd995ac8..1d7c9c6a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierOptions.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Server.Options; -public sealed class UAuthUserIdentifierOptions +public sealed class UAuthIdentifierOptions { public bool AllowUsernameChange { get; set; } = true; public bool AllowMultipleUsernames { get; set; } = false; @@ -14,7 +14,7 @@ public sealed class UAuthUserIdentifierOptions public bool AllowAdminOverride { get; set; } = true; public bool AllowUserOverride { get; set; } = true; - internal UAuthUserIdentifierOptions Clone() => new() + internal UAuthIdentifierOptions Clone() => new() { AllowUsernameChange = AllowUsernameChange, AllowMultipleUsernames = AllowMultipleUsernames, diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierValidationOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierValidationOptions.cs new file mode 100644 index 00000000..612c518a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierValidationOptions.cs @@ -0,0 +1,59 @@ +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthIdentifierValidationOptions +{ + public UsernameIdentifierRule UserName { get; set; } = new(); + public EmailIdentifierRule Email { get; set; } = new(); + public PhoneIdentifierRule Phone { get; set; } = new(); + + internal UAuthIdentifierValidationOptions Clone() => new() + { + UserName = UserName.Clone(), + Email = Email.Clone(), + Phone = Phone.Clone() + }; +} + +public sealed class UsernameIdentifierRule +{ + public bool Enabled { get; set; } = true; + public int MinLength { get; set; } = 3; + public int MaxLength { get; set; } = 64; + public string? AllowedRegex { get; set; } = "^[a-zA-Z0-9._-]+$"; + + internal UsernameIdentifierRule Clone() => new() + { + Enabled = Enabled, + MinLength = MinLength, + MaxLength = MaxLength, + AllowedRegex = AllowedRegex, + }; +} + +public sealed class EmailIdentifierRule +{ + public bool Enabled { get; set; } = true; + public int MinLength { get; set; } = 3; + public int MaxLength { get; set; } = 256; + + internal EmailIdentifierRule Clone() => new() + { + Enabled = Enabled, + MinLength = MinLength, + MaxLength = MaxLength, + }; +} + +public sealed class PhoneIdentifierRule +{ + public bool Enabled { get; set; } = true; + public int MinLength { get; set; } = 3; + public int MaxLength { get; set; } = 24; + + internal PhoneIdentifierRule Clone() => new() + { + Enabled = Enabled, + MinLength = MinLength, + MaxLength = MaxLength, + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs index 450ce64e..5b6c5032 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs @@ -1,9 +1,10 @@ -using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Server.Options; public sealed class UAuthLoginIdentifierOptions { - public ISet AllowedBuiltIns { get; set; } = + public ISet AllowedTypes { get; set; } = new HashSet { UserIdentifierType.Username, @@ -17,12 +18,32 @@ public sealed class UAuthLoginIdentifierOptions public bool EnableCustomResolvers { get; set; } = true; public bool CustomResolversFirst { get; set; } = true; + public UAuthIdentifierNormalizationOptions Normalization { get; set; } = new(); + + public bool EnforceGlobalUniquenessForAllIdentifiers { get; set; } = false; + internal UAuthLoginIdentifierOptions Clone() => new() { - AllowedBuiltIns = new HashSet(AllowedBuiltIns), + AllowedTypes = new HashSet(AllowedTypes), RequireVerificationForEmail = RequireVerificationForEmail, RequireVerificationForPhone = RequireVerificationForPhone, EnableCustomResolvers = EnableCustomResolvers, CustomResolversFirst = CustomResolversFirst, + EnforceGlobalUniquenessForAllIdentifiers = EnforceGlobalUniquenessForAllIdentifiers, + Normalization = Normalization.Clone() + }; +} + +public sealed class UAuthIdentifierNormalizationOptions +{ + public CaseHandling UsernameCase { get; set; } = CaseHandling.Preserve; + public CaseHandling EmailCase { get; set; } = CaseHandling.ToLower; + public CaseHandling CustomCase { get; set; } = CaseHandling.Preserve; + + internal UAuthIdentifierNormalizationOptions Clone() => new() + { + UsernameCase = UsernameCase, + EmailCase = EmailCase, + CustomCase = CustomCase }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs index 1da6d92d..685bd2a3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs @@ -7,12 +7,12 @@ public sealed class UAuthPrimaryCredentialPolicy /// /// Default primary credential for UI-style requests. /// - public PrimaryCredentialKind Ui { get; set; } = PrimaryCredentialKind.Stateful; + public PrimaryGrantKind Ui { get; set; } = PrimaryGrantKind.Stateful; /// /// Default primary credential for API requests. /// - public PrimaryCredentialKind Api { get; set; } = PrimaryCredentialKind.Stateless; + public PrimaryGrantKind Api { get; set; } = PrimaryGrantKind.Stateless; internal UAuthPrimaryCredentialPolicy Clone() => new() { diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthResetOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResetOptions.cs new file mode 100644 index 00000000..9abeec8d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResetOptions.cs @@ -0,0 +1,22 @@ +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthResetOptions +{ + public TimeSpan TokenValidity { get; set; } = TimeSpan.FromMinutes(30); + public TimeSpan CodeValidity { get; set; } = TimeSpan.FromMinutes(10); + public int MaxAttempts { get; set; } = 3; + + /// + /// Gets or sets the length for numeric reset codes. Does not affect token-based resets. + /// Default is 6, which means the code will be a 6-digit number. + /// + public int CodeLength { get; set; } = 6; + + internal UAuthResetOptions Clone() => new() + { + TokenValidity = TokenValidity, + CodeValidity = CodeValidity, + MaxAttempts = MaxAttempts, + CodeLength = CodeLength + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs index b586f272..45b677a3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs @@ -10,7 +10,7 @@ public sealed class UAuthServerEndpointOptions public bool Login { get; set; } = true; public bool Pkce { get; set; } = true; - public bool Token { get; set; } = true; + //public bool Token { get; set; } = true; public bool Session { get; set; } = true; //public bool UserInfo { get; set; } = true; @@ -21,17 +21,22 @@ public sealed class UAuthServerEndpointOptions public bool Authorization { get; set; } = true; + public HashSet DisabledActions { get; set; } = new(); + + public bool IsDisabled(string action) => DisabledActions.Contains(action); + internal UAuthServerEndpointOptions Clone() => new() { Login = Login, Pkce = Pkce, - Token = Token, + //Token = Token, Session = Session, //UserInfo = UserInfo, UserLifecycle = UserLifecycle, UserProfile = UserProfile, UserIdentifier = UserIdentifier, Credentials = Credentials, - Authorization = Authorization + Authorization = Authorization, + DisabledActions = new HashSet(DisabledActions) }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 17dbebe1..34f41c83 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -74,6 +74,8 @@ public sealed class UAuthServerOptions public UAuthResponseOptions AuthResponse { get; init; } = new(); + public UAuthResetOptions ResetCredential { get; init; } = new(); + public UAuthHubServerOptions Hub { get; set; } = new(); /// @@ -87,7 +89,9 @@ public sealed class UAuthServerOptions /// public UAuthServerEndpointOptions Endpoints { get; set; } = new(); - public UAuthUserIdentifierOptions UserIdentifiers { get; set; } = new(); + public UAuthIdentifierOptions Identifiers { get; set; } = new(); + + public UAuthIdentifierValidationOptions IdentifierValidation { get; set; } = new(); public UAuthLoginIdentifierOptions LoginIdentifiers { get; set; } = new(); @@ -138,9 +142,11 @@ internal UAuthServerOptions Clone() PrimaryCredential = PrimaryCredential.Clone(), AuthResponse = AuthResponse.Clone(), + ResetCredential = ResetCredential.Clone(), Hub = Hub.Clone(), SessionResolution = SessionResolution.Clone(), - UserIdentifiers = UserIdentifiers.Clone(), + Identifiers = Identifiers.Clone(), + IdentifierValidation = IdentifierValidation.Clone(), LoginIdentifiers = LoginIdentifiers.Clone(), Endpoints = Endpoints.Clone(), Navigation = Navigation.Clone(), diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs index 82af7684..3e8ce27b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs @@ -6,7 +6,7 @@ public sealed class UAuthServerUserIdentifierOptionsValidator : IValidateOptions { public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) { - if (!options.UserIdentifiers.AllowAdminOverride && !options.UserIdentifiers.AllowUserOverride) + if (!options.Identifiers.AllowAdminOverride && !options.Identifiers.AllowUserOverride) { return ValidateOptionsResult.Fail("Both AllowAdminOverride and AllowUserOverride cannot be false. " + "At least one actor must be able to manage user identifiers."); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs new file mode 100644 index 00000000..3078e595 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Services; + +public interface ISessionApplicationService +{ + Task> GetUserChainsAsync(AccessContext context,UserKey userKey, PageRequest request, CancellationToken ct = default); + + Task GetUserChainDetailAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default); + + Task RevokeUserSessionAsync(AccessContext context, UserKey userKey, AuthSessionId sessionId, CancellationToken ct = default); + + Task RevokeUserChainAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default); + + Task RevokeOtherChainsAsync(AccessContext context, UserKey userKey, SessionChainId? currentChainId, CancellationToken ct = default); + + Task RevokeAllChainsAsync(AccessContext context, UserKey userKey, SessionChainId? exceptChainId, CancellationToken ct = default); + Task LogoutDeviceAsync(AccessContext context, SessionChainId currentChainId, CancellationToken ct = default); + Task LogoutOtherDevicesAsync(AccessContext context, UserKey userKey, SessionChainId currentChainId, CancellationToken ct = default); + Task LogoutAllDevicesAsync(AccessContext context, UserKey userKey, CancellationToken ct = default); + + Task RevokeRootAsync(AccessContext context, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs new file mode 100644 index 00000000..8e6d6ad9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs @@ -0,0 +1,271 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Server.Services; + +internal sealed class SessionApplicationService : ISessionApplicationService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly ISessionStoreFactory _storeFactory; + private readonly IClock _clock; + + public SessionApplicationService(IAccessOrchestrator accessOrchestrator, ISessionStoreFactory storeFactory, IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _storeFactory = storeFactory; + _clock = clock; + } + + public async Task> GetUserChainsAsync(AccessContext context, UserKey userKey, PageRequest request, CancellationToken ct = default) + { + var command = new AccessCommand>(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + request = request.Normalize(); + var chains = await store.GetChainsByUserAsync(userKey); + var actorChainId = context.ActorChainId; + + if (!string.IsNullOrWhiteSpace(request.SortBy)) + { + chains = request.SortBy switch + { + nameof(SessionChainSummaryDto.ChainId) => request.Descending + ? chains.OrderByDescending(x => x.ChainId).ToList() + : chains.OrderBy(x => x.Version).ToList(), + + nameof(SessionChainSummaryDto.CreatedAt) => request.Descending + ? chains.OrderByDescending(x => x.CreatedAt).ToList() + : chains.OrderBy(x => x.Version).ToList(), + + nameof(SessionChainSummaryDto.LastSeenAt) => request.Descending + ? chains.OrderByDescending(x => x.LastSeenAt).ToList() + : chains.OrderBy(x => x.LastSeenAt).ToList(), + + nameof(SessionChainSummaryDto.RevokedAt) => request.Descending + ? chains.OrderByDescending(x => x.RevokedAt).ToList() + : chains.OrderBy(x => x.RevokedAt).ToList(), + + nameof(SessionChainSummaryDto.DeviceType) => request.Descending + ? chains.OrderByDescending(x => x.Device.DeviceType).ToList() + : chains.OrderBy(x => x.Device.DeviceType).ToList(), + + nameof(SessionChainSummaryDto.OperatingSystem) => request.Descending + ? chains.OrderByDescending(x => x.Device.OperatingSystem).ToList() + : chains.OrderBy(x => x.Device.OperatingSystem).ToList(), + + nameof(SessionChainSummaryDto.Platform) => request.Descending + ? chains.OrderByDescending(x => x.Device.Platform).ToList() + : chains.OrderBy(x => x.Device.Platform).ToList(), + + nameof(SessionChainSummaryDto.Browser) => request.Descending + ? chains.OrderByDescending(x => x.Device.Browser).ToList() + : chains.OrderBy(x => x.Device.Browser).ToList(), + + nameof(SessionChainSummaryDto.RotationCount) => request.Descending + ? chains.OrderByDescending(x => x.RotationCount).ToList() + : chains.OrderBy(x => x.RotationCount).ToList(), + + nameof(SessionChainSummaryDto.TouchCount) => request.Descending + ? chains.OrderByDescending(x => x.TouchCount).ToList() + : chains.OrderBy(x => x.TouchCount).ToList(), + + _ => chains + }; + } + + var total = chains.Count; + + var pageItems = chains + .Skip((request.PageNumber - 1) * request.PageSize) + .Take(request.PageSize) + .Select(c => new SessionChainSummaryDto + { + ChainId = c.ChainId, + DeviceType = c.Device.DeviceType, + OperatingSystem = c.Device.OperatingSystem, + Platform = c.Device.Platform, + Browser = c.Device.Browser, + CreatedAt = c.CreatedAt, + LastSeenAt = c.LastSeenAt, + RotationCount = c.RotationCount, + TouchCount = c.TouchCount, + IsRevoked = c.IsRevoked, + RevokedAt = c.RevokedAt, + ActiveSessionId = c.ActiveSessionId, + IsCurrentDevice = actorChainId.HasValue && c.ChainId == actorChainId.Value, + State = c.State, + }) + .ToList(); + + return new PagedResult(pageItems, total, request.PageNumber, request.PageSize, request.SortBy, request.Descending); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task GetUserChainDetailAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + var chain = await store.GetChainAsync(chainId) ?? throw new UAuthNotFoundException("chain_not_found"); + + if (chain.UserKey != userKey) + throw new UAuthValidationException("User conflict."); + + var sessions = await store.GetSessionsByChainAsync(chainId); + + return new SessionChainDetailDto + { + ChainId = chain.ChainId, + DeviceType = chain.Device.DeviceType, + OperatingSystem = chain.Device.OperatingSystem, + Platform = chain.Device.Platform, + Browser = chain.Device.Browser, + CreatedAt = chain.CreatedAt, + LastSeenAt = chain.LastSeenAt, + State = chain.State, + RotationCount = chain.RotationCount, + TouchCount = chain.TouchCount, + IsRevoked = chain.IsRevoked, + RevokedAt = chain.RevokedAt, + ActiveSessionId = chain.ActiveSessionId, + + Sessions = sessions + .OrderByDescending(x => x.CreatedAt) + .Select(s => new SessionInfoDto( + s.SessionId, + s.CreatedAt, + s.ExpiresAt, + s.IsRevoked)) + .ToList() + }; + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task RevokeUserSessionAsync(AccessContext context, UserKey userKey, AuthSessionId sessionId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + var now = _clock.UtcNow; + + var session = await store.GetSessionAsync(sessionId) + ?? throw new InvalidOperationException("session_not_found"); + + if (session.UserKey != userKey) + throw new UnauthorizedAccessException(); + + var expected = session.Version; + var revoked = session.Revoke(now); + + await store.SaveSessionAsync(revoked, expected); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task RevokeUserChainAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var isCurrent = context.ActorChainId == chainId; + var store = _storeFactory.Create(context.ResourceTenant); + await store.RevokeChainCascadeAsync(chainId, _clock.UtcNow); + + return new RevokeResult + { + CurrentChain = isCurrent, + RootRevoked = false + }; + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task RevokeOtherChainsAsync(AccessContext context, UserKey userKey, SessionChainId? currentChainId, CancellationToken ct = default) + { + await RevokeAllChainsAsync(context, userKey, currentChainId, ct); + } + + public async Task RevokeAllChainsAsync(AccessContext context, UserKey userKey, SessionChainId? exceptChainId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + var chains = await store.GetChainsByUserAsync(userKey); + + foreach (var chain in chains) + { + if (exceptChainId.HasValue && chain.ChainId == exceptChainId.Value) + continue; + + await store.RevokeChainCascadeAsync(chain.ChainId, _clock.UtcNow); + } + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task LogoutDeviceAsync(AccessContext context, SessionChainId currentChainId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var isCurrent = context.ActorChainId == currentChainId; + var store = _storeFactory.Create(context.ResourceTenant); + var now = _clock.UtcNow; + + await store.LogoutChainAsync(currentChainId, now, innerCt); + + return new RevokeResult + { + CurrentChain = isCurrent, + RootRevoked = false + }; + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task LogoutOtherDevicesAsync(AccessContext context, UserKey userKey, SessionChainId currentChainId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + var now = _clock.UtcNow; + + await store.RevokeOtherSessionsAsync(userKey, currentChainId, now, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task LogoutAllDevicesAsync(AccessContext context, UserKey userKey, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + var now = _clock.UtcNow; + + await store.RevokeAllSessionsAsync(userKey, now, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task RevokeRootAsync(AccessContext context, UserKey userKey, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + await store.RevokeRootCascadeAsync(userKey, _clock.UtcNow); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index 1ad74409..caafda9e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -31,21 +31,6 @@ public UAuthFlowService( _events = events; } - public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - - public Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - - public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - public Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) { return _loginOrchestrator.LoginAsync(flow, request, ct); @@ -106,4 +91,19 @@ public Task ReauthenticateAsync(ReauthRequest request, Cancellatio { throw new NotImplementedException(); } + + public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs deleted file mode 100644 index 7cd1b428..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs +++ /dev/null @@ -1,50 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Extensions; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -// TODO: Add wrapper service in client project. Validate method also may add. -namespace CodeBeam.UltimateAuth.Server.Services; - -internal sealed class UAuthSessionManager : IUAuthSessionManager -{ - private readonly IAuthFlowContextAccessor _authFlow; - private readonly ISessionOrchestrator _orchestrator; - private readonly IClock _clock; - - public UAuthSessionManager(IAuthFlowContextAccessor authFlow, ISessionOrchestrator orchestrator, IClock clock) - { - _authFlow = authFlow; - _orchestrator = orchestrator; - _clock = clock; - } - - public Task RevokeSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) - { - var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); - var command = new RevokeSessionCommand(sessionId); - return _orchestrator.ExecuteAsync(authContext, command, ct); - } - - public Task RevokeChainAsync(SessionChainId chainId, CancellationToken ct = default) - { - var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); - var command = new RevokeChainCommand(chainId); - return _orchestrator.ExecuteAsync(authContext, command, ct); - } - - public Task RevokeAllChainsAsync(UserKey userKey, SessionChainId? exceptChainId, CancellationToken ct = default) - { - var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); - var command = new RevokeAllChainsCommand(userKey, exceptChainId); - return _orchestrator.ExecuteAsync(authContext, command, ct); - } - - public Task RevokeRootAsync(UserKey userKey, CancellationToken ct = default) - { - var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); - var command = new RevokeRootCommand(userKey); - return _orchestrator.ExecuteAsync(authContext, command, ct); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs index 04d34fe4..2e35a6cb 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs @@ -6,11 +6,11 @@ namespace CodeBeam.UltimateAuth.Server.Services; public sealed class UAuthSessionQueryService : ISessionQueryService { - private readonly ISessionStoreKernelFactory _storeFactory; + private readonly ISessionStoreFactory _storeFactory; private readonly IAuthFlowContextAccessor _authFlow; public UAuthSessionQueryService( - ISessionStoreKernelFactory storeFactory, + ISessionStoreFactory storeFactory, IAuthFlowContextAccessor authFlow) { _storeFactory = storeFactory; @@ -37,7 +37,7 @@ public Task> GetChainsByUserAsync(UserKey userK return CreateKernel().GetChainIdBySessionAsync(sessionId); } - private ISessionStoreKernel CreateKernel() + private ISessionStore CreateKernel() { var tenantId = _authFlow.Current.Tenant; return _storeFactory.Create(tenantId); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs index 74abecdc..da89b7c9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -9,14 +9,11 @@ namespace CodeBeam.UltimateAuth.Server.Services; internal sealed class UAuthSessionValidator : ISessionValidator { - private readonly ISessionStoreKernelFactory _storeFactory; + private readonly ISessionStoreFactory _storeFactory; private readonly IUserClaimsProvider _claimsProvider; private readonly UAuthServerOptions _options; - public UAuthSessionValidator( - ISessionStoreKernelFactory storeFactory, - IUserClaimsProvider claimsProvider, - IOptions options) + public UAuthSessionValidator(ISessionStoreFactory storeFactory, IUserClaimsProvider claimsProvider, IOptions options) { _storeFactory = storeFactory; _claimsProvider = claimsProvider; @@ -33,27 +30,54 @@ public async Task ValidateSessionAsync(SessionValidatio if (session is null) return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId); - var state = session.GetState(context.Now, _options.Session.IdleTimeout); + var state = session.GetState(context.Now); if (state != SessionState.Active) - return SessionValidationResult.Invalid(state, sessionId: session.SessionId, chainId: session.ChainId); + return SessionValidationResult.Invalid(state, session.UserKey, session.SessionId, session.ChainId); var chain = await kernel.GetChainAsync(session.ChainId); if (chain is null || chain.IsRevoked) return SessionValidationResult.Invalid(SessionState.Revoked, session.UserKey, session.SessionId, session.ChainId); - var root = await kernel.GetSessionRootByUserAsync(session.UserKey); + var chainState = chain.GetState(context.Now, _options.Session.IdleTimeout); + if (chainState != SessionState.Active) + return SessionValidationResult.Invalid(chainState, chain.UserKey, session.SessionId, chain.ChainId); + + //if (chain.ActiveSessionId != session.SessionId) + // return SessionValidationResult.Invalid(SessionState.SecurityMismatch, chain.UserKey, session.SessionId, chain.ChainId); + + if (chain.ChainId != session.ChainId) + return SessionValidationResult.Invalid(SessionState.SecurityMismatch, chain.UserKey, session.SessionId, chain.ChainId); + + if (chain.Tenant != context.Tenant) + return SessionValidationResult.Invalid(SessionState.SecurityMismatch, chain.UserKey, session.SessionId, chain.ChainId); + + var root = await kernel.GetRootByUserAsync(session.UserKey); if (root is null || root.IsRevoked) - return SessionValidationResult.Invalid(SessionState.Revoked, session.UserKey, session.SessionId, session.ChainId, root?.RootId); + return SessionValidationResult.Invalid(SessionState.Revoked, chain.UserKey, session.SessionId, chain.ChainId, root?.RootId); + + if (chain.RootId != root.RootId) + return SessionValidationResult.Invalid(SessionState.SecurityMismatch, chain.UserKey, session.SessionId, chain.ChainId, root.RootId); if (session.SecurityVersionAtCreation != root.SecurityVersion) return SessionValidationResult.Invalid(SessionState.SecurityMismatch, session.UserKey, session.SessionId, session.ChainId, root.RootId); - // TODO: Implement device id, AllowAndRebind behavior and check device mathing in blazor server circuit and external http calls. - // Currently this line has error on refresh flow. - //if (!session.Device.Matches(context.Device) && _options.Session.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) - // return SessionValidationResult.Invalid(SessionState.DeviceMismatch); + if (chain.Device.HasDeviceId && context.Device.HasDeviceId) + { + if (!Equals(chain.Device.DeviceId, context.Device.DeviceId)) + { + if (_options.Session.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) + return SessionValidationResult.Invalid(SessionState.DeviceMismatch, chain.UserKey, session.SessionId, chain.ChainId, root.RootId); + + //if (_options.Session.DeviceMismatchBehavior == AllowAndRebind) + } + } + //else + //{ + // // Add SessionValidatorOrigin to seperate UserRequest or background task + // return SessionValidationResult.Invalid(SessionState.DeviceMismatch, chain.UserKey, session.SessionId, chain.ChainId, root.RootId); + //} var claims = await _claimsProvider.GetClaimsAsync(context.Tenant, session.UserKey, ct); - return SessionValidationResult.Active(context.Tenant, session.UserKey, session.SessionId, session.ChainId, root.RootId, claims, session.CreatedAt, session.Device.DeviceId); + return SessionValidationResult.Active(context.Tenant, session.UserKey, session.SessionId, session.ChainId, root.RootId, claims, session.CreatedAt, chain.Device.DeviceId); } } diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/AssemblyVisibility.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/CodeBeam.UltimateAuth.Authentication.InMemory.csproj b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/CodeBeam.UltimateAuth.Authentication.InMemory.csproj new file mode 100644 index 00000000..0ad38403 --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/CodeBeam.UltimateAuth.Authentication.InMemory.csproj @@ -0,0 +1,16 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + \ No newline at end of file diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs new file mode 100644 index 00000000..8c7e25ab --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs @@ -0,0 +1,79 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Authentication.InMemory; + +internal sealed class InMemoryAuthenticationSecurityStateStore : IAuthenticationSecurityStateStore +{ + private readonly ConcurrentDictionary _byId = new(); + private readonly ConcurrentDictionary<(TenantKey, UserKey, AuthenticationSecurityScope, CredentialType?), Guid> _index = new(); + + public Task GetAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_index.TryGetValue((tenant, userKey, scope, credentialType), out var id) && _byId.TryGetValue(id, out var state)) + { + return Task.FromResult(state); + } + + return Task.FromResult(null); + } + + public Task AddAsync(AuthenticationSecurityState state, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (state.Tenant, state.UserKey, state.Scope, state.CredentialType); + + if (!_index.TryAdd(key, state.Id)) + throw new UAuthConflictException("security_state_already_exists"); + + if (!_byId.TryAdd(state.Id, state)) + { + _index.TryRemove(key, out _); + throw new UAuthConflictException("security_state_add_failed"); + } + + return Task.CompletedTask; + } + + public Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (state.Tenant, state.UserKey, state.Scope, state.CredentialType); + + if (!_index.TryGetValue(key, out var id) || id != state.Id) + throw new UAuthConflictException("security_state_index_corrupted"); + + if (!_byId.TryGetValue(state.Id, out var current)) + throw new UAuthNotFoundException("security_state_not_found"); + + if (current.SecurityVersion != expectedVersion) + throw new UAuthConflictException("security_state_version_conflict"); + + if (!_byId.TryUpdate(state.Id, state, current)) + throw new UAuthConflictException("security_state_update_conflict"); + + return Task.CompletedTask; + } + + public Task DeleteAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (tenant, userKey, scope, credentialType); + + if (!_index.TryRemove(key, out var id)) + return Task.CompletedTask; + + _byId.TryRemove(id, out _); + + return Task.CompletedTask; + } +} diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..ab406e99 --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Authentication.InMemory; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthInMemoryAuthenticationSecurity(this IServiceCollection services) + { + services.AddSingleton(); + + return services; + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs new file mode 100644 index 00000000..35626d73 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs @@ -0,0 +1,19 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public readonly record struct Permission(string Value) +{ + public static Permission From(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("permission_required"); + + return new Permission(value.Trim().ToLowerInvariant()); + } + + public static readonly Permission Wildcard = new("*"); + + public bool IsWildcard => Value == "*"; + public bool IsPrefix => Value.EndsWith(".*"); + + public override string ToString() => Value; +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/RoleId.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/RoleId.cs new file mode 100644 index 00000000..bc051dfb --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/RoleId.cs @@ -0,0 +1,24 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public readonly record struct RoleId(Guid Value) : IParsable +{ + public static RoleId New() => new(Guid.NewGuid()); + + public static RoleId From(Guid guid) => new(guid); + + public static RoleId Parse(string s, IFormatProvider? provider) => new(Guid.Parse(s)); + + public static bool TryParse(string? s, IFormatProvider? provider, out RoleId result) + { + if (Guid.TryParse(s, out var guid)) + { + result = new RoleId(guid); + return true; + } + + result = default; + return false; + } + + public override string ToString() => Value.ToString(); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/PermissionDto.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/PermissionDto.cs deleted file mode 100644 index 10baf330..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/PermissionDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Authorization.Contracts; - -public sealed record PermissionDto -{ - public required string Value { get; init; } -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleDto.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleDto.cs deleted file mode 100644 index cc50b191..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Authorization.Contracts; - -public sealed record RoleDto -{ - public required string Name { get; init; } -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleInfo.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleInfo.cs new file mode 100644 index 00000000..67c61d8e --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleInfo.cs @@ -0,0 +1,13 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record RoleInfo +{ + public RoleId Id { get; init; } + public required string Name { get; init; } + + public IReadOnlyCollection Permissions { get; init; } = Array.Empty(); + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset? UpdatedAt { get; init; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleQuery.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleQuery.cs new file mode 100644 index 00000000..f9515966 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleQuery.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class RoleQuery : PageRequest +{ + public string? Search { get; set; } + public bool IncludeDeleted { get; set; } +} \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/UserRoleInfo.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/UserRoleInfo.cs new file mode 100644 index 00000000..8dcca75b --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/UserRoleInfo.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public class UserRoleInfo +{ + public TenantKey Tenant { get; init; } + public UserKey UserKey { get; init; } + public RoleId RoleId { get; init; } + public string Name { get; set; } = default!; + public DateTimeOffset AssignedAt { get; init; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/CompiledPermissionSet.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/CompiledPermissionSet.cs new file mode 100644 index 00000000..e1214ce1 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/CompiledPermissionSet.cs @@ -0,0 +1,47 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class CompiledPermissionSet +{ + private readonly HashSet _exact = new(); + private readonly HashSet _prefix = new(); + private readonly bool _hasWildcard; + + public CompiledPermissionSet(IEnumerable permissions) + { + foreach (var p in permissions) + { + var value = p.Value; + + if (value == "*") + { + _hasWildcard = true; + continue; + } + + if (value.EndsWith(".*")) + { + _prefix.Add(value[..^2]); + continue; + } + + _exact.Add(value); + } + } + + public bool IsAllowed(string action) + { + if (_hasWildcard) + return true; + + if (_exact.Contains(action)) + return true; + + foreach (var prefix in _prefix) + { + if (action.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionExpander.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionExpander.cs new file mode 100644 index 00000000..c076dbd4 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionExpander.cs @@ -0,0 +1,29 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public static class PermissionExpander +{ + public static IReadOnlyCollection Expand(IEnumerable stored, IEnumerable catalog) + { + var result = new HashSet(); + + foreach (var perm in stored) + { + if (perm.IsWildcard) + { + result.UnionWith(catalog); + continue; + } + + if (perm.IsPrefix) + { + var prefix = perm.Value[..^2]; + result.UnionWith(catalog.Where(x => x.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase))); + continue; + } + + result.Add(perm.Value); + } + + return result.Select(Permission.From).ToArray(); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionNormalizer.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionNormalizer.cs new file mode 100644 index 00000000..a9f64276 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionNormalizer.cs @@ -0,0 +1,43 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public static class PermissionNormalizer +{ + public static IReadOnlyCollection Normalize(IEnumerable permissions, IEnumerable catalog) + { + var selected = new HashSet(permissions.Select(p => p.Value), StringComparer.OrdinalIgnoreCase); + + if (selected.Contains("*")) + return new[] { Permission.Wildcard }; + + if (selected.Count == catalog.Count() && catalog.All(selected.Contains)) + { + return new[] { Permission.Wildcard }; + } + + var result = new HashSet(); + + var catalogGroups = catalog.GroupBy(p => p.Split('.')[0]); + + foreach (var group in catalogGroups) + { + var prefix = group.Key; + + var allPermissions = group.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var selectedInGroup = selected + .Where(p => p.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (selectedInGroup.SetEquals(allPermissions)) + { + result.Add(prefix + ".*"); + } + else + { + result.UnionWith(selectedInGroup); + } + } + + return result.Select(Permission.From).ToArray(); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/UAuthPermissionCatalog.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/UAuthPermissionCatalog.cs new file mode 100644 index 00000000..b3a0b59a --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/UAuthPermissionCatalog.cs @@ -0,0 +1,40 @@ +using CodeBeam.UltimateAuth.Core.Defaults; +using System.Reflection; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public static class UAuthPermissionCatalog +{ + public static IReadOnlyList GetAll() + { + var result = new List(); + Collect(typeof(UAuthActions), result); + return result; + } + + public static IReadOnlyList GetAdminPermissions() + { + return GetAll() + .Where(x => x.EndsWith(".admin", StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + private static void Collect(Type type, List result) + { + foreach (var field in type.GetFields( + BindingFlags.Public | + BindingFlags.Static | + BindingFlags.FlattenHierarchy)) + { + if (field.IsLiteral && field.FieldType == typeof(string)) + { + result.Add((string)field.GetValue(null)!); + } + } + + foreach (var nested in type.GetNestedTypes()) + { + Collect(nested, result); + } + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/CreateRoleRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/CreateRoleRequest.cs new file mode 100644 index 00000000..539f7f86 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/CreateRoleRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class CreateRoleRequest +{ + public string Name { get; set; } = default!; + public IEnumerable? Permissions { get; set; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/DeleteRoleRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/DeleteRoleRequest.cs new file mode 100644 index 00000000..cdc013f3 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/DeleteRoleRequest.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class DeleteRoleRequest +{ + public DeleteMode Mode { get; set; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RenameRoleRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RenameRoleRequest.cs new file mode 100644 index 00000000..4d33d28e --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RenameRoleRequest.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class RenameRoleRequest +{ + public string Name { get; set; } = default!; +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/SetPermissionsRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/SetPermissionsRequest.cs new file mode 100644 index 00000000..be5237b6 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/SetPermissionsRequest.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class SetPermissionsRequest +{ + public IEnumerable Permissions { get; set; } = []; +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/DeleteRoleResult.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/DeleteRoleResult.cs new file mode 100644 index 00000000..ce6c06b1 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/DeleteRoleResult.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class DeleteRoleResult +{ + public RoleId RoleId { get; init; } + + public int RemovedAssignments { get; init; } + + public DeleteMode Mode { get; init; } + + public DateTimeOffset DeletedAt { get; init; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs index d345e84e..1acc9da8 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs @@ -1,9 +1,10 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Authorization.Contracts; public sealed record UserRolesResponse { public required UserKey UserKey { get; init; } - public required IReadOnlyCollection Roles { get; init; } + public required PagedResult Roles { get; init; } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs index d4698be1..b62ad6b2 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization.Reference; +using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -8,6 +9,7 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthAuthorizationInMemory(this IServiceCollection services) { + services.TryAddSingleton(); services.TryAddSingleton(); // Never try add - seeding is enumerated and all contributors are added. diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs index 7bc3b7ac..5cb76c78 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -9,22 +11,32 @@ internal sealed class InMemoryAuthorizationSeedContributor : ISeedContributor { public int Order => 20; + private readonly IRoleStore _roleStore; private readonly IUserRoleStore _roles; private readonly IInMemoryUserIdProvider _ids; + private readonly IClock _clock; - public InMemoryAuthorizationSeedContributor(IUserRoleStore roles, IInMemoryUserIdProvider ids) + public InMemoryAuthorizationSeedContributor(IRoleStore roleStore, IUserRoleStore roles, IInMemoryUserIdProvider ids, IClock clock) { + _roleStore = roleStore; _roles = roles; _ids = ids; + _clock = clock; } public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) { + var adminRoleId = RoleId.From(Guid.NewGuid()); + var userRoleId = RoleId.From(Guid.NewGuid()); + var now = _clock.UtcNow; + await _roleStore.AddAsync(Role.Create(adminRoleId, tenant, "Admin", new HashSet() { Permission.Wildcard }, _clock.UtcNow)); + await _roleStore.AddAsync(Role.Create(userRoleId, tenant, "User", null, _clock.UtcNow)); + var adminKey = _ids.GetAdminUserId(); - await _roles.AssignAsync(tenant, adminKey, "Admin", ct); - await _roles.AssignAsync(tenant, adminKey, "User", ct); + await _roles.AssignAsync(tenant, adminKey, adminRoleId, now, ct); + await _roles.AssignAsync(tenant, adminKey, userRoleId, now, ct); var userKey = _ids.GetUserUserId(); - await _roles.AssignAsync(tenant, userKey, "User", ct); + await _roles.AssignAsync(tenant, userKey, userRoleId, now, ct); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs new file mode 100644 index 00000000..81843e70 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs @@ -0,0 +1,128 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.InMemory; + +internal sealed class InMemoryRoleStore : InMemoryVersionedStore, IRoleStore +{ + protected override RoleKey GetKey(Role entity) => new(entity.Tenant, entity.Id); + + protected override void BeforeAdd(Role entity) + { + if (Values().Any(r => + r.Tenant == entity.Tenant && + r.NormalizedName == entity.NormalizedName && + !r.IsDeleted)) + { + throw new UAuthConflictException("role_already_exists"); + } + } + + protected override void BeforeSave(Role entity, Role current, long expectedVersion) + { + if (entity.NormalizedName != current.NormalizedName) + { + if (Values().Any(r => + r.Tenant == entity.Tenant && + r.NormalizedName == entity.NormalizedName && + r.Id != entity.Id && + !r.IsDeleted)) + { + throw new UAuthConflictException("role_name_already_exists"); + } + } + } + + public Task GetByNameAsync(TenantKey tenant, string normalizedName, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var role = Values() + .FirstOrDefault(r => + r.Tenant == tenant && + r.NormalizedName == normalizedName && + !r.IsDeleted); + + return Task.FromResult(role); + } + + public Task> GetByIdsAsync(TenantKey tenant, IReadOnlyCollection roleIds, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var result = new List(roleIds.Count); + + foreach (var id in roleIds) + { + if (TryGet(new RoleKey(tenant, id), out var role) && role is not null) + { + result.Add(role.Snapshot()); + } + } + + return Task.FromResult>(result); + } + + public Task> QueryAsync(TenantKey tenant, RoleQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var normalized = query.Normalize(); + + var baseQuery = Values() + .Where(r => r.Tenant == tenant); + + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(r => !r.IsDeleted); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim().ToUpperInvariant(); + + baseQuery = baseQuery.Where(r => + r.NormalizedName.Contains(search)); + } + + baseQuery = query.SortBy switch + { + nameof(Role.CreatedAt) => query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), + + nameof(Role.UpdatedAt) => query.Descending + ? baseQuery.OrderByDescending(x => x.UpdatedAt) + : baseQuery.OrderBy(x => x.UpdatedAt), + + nameof(Role.Name) => query.Descending + ? baseQuery.OrderByDescending(x => x.Name) + : baseQuery.OrderBy(x => x.Name), + + nameof(Role.NormalizedName) => query.Descending + ? baseQuery.OrderByDescending(x => x.NormalizedName) + : baseQuery.OrderBy(x => x.NormalizedName), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var totalCount = baseQuery.Count(); + + var items = baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .ToList() + .AsReadOnly(); + + return Task.FromResult( + new PagedResult( + items, + totalCount, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending)); + } +} \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs index 4616d46c..6bcb7e71 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Collections.Concurrent; @@ -6,48 +7,98 @@ namespace CodeBeam.UltimateAuth.Authorization.InMemory; internal sealed class InMemoryUserRoleStore : IUserRoleStore { - private readonly ConcurrentDictionary<(TenantKey Tenant, UserKey UserKey), HashSet> _roles = new(); + private readonly ConcurrentDictionary<(TenantKey, UserKey), List> _assignments = new(); - public Task> GetRolesAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + public Task> GetAssignmentsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_roles.TryGetValue((tenant, userKey), out var set)) + if (_assignments.TryGetValue((tenant, userKey), out var list)) { - lock (set) + lock (list) + return Task.FromResult>(list.ToArray()); + } + + return Task.FromResult>(Array.Empty()); + } + + public Task AssignAsync(TenantKey tenant, UserKey userKey, RoleId roleId, DateTimeOffset assignedAt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var list = _assignments.GetOrAdd((tenant, userKey), _ => new List()); + + lock (list) + { + if (list.Any(x => x.RoleId == roleId)) + return Task.CompletedTask; + + list.Add(new UserRole { - return Task.FromResult>(set.ToArray()); - } + Tenant = tenant, + UserKey = userKey, + RoleId = roleId, + AssignedAt = assignedAt + }); } - return Task.FromResult>(Array.Empty()); + return Task.CompletedTask; } - public Task AssignAsync(TenantKey tenant, UserKey userKey, string role, CancellationToken ct = default) + public Task RemoveAsync(TenantKey tenant, UserKey userKey, RoleId roleId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var set = _roles.GetOrAdd((tenant, userKey), _ => new HashSet(StringComparer.OrdinalIgnoreCase)); - lock (set) + if (_assignments.TryGetValue((tenant, userKey), out var list)) { - set.Add(role); + lock (list) + { + list.RemoveAll(x => x.RoleId == roleId); + } } return Task.CompletedTask; } - public Task RemoveAsync(TenantKey tenant, UserKey userKey, string role, CancellationToken ct = default) + public Task RemoveAssignmentsByRoleAsync(TenantKey tenant, RoleId roleId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_roles.TryGetValue((tenant, userKey), out var set)) + foreach (var kv in _assignments) { - lock (set) + if (kv.Key.Item1 != tenant) + continue; + + var list = kv.Value; + + lock (list) { - set.Remove(role); + list.RemoveAll(x => x.RoleId == roleId); } } return Task.CompletedTask; } + + public Task CountAssignmentsAsync(TenantKey tenant, RoleId roleId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var count = 0; + + foreach (var kv in _assignments) + { + if (kv.Key.Item1 != tenant) + continue; + + var list = kv.Value; + + lock (list) + { + count += list.Count(x => x.RoleId == roleId); + } + } + + return Task.FromResult(count); + } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs deleted file mode 100644 index 52d62db5..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Authorization.Reference; - -internal sealed class AssignUserRoleCommand : IAccessCommand -{ - private readonly Func _execute; - - public AssignUserRoleCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs deleted file mode 100644 index a57a1b16..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Authorization.Reference; - -internal sealed class GetUserRolesCommand : IAccessCommand> -{ - private readonly Func>> _execute; - - public GetUserRolesCommand(Func>> execute) - { - _execute = execute; - } - - public Task> ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs deleted file mode 100644 index 35693190..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Authorization.Reference; - -internal sealed class RemoveUserRoleCommand : IAccessCommand -{ - private readonly Func _execute; - - public RemoveUserRoleCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs deleted file mode 100644 index 2550b229..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs +++ /dev/null @@ -1,7 +0,0 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; - -public sealed class Role -{ - public required string Name { get; init; } - public IReadOnlyCollection Permissions { get; init; } = Array.Empty(); -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs index a86fb67b..1c93e22e 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Extensions; using Microsoft.AspNetCore.Http; @@ -12,17 +12,25 @@ public sealed class AuthorizationEndpointHandler : IAuthorizationEndpointHandler { private readonly IAuthFlowContextAccessor _authFlow; private readonly IAuthorizationService _authorization; - private readonly IUserRoleService _roles; + private readonly IUserRoleService _userRoles; + private readonly IRoleService _roles; private readonly IAccessContextFactory _accessContextFactory; - public AuthorizationEndpointHandler(IAuthFlowContextAccessor authFlow, IAuthorizationService authorization, IUserRoleService roles, IAccessContextFactory accessContextFactory) + public AuthorizationEndpointHandler( + IAuthFlowContextAccessor authFlow, + IAuthorizationService authorization, + IUserRoleService userRoles, + IRoleService roles, + IAccessContextFactory accessContextFactory) { _authFlow = authFlow; _authorization = authorization; + _userRoles = userRoles; _roles = roles; _accessContextFactory = accessContextFactory; } + public async Task CheckAsync(HttpContext ctx) { var flow = _authFlow.Current; @@ -57,8 +65,12 @@ public async Task CheckAsync(HttpContext ctx) public async Task GetMyRolesAsync(HttpContext ctx) { var flow = _authFlow.Current; + if (!flow.IsAuthenticated) return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + var accessContext = await _accessContextFactory.CreateAsync( flow, action: UAuthActions.Authorization.Roles.ReadSelf, @@ -66,7 +78,7 @@ public async Task GetMyRolesAsync(HttpContext ctx) resourceId: flow.UserKey!.Value ); - var roles = await _roles.GetRolesAsync(accessContext, flow.UserKey!.Value, ctx.RequestAborted); + var roles = await _userRoles.GetRolesAsync(accessContext, flow.UserKey!.Value, req, ctx.RequestAborted); return Results.Ok(new UserRolesResponse { UserKey = flow.UserKey!.Value, @@ -81,6 +93,8 @@ public async Task GetUserRolesAsync(UserKey userKey, HttpContext ctx) if (!flow.IsAuthenticated) return Results.Unauthorized(); + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + var accessContext = await _accessContextFactory.CreateAsync( flow, action: UAuthActions.Authorization.Roles.ReadAdmin, @@ -88,7 +102,7 @@ public async Task GetUserRolesAsync(UserKey userKey, HttpContext ctx) resourceId: userKey.Value ); - var roles = await _roles.GetRolesAsync(accessContext, userKey, ctx.RequestAborted); + var roles = await _userRoles.GetRolesAsync(accessContext, userKey, req, ctx.RequestAborted); return Results.Ok(new UserRolesResponse { @@ -112,7 +126,7 @@ public async Task AssignRoleAsync(UserKey userKey, HttpContext ctx) resourceId: userKey.Value ); - await _roles.AssignAsync(accessContext, userKey, req.Role, ctx.RequestAborted); + await _userRoles.AssignAsync(accessContext, userKey, req.Role, ctx.RequestAborted); return Results.Ok(); } @@ -131,7 +145,109 @@ public async Task RemoveRoleAsync(UserKey userKey, HttpContext ctx) resourceId: userKey.Value ); - await _roles.RemoveAsync(accessContext, userKey, req.Role, ctx.RequestAborted); + await _userRoles.RemoveAsync(accessContext, userKey, req.Role, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task CreateRoleAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.CreateAdmin, + resource: "authorization.roles" + ); + + var role = await _roles.CreateAsync(accessContext, req.Name, req.Permissions, ctx.RequestAborted); + + return Results.Ok(role); + } + + public async Task RenameRoleAsync(RoleId roleId, HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.RenameAdmin, + resource: "authorization.roles", + resourceId: roleId.ToString() + ); + + await _roles.RenameAsync(accessContext, roleId, req.Name, ctx.RequestAborted); + return Results.Ok(); } + + public async Task DeleteRoleAsync(RoleId roleId, HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.DeleteAdmin, + resource: "authorization.roles", + resourceId: roleId.ToString() + ); + + var result = await _roles.DeleteAsync(accessContext, roleId, req.Mode, ctx.RequestAborted); + + return Results.Ok(result); + } + public async Task SetRolePermissionsAsync(RoleId roleId, HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.SetPermissionsAdmin, + resource: "authorization.roles", + resourceId: roleId.ToString() + ); + + await _roles.SetPermissionsAsync(accessContext, roleId, req.Permissions, ctx.RequestAborted); + + return Results.Ok(); + } + + public async Task QueryRolesAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.QueryAdmin, + resource: "authorization.roles" + ); + + var result = await _roles.QueryAsync(accessContext, req, ctx.RequestAborted); + + return Results.Ok(result); + } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/ServiceCollectionExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/ServiceCollectionExtensions.cs index 3142d271..49c5f58a 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/ServiceCollectionExtensions.cs @@ -9,9 +9,10 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddUltimateAuthAuthorizationReference(this IServiceCollection services) { services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); return services; diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs index 4f5d5a7b..e30a12a9 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs @@ -1,34 +1,33 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization.Reference; -public sealed class RolePermissionResolver : IRolePermissionResolver +internal sealed class RolePermissionResolver : IRolePermissionResolver { - private static readonly IReadOnlyDictionary _map - = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["admin"] = new[] - { - new Permission("*") - }, - ["user"] = new[] - { - new Permission("profile.read"), - new Permission("profile.update") - } - }; - - public Task> ResolveAsync(TenantKey tenant, IEnumerable roles, CancellationToken ct = default) + private readonly IRoleStore _roles; + + public RolePermissionResolver(IRoleStore roles) { - var result = new List(); + _roles = roles; + } + + public async Task> ResolveAsync(TenantKey tenant, IReadOnlyCollection roleIds, CancellationToken ct = default) + { + if (roleIds.Count == 0) + return Array.Empty(); + + var roles = await _roles.GetByIdsAsync(tenant, roleIds, ct); + + var permissions = new HashSet(); foreach (var role in roles) { - if (_map.TryGetValue(role, out var perms)) - result.AddRange(perms); + foreach (var perm in role.Permissions) + permissions.Add(perm); } - return Task.FromResult>(result); + return permissions.ToArray(); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs index 150eae91..b7ab7070 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs @@ -1,23 +1,24 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization.Reference; -public sealed class UserPermissionStore : IUserPermissionStore +internal sealed class UserPermissionStore : IUserPermissionStore { - private readonly IUserRoleStore _roles; + private readonly IUserRoleStore _userRoles; private readonly IRolePermissionResolver _resolver; - public UserPermissionStore(IUserRoleStore roles, IRolePermissionResolver resolver) + public UserPermissionStore(IUserRoleStore userRoles, IRolePermissionResolver resolver) { - _roles = roles; + _userRoles = userRoles; _resolver = resolver; } public async Task> GetPermissionsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var roles = await _roles.GetRolesAsync(tenant, userKey, ct); - return await _resolver.ResolveAsync(tenant, roles, ct); + var assignments = await _userRoles.GetAssignmentsAsync(tenant, userKey, ct); + var roleIds = assignments.Select(x => x.RoleId).ToArray(); + return await _resolver.ResolveAsync(tenant, roleIds, ct); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs index 3b01d601..720bd196 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs @@ -1,36 +1,36 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Policies.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Authorization.Reference; internal sealed class AuthorizationService : IAuthorizationService { - private readonly IAccessPolicyProvider _policyProvider; - private readonly IAccessAuthority _accessAuthority; + private readonly IAccessOrchestrator _accessOrchestrator; - public AuthorizationService(IAccessPolicyProvider policyProvider, IAccessAuthority accessAuthority) + public AuthorizationService(IAccessOrchestrator accessOrchestrator) { - _policyProvider = policyProvider; - _accessAuthority = accessAuthority; + _accessOrchestrator = accessOrchestrator; } - public Task AuthorizeAsync(AccessContext context, CancellationToken ct = default) + public async Task AuthorizeAsync(AccessContext context, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var policies = _policyProvider.GetPolicies(context); - var decision = _accessAuthority.Decide(context, policies); - - if (decision.RequiresReauthentication) - return Task.FromResult(AuthorizationResult.ReauthRequired()); - - return Task.FromResult( - decision.IsAllowed - ? AuthorizationResult.Allow() - : AuthorizationResult.Deny(decision.DenyReason) - ); + var cmd = new AccessCommand(innerCt => + { + return Task.FromResult(AuthorizationResult.Allow()); + }); + + try + { + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + return AuthorizationResult.Allow(); + } + catch (UAuthAuthorizationException ex) + { + return AuthorizationResult.Deny(ex.Message); + } } - } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs new file mode 100644 index 00000000..c5746fad --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs @@ -0,0 +1,126 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Authorization.Reference; + +internal sealed class RoleService : IRoleService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly IRoleStore _roles; + private readonly IUserRoleStore _userRoles; + private readonly IClock _clock; + + public RoleService( + IAccessOrchestrator accessOrchestrator, + IRoleStore roles, + IUserRoleStore userRoles, + IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _roles = roles; + _userRoles = userRoles; + _clock = clock; + } + + public async Task CreateAsync(AccessContext context, string name, IEnumerable? permissions, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var role = Role.Create(RoleId.New(), context.ResourceTenant, name, permissions, _clock.UtcNow); + await _roles.AddAsync(role, innerCt); + + return role; + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task RenameAsync(AccessContext context, RoleId roleId, string newName, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var key = new RoleKey(context.ResourceTenant, roleId); + var role = await _roles.GetAsync(key, innerCt); + + if (role is null || role.IsDeleted) + throw new UAuthNotFoundException("role_not_found"); + + var expected = role.Version; + role.Rename(newName, _clock.UtcNow); + + await _roles.SaveAsync(role, expected, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task DeleteAsync(AccessContext context, RoleId roleId, DeleteMode mode, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var key = new RoleKey(context.ResourceTenant, roleId); + + var role = await _roles.GetAsync(key, innerCt); + + if (role is null) + throw new UAuthNotFoundException("role_not_found"); + + var removed = await _userRoles.CountAssignmentsAsync(context.ResourceTenant, roleId, innerCt); + await _userRoles.RemoveAssignmentsByRoleAsync(context.ResourceTenant, roleId, innerCt); + await _roles.DeleteAsync(key, role.Version, mode, _clock.UtcNow, innerCt); + + return new DeleteRoleResult + { + RoleId = roleId, + RemovedAssignments = removed, + Mode = mode, + DeletedAt = _clock.UtcNow + }; + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task SetPermissionsAsync(AccessContext context, RoleId roleId, IEnumerable permissions, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var key = new RoleKey(context.ResourceTenant, roleId); + var role = await _roles.GetAsync(key, innerCt); + + if (role is null || role.IsDeleted) + throw new UAuthNotFoundException("role_not_found"); + + var expected = role.Version; + role.SetPermissions(permissions, _clock.UtcNow); + + await _roles.SaveAsync(role, expected, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task> QueryAsync(AccessContext context, RoleQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand>(async innerCt => + { + return await _roles.QueryAsync(context.ResourceTenant, query, innerCt); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs index 1def14bf..5d987173 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Infrastructure; @@ -7,52 +9,94 @@ namespace CodeBeam.UltimateAuth.Authorization.Reference; internal sealed class UserRoleService : IUserRoleService { private readonly IAccessOrchestrator _accessOrchestrator; - private readonly IUserRoleStore _store; + private readonly IUserRoleStore _userRoles; + private readonly IRoleStore _roles; + private readonly IClock _clock; - public UserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStore store) + public UserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStore userRoles, IRoleStore roles, IClock clock) { _accessOrchestrator = accessOrchestrator; - _store = store; + _userRoles = userRoles; + _roles = roles; + _clock = clock; } - public async Task AssignAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default) + public async Task AssignAsync(AccessContext context, UserKey targetUserKey, string roleName, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (string.IsNullOrWhiteSpace(role)) - throw new ArgumentException("role_empty", nameof(role)); + var now = _clock.UtcNow; - var cmd = new AssignUserRoleCommand( - async innerCt => - { - await _store.AssignAsync(context.ResourceTenant, targetUserKey, role, innerCt); - }); + var cmd = new AccessCommand(async innerCt => + { + var normalized = roleName.Trim().ToUpperInvariant(); + var role = await _roles.GetByNameAsync(context.ResourceTenant, normalized, innerCt); + + if (role is null || role.IsDeleted) + throw new InvalidOperationException("role_not_found"); + + await _userRoles.AssignAsync(context.ResourceTenant, targetUserKey, role.Id, now, innerCt); + }); await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - public async Task RemoveAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default) + public async Task RemoveAsync(AccessContext context, UserKey targetUserKey, string roleName, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (string.IsNullOrWhiteSpace(role)) - throw new ArgumentException("role_empty", nameof(role)); + var cmd = new AccessCommand(async innerCt => + { + var normalized = roleName.Trim().ToUpperInvariant(); + var role = await _roles.GetByNameAsync(context.ResourceTenant, normalized, innerCt); + + if (role is null) + return; - var cmd = new RemoveUserRoleCommand( - async innerCt => - { - await _store.RemoveAsync(context.ResourceTenant, targetUserKey, role, innerCt); - }); + await _userRoles.RemoveAsync(context.ResourceTenant, targetUserKey, role.Id, innerCt); + }); await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - - public async Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default) + public async Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, PageRequest request, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var cmd = new GetUserRolesCommand(innerCt => _store.GetRolesAsync(context.ResourceTenant, targetUserKey, innerCt)); + var cmd = new AccessCommand>(async innerCt => + { + request = request.Normalize(); + + var assignments = await _userRoles.GetAssignmentsAsync(context.ResourceTenant, targetUserKey, innerCt); + var roleIds = assignments.Select(x => x.RoleId).ToArray(); + var roles = await _roles.GetByIdsAsync(context.ResourceTenant, roleIds, innerCt); + + var roleMap = roles.ToDictionary(x => x.Id); + + var joined = assignments + .Where(a => roleMap.ContainsKey(a.RoleId)) + .Select(a => new UserRoleInfo + { + Tenant = a.Tenant, + UserKey = a.UserKey, + RoleId = a.RoleId, + AssignedAt = a.AssignedAt, + Name = roleMap[a.RoleId].Name + }) + .ToList(); + + var total = joined.Count; + + var pageItems = joined.Skip((request.PageNumber - 1) * request.PageSize).Take(request.PageSize).ToList(); + + return new PagedResult( + pageItems, + total, + request.PageNumber, + request.PageSize, + request.SortBy, + request.Descending); + }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs index d6e09167..119d5002 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs @@ -1,9 +1,10 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization; public interface IRolePermissionResolver { - Task> ResolveAsync(TenantKey tenant, IEnumerable roles, CancellationToken ct = default); + Task> ResolveAsync(TenantKey tenant, IReadOnlyCollection roles, CancellationToken ct = default); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs new file mode 100644 index 00000000..bc794624 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IRoleService +{ + Task CreateAsync(AccessContext context, string name, IEnumerable? permissions, CancellationToken ct = default); + + Task RenameAsync(AccessContext context, RoleId roleId, string newName, CancellationToken ct = default); + + Task DeleteAsync(AccessContext context, RoleId roleId, DeleteMode mode, CancellationToken ct = default); + + Task SetPermissionsAsync(AccessContext context, RoleId roleId, IEnumerable permissions, CancellationToken ct = default); + + Task> QueryAsync(AccessContext context, RoleQuery query, CancellationToken ct = default); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStore.cs new file mode 100644 index 00000000..5b263885 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStore.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IRoleStore : IVersionedStore +{ + Task GetByNameAsync(TenantKey tenant, string normalizedName, CancellationToken ct = default); + Task> GetByIdsAsync(TenantKey tenant, IReadOnlyCollection roleIds, CancellationToken ct = default); + Task> QueryAsync(TenantKey tenant, RoleQuery query, CancellationToken ct = default); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs index db519dc2..eb2aabaa 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs index 3c9a4f29..ba96b03d 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs @@ -1,11 +1,12 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Authorization; public interface IUserRoleService { - Task AssignAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default); - Task RemoveAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default); - Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default); + Task AssignAsync(AccessContext context, UserKey targetUserKey, string roleName, CancellationToken ct = default); + Task RemoveAsync(AccessContext context, UserKey targetUserKey, string roleName, CancellationToken ct = default); + Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, PageRequest request, CancellationToken ct = default); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs index 028f5f6a..a87fc1e3 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs @@ -1,11 +1,14 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization; public interface IUserRoleStore { - Task AssignAsync(TenantKey tenant, UserKey userKey, string role, CancellationToken ct = default); - Task RemoveAsync(TenantKey tenant, UserKey userKey, string role, CancellationToken ct = default); - Task> GetRolesAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task> GetAssignmentsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task AssignAsync(TenantKey tenant, UserKey userKey, RoleId roleId, DateTimeOffset assignedAt, CancellationToken ct = default); + Task RemoveAsync(TenantKey tenant, UserKey userKey, RoleId roleId, CancellationToken ct = default); + Task RemoveAssignmentsByRoleAsync(TenantKey tenant, RoleId roleId, CancellationToken ct = default); + Task CountAssignmentsAsync(TenantKey tenant, RoleId roleId, CancellationToken ct = default); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj b/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj index ce41f1eb..d1493e50 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj @@ -12,6 +12,7 @@ + diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs deleted file mode 100644 index 69dc0f07..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Authorization.Domain; - -public readonly record struct Permission(string Value) -{ - public override string ToString() => Value; -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs new file mode 100644 index 00000000..b008663c --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs @@ -0,0 +1,135 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization; + +public sealed class Role : IVersionedEntity, IEntitySnapshot, ISoftDeletable +{ + private readonly HashSet _permissions = new(); + + public RoleId Id { get; private set; } + public TenantKey Tenant { get; private set; } + + public string Name { get; private set; } = default!; + public string NormalizedName { get; private set; } = default!; + + public IReadOnlyCollection Permissions => _permissions; + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? UpdatedAt { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } + + public long Version { get; set; } + + public bool IsDeleted => DeletedAt != null; + + private Role() { } + + public static Role Create( + RoleId? id, + TenantKey tenant, + string name, + IEnumerable? permissions, + DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(name)) + throw new InvalidOperationException("role_name_required"); + + var normalized = Normalize(name); + + var role = new Role + { + Id = id ?? RoleId.New(), + Tenant = tenant, + Name = name.Trim(), + NormalizedName = normalized, + CreatedAt = now, + Version = 0 + }; + + if (permissions is not null) + { + foreach (var p in permissions) + role._permissions.Add(p); + } + + return role; + } + + public Role Rename(string newName, DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(newName)) + throw new InvalidOperationException("role_name_required"); + + if (NormalizedName == Normalize(newName)) + return this; + + Name = newName.Trim(); + NormalizedName = Normalize(newName); + UpdatedAt = now; + + return this; + } + + public Role AddPermission(Permission permission, DateTimeOffset now) + { + _permissions.Add(permission); + UpdatedAt = now; + return this; + } + + public Role RemovePermission(Permission permission, DateTimeOffset now) + { + _permissions.Remove(permission); + UpdatedAt = now; + return this; + } + + public Role SetPermissions(IEnumerable permissions, DateTimeOffset now) + { + _permissions.Clear(); + var normalized = PermissionNormalizer.Normalize(permissions, UAuthPermissionCatalog.GetAdminPermissions()); + + foreach (var p in normalized) + _permissions.Add(p); + + UpdatedAt = now; + return this; + } + + public Role MarkDeleted(DateTimeOffset now) + { + if (IsDeleted) + return this; + + DeletedAt = now; + UpdatedAt = now; + + return this; + } + + public Role Snapshot() + { + var copy = new Role + { + Id = Id, + Tenant = Tenant, + Name = Name, + NormalizedName = NormalizedName, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + DeletedAt = DeletedAt, + Version = Version + }; + + foreach (var p in _permissions) + copy._permissions.Add(p); + + return copy; + } + + private static string Normalize(string name) + => name.Trim().ToUpperInvariant(); +} \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleKey.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleKey.cs new file mode 100644 index 00000000..f52f8cfb --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleKey.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.Domain; + +public readonly record struct RoleKey( + TenantKey Tenant, + RoleId RoleId); \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/UserRole.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/UserRole.cs new file mode 100644 index 00000000..c0538ff0 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/UserRole.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization; + +public sealed class UserRole +{ + public TenantKey Tenant { get; init; } + public UserKey UserKey { get; init; } + public RoleId RoleId { get; init; } + public DateTimeOffset AssignedAt { get; init; } +} \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs similarity index 55% rename from src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs rename to src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs index eb58589c..94b705cb 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Security.Claims; @@ -8,28 +9,32 @@ namespace CodeBeam.UltimateAuth.Authorization; public sealed class AuthorizationClaimsProvider : IUserClaimsProvider { private readonly IUserRoleStore _roles; + private readonly IRoleStore _roleStore; private readonly IUserPermissionStore _permissions; - public AuthorizationClaimsProvider(IUserRoleStore roles, IUserPermissionStore permissions) + public AuthorizationClaimsProvider(IUserRoleStore roles, IRoleStore roleStore, IUserPermissionStore permissions) { _roles = roles; + _roleStore = roleStore; _permissions = permissions; } public async Task GetClaimsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var roles = await _roles.GetRolesAsync(tenant, userKey, ct); + var assignments = await _roles.GetAssignmentsAsync(tenant, userKey, ct); + var roleIds = assignments.Select(x => x.RoleId).Distinct().ToArray(); + var roles = await _roleStore.GetByIdsAsync(tenant, roleIds, ct); var perms = await _permissions.GetPermissionsAsync(tenant, userKey, ct); var builder = ClaimsSnapshot.Create(); - builder.Add("uauth:tenant", tenant.Value); + builder.Add(UAuthConstants.Claims.Tenant, tenant.Value); foreach (var role in roles) - builder.Add(ClaimTypes.Role, role); + builder.Add(ClaimTypes.Role, role.Name); foreach (var perm in perms) - builder.Add("uauth:permission", perm.Value); + builder.Add(UAuthConstants.Claims.Permission, perm.Value); return builder.Build(); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs deleted file mode 100644 index 2a6848a4..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs +++ /dev/null @@ -1,29 +0,0 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Authorization; - -public sealed class PermissionAccessPolicy : IAccessPolicy -{ - private readonly IReadOnlySet _permissions; - private readonly string _operation; - - public PermissionAccessPolicy(IEnumerable permissions, string operation) - { - _permissions = permissions.Select(p => p.Value).ToHashSet(StringComparer.OrdinalIgnoreCase); - _operation = operation; - } - - public bool AppliesTo(AccessContext context) => context.ActorUserKey is not null; - - public AccessDecision Decide(AccessContext context) - { - if (context.ActorUserKey is null) - return AccessDecision.Deny("unauthenticated"); - - return _permissions.Contains(_operation) - ? AccessDecision.Allow() - : AccessDecision.Deny("missing_permission"); - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Domain/CredentialKey.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Domain/CredentialKey.cs new file mode 100644 index 00000000..aa6a924e --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Domain/CredentialKey.cs @@ -0,0 +1,7 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public readonly record struct CredentialKey( + TenantKey Tenant, + Guid Id); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs index e236ba27..2c3df24a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs @@ -1,7 +1,10 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record CredentialDto { + public Guid Id { get; set; } public CredentialType Type { get; init; } public CredentialSecurityStatus Status { get; init; } @@ -10,12 +13,11 @@ public sealed record CredentialDto public DateTimeOffset? LastUsedAt { get; init; } - public DateTimeOffset? LockedUntil { get; init; } - public DateTimeOffset? ExpiresAt { get; init; } public DateTimeOffset? RevokedAt { get; init; } - public DateTimeOffset? ResetRequestedAt { get; init; } public string? Source { get; init; } + + public long Version { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs index 2291404c..27436d4a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs @@ -1,44 +1,37 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed class CredentialSecurityState { public DateTimeOffset? RevokedAt { get; } - public DateTimeOffset? LockedUntil { get; } public DateTimeOffset? ExpiresAt { get; } - public DateTimeOffset? ResetRequestedAt { get; init; } public Guid SecurityStamp { get; } - public CredentialSecurityStatus Status(DateTimeOffset now) - { - if (RevokedAt is not null) - return CredentialSecurityStatus.Revoked; - - if (LockedUntil is not null && LockedUntil > now) - return CredentialSecurityStatus.Locked; - - if (ExpiresAt is not null && ExpiresAt <= now) - return CredentialSecurityStatus.Expired; - - if (ResetRequestedAt is not null) - return CredentialSecurityStatus.ResetRequested; - - return CredentialSecurityStatus.Active; - } + public bool IsRevoked => RevokedAt != null; + public bool IsExpired => ExpiresAt != null; public CredentialSecurityState( DateTimeOffset? revokedAt = null, - DateTimeOffset? lockedUntil = null, DateTimeOffset? expiresAt = null, - DateTimeOffset? resetRequestedAt = null, Guid securityStamp = default) { RevokedAt = revokedAt; - LockedUntil = lockedUntil; ExpiresAt = expiresAt; - ResetRequestedAt = resetRequestedAt; SecurityStamp = securityStamp; } + public CredentialSecurityStatus Status(DateTimeOffset now) + { + if (RevokedAt is not null) + return CredentialSecurityStatus.Revoked; + + if (ExpiresAt is not null && ExpiresAt <= now) + return CredentialSecurityStatus.Expired; + + return CredentialSecurityStatus.Active; + } + /// /// Determines whether the credential can be used at the given time. /// @@ -48,57 +41,50 @@ public static CredentialSecurityState Active(Guid? securityStamp = null) { return new CredentialSecurityState( revokedAt: null, - lockedUntil: null, expiresAt: null, - resetRequestedAt: null, - securityStamp: securityStamp ?? Guid.NewGuid()); + securityStamp: securityStamp ?? Guid.NewGuid() + ); } + /// + /// Revokes the credential permanently. + /// public CredentialSecurityState Revoke(DateTimeOffset now) { + if (RevokedAt is not null) + return this; + return new CredentialSecurityState( revokedAt: now, - lockedUntil: LockedUntil, expiresAt: ExpiresAt, - resetRequestedAt: ResetRequestedAt, - securityStamp: Guid.NewGuid()); + securityStamp: Guid.NewGuid() + ); } + /// + /// Sets or clears expiry while preserving the rest of the state. + /// public CredentialSecurityState SetExpiry(DateTimeOffset? expiresAt) { + // optional: normalize already-expired value? keep as-is; domain policy can decide. + if (ExpiresAt == expiresAt) + return this; + return new CredentialSecurityState( revokedAt: RevokedAt, - lockedUntil: LockedUntil, expiresAt: expiresAt, - resetRequestedAt: ResetRequestedAt, - securityStamp: SecurityStamp); + securityStamp: EnsureStamp(SecurityStamp) + ); } - public CredentialSecurityState BeginReset(DateTimeOffset now, bool rotateStamp = true) - => new( - revokedAt: RevokedAt, - lockedUntil: LockedUntil, - expiresAt: ExpiresAt, - resetRequestedAt: now, - securityStamp: rotateStamp ? Guid.NewGuid() : SecurityStamp - ); - - public CredentialSecurityState CompleteReset(bool rotateStamp = true) - => new( - revokedAt: RevokedAt, - lockedUntil: LockedUntil, - expiresAt: ExpiresAt, - resetRequestedAt: null, - securityStamp: rotateStamp ? Guid.NewGuid() : SecurityStamp - ); + private static Guid EnsureStamp(Guid stamp) => stamp == Guid.Empty ? Guid.NewGuid() : stamp; public CredentialSecurityState RotateStamp() { return new CredentialSecurityState( revokedAt: RevokedAt, - lockedUntil: LockedUntil, expiresAt: ExpiresAt, - resetRequestedAt: ResetRequestedAt, - securityStamp: Guid.NewGuid()); + securityStamp: Guid.NewGuid() + ); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs index d90804d7..d75b3543 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public static class CredentialTypeParser { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs index 89a903c5..2f6b46dd 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record AddCredentialRequest() { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs index 98eb2e4c..dd96bf98 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs @@ -1,6 +1,12 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record BeginCredentialResetRequest { - public string? Reason { get; init; } + public string Identifier { get; init; } = default!; + public CredentialType CredentialType { get; set; } = CredentialType.Password; + public ResetCodeType ResetCodeType { get; set; } + public string? Channel { get; init; } + public TimeSpan? Validity { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs index 85c5770e..7483584c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs @@ -2,8 +2,7 @@ public sealed record ChangeCredentialRequest { - public CredentialType Type { get; init; } - - public string CurrentSecret { get; init; } = default!; - public string NewSecret { get; init; } = default!; + public Guid Id { get; init; } + public required string CurrentSecret { get; init; } + public required string NewSecret { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs index 7dcaf3da..ec7abac5 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs @@ -1,7 +1,11 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record CompleteCredentialResetRequest { + public string? Identifier { get; init; } + public CredentialType CredentialType { get; set; } = CredentialType.Password; + public string? ResetToken { get; init; } public required string NewSecret { get; init; } - public string? Source { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CredentialActionRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CredentialActionRequest.cs new file mode 100644 index 00000000..d0f3465f --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CredentialActionRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialActionRequest +{ + public Guid Id { get; set; } + public string? Reason { get; set; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/DeleteCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/DeleteCredentialRequest.cs new file mode 100644 index 00000000..b1bf31fa --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/DeleteCredentialRequest.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public class DeleteCredentialRequest +{ + public Guid Id { get; init; } + public DeleteMode Mode { get; set; } = DeleteMode.Soft; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs index a895d5e7..0f185532 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs @@ -1,10 +1,8 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record ResetPasswordRequest { - public UserKey UserKey { get; init; } = default!; + public Guid Id { get; set; } public required string NewPassword { get; init; } /// diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs index 108fa25c..b6a64cd6 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs @@ -2,6 +2,8 @@ public sealed record RevokeCredentialRequest { + public Guid Id { get; init; } + /// /// If specified, credential is revoked until this time. /// Null means permanent revocation. diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs index 2ccd937b..a40a8345 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record SetInitialCredentialRequest { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs index bd530568..0268a697 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record ValidateCredentialsRequest { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs index dd9f3daa..5a226706 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record AddCredentialResult { @@ -6,19 +8,24 @@ public sealed record AddCredentialResult public string? Error { get; init; } + public Guid? Id { get; set; } public CredentialType? Type { get; init; } - public static AddCredentialResult Success(CredentialType type) + public static AddCredentialResult Success(Guid id, CredentialType type) => new() { Succeeded = true, - Type = type + Id = id, + Type = type, + Error = null }; public static AddCredentialResult Fail(string error) => new() { Succeeded = false, - Error = error + Error = error, + Id = null, + Type = null }; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs new file mode 100644 index 00000000..c7f399c8 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record BeginCredentialResetResult +{ + public string? Token { get; init; } + public DateTimeOffset ExpiresAt { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs index 8579bc23..6a78c338 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs @@ -1,8 +1,10 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record ChangeCredentialResult { - public bool Succeeded { get; init; } + public bool IsSuccess { get; init; } public string? Error { get; init; } @@ -11,14 +13,14 @@ public sealed record ChangeCredentialResult public static ChangeCredentialResult Success(CredentialType type) => new() { - Succeeded = true, + IsSuccess = true, Type = type }; public static ChangeCredentialResult Fail(string error) => new() { - Succeeded = false, + IsSuccess = false, Error = error }; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs index 36d9ae8d..c116b8e6 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record CredentialProvisionResult { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs index 2598739f..e4ef841f 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; @@ -9,40 +10,47 @@ namespace CodeBeam.UltimateAuth.Credentials.InMemory; internal sealed class InMemoryCredentialSeedContributor : ISeedContributor { + private static readonly Guid _adminPasswordId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid _userPasswordId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); public int Order => 10; private readonly ICredentialStore _credentials; private readonly IInMemoryUserIdProvider _ids; private readonly IUAuthPasswordHasher _hasher; + private readonly IClock _clock; - public InMemoryCredentialSeedContributor(ICredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher) + public InMemoryCredentialSeedContributor(ICredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher, IClock clock) { _credentials = credentials; _ids = ids; _hasher = hasher; + _clock = clock; } public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) { - await SeedCredentialAsync(_ids.GetAdminUserId(), "admin", tenant, ct); - await SeedCredentialAsync(_ids.GetUserUserId(), "user", tenant, ct); + await SeedCredentialAsync(_ids.GetAdminUserId(), _adminPasswordId, "admin", tenant, ct); + await SeedCredentialAsync(_ids.GetUserUserId(), _userPasswordId, "user", tenant, ct); } - private async Task SeedCredentialAsync(UserKey userKey, string hash, TenantKey tenant, CancellationToken ct) + private async Task SeedCredentialAsync(UserKey userKey, Guid credentialId, string secretHash, TenantKey tenant, CancellationToken ct) { - if (await _credentials.ExistsAsync(tenant, userKey, CredentialType.Password, null, ct)) - return; - - await _credentials.AddAsync(tenant, - new PasswordCredential( - Guid.NewGuid(), - tenant, - userKey, - _hasher.Hash(hash), - CredentialSecurityState.Active(), - new CredentialMetadata(), - DateTimeOffset.UtcNow, - null), - ct); + try + { + await _credentials.AddAsync( + PasswordCredential.Create( + credentialId, + tenant, + userKey, + _hasher.Hash(secretHash), + CredentialSecurityState.Active(), + new CredentialMetadata(), + _clock.UtcNow), + ct); + } + catch (UAuthConflictException) + { + // already seeded + } } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs index 263c365b..65d6f39c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs @@ -1,177 +1,87 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; -using System.Collections.Concurrent; namespace CodeBeam.UltimateAuth.Credentials.InMemory; -internal sealed class InMemoryCredentialStore : ICredentialStore +internal sealed class InMemoryCredentialStore : InMemoryVersionedStore, ICredentialStore { - private readonly ConcurrentDictionary<(TenantKey Tenant, Guid Id), PasswordCredential> _byId = new(); - private readonly ConcurrentDictionary<(TenantKey Tenant, UserKey UserKey), ConcurrentDictionary> _byUser = new(); - - private readonly IUAuthPasswordHasher _hasher; - - public InMemoryCredentialStore(IUAuthPasswordHasher hasher) - { - _hasher = hasher; - } + protected override CredentialKey GetKey(PasswordCredential entity) => new(entity.Tenant, entity.Id); public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_byUser.TryGetValue((tenant, userKey), out var ids) || ids.Count == 0) - return Task.FromResult>(Array.Empty()); - - var list = new List(ids.Count); - - foreach (var id in ids.Keys) - { - if (_byId.TryGetValue((tenant, id), out var cred)) - { - list.Add(cred); - } - } + var result = Values() + .Where(c => c.Tenant == tenant && c.UserKey == userKey) + .Cast() + .ToArray(); - return Task.FromResult>(list); + return Task.FromResult>(result); } - public Task GetByIdAsync(TenantKey tenant, Guid credentialId, CancellationToken ct = default) + public Task GetByIdAsync(CredentialKey key, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - _byId.TryGetValue((tenant, credentialId), out var cred); - return Task.FromResult(cred); - } - - public Task ExistsAsync(TenantKey tenant, UserKey userKey, CredentialType type, string? secretHash, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + if (TryGet(key, out var entity)) + return Task.FromResult(entity); - if (!_byUser.TryGetValue((tenant, userKey), out var ids) || ids.Count == 0) - return Task.FromResult(false); - - foreach (var id in ids.Keys) - { - if (!_byId.TryGetValue((tenant, id), out var cred)) - continue; - - if (cred.Type != type) - continue; - - if (secretHash is null) - return Task.FromResult(true); - - if (string.Equals(cred.SecretHash, secretHash, StringComparison.Ordinal)) - return Task.FromResult(true); - } - - return Task.FromResult(false); + return Task.FromResult(entity); } - public Task AddAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default) + public Task AddAsync(ICredential credential, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); - - // TODO: Support other credential types if needed. For now, we only have PasswordCredential in-memory. + // TODO: Implement other credential types if (credential is not PasswordCredential pwd) throw new NotSupportedException("Only password credentials are supported in-memory."); - var id = pwd.Id == Guid.Empty ? Guid.NewGuid() : pwd.Id; - - var key = (tenant, id); - if (_byId.ContainsKey(key)) - throw new InvalidOperationException("credential_already_exists"); - - if (pwd.Id == Guid.Empty) - throw new InvalidOperationException("credential_id_required"); - - if (!_byId.TryAdd(key, pwd)) - throw new InvalidOperationException("credential_already_exists"); - - var userIndex = _byUser.GetOrAdd((tenant, pwd.UserKey), _ => new ConcurrentDictionary()); - userIndex.TryAdd(pwd.Id, 0); - - return Task.CompletedTask; + return base.AddAsync(pwd, ct); } - public Task UpdateAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default) + public Task SaveAsync(ICredential credential, long expectedVersion, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); - if (credential is not PasswordCredential pwd) throw new NotSupportedException("Only password credentials are supported in-memory."); - var key = (tenant, pwd.Id); - - if (!_byId.ContainsKey(key)) - throw new InvalidOperationException("credential_not_found"); - - _byId[key] = pwd; - - return Task.CompletedTask; + return base.SaveAsync(pwd, expectedVersion, ct); } - public Task RevokeAsync(TenantKey tenant, Guid credentialId, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var key = (tenant, credentialId); - - if (!_byId.TryGetValue(key, out var cred)) - throw new InvalidOperationException("credential_not_found"); + if (!TryGet(key, out var credential)) + throw new UAuthNotFoundException("credential_not_found"); - if (cred.IsRevoked) - return Task.CompletedTask; + if (credential is not PasswordCredential pwd) + throw new NotSupportedException("Only password credentials are supported in-memory."); - cred.Revoke(revokedAt); + var revoked = pwd.Revoke(revokedAt); - return Task.CompletedTask; + return SaveAsync(revoked, expectedVersion, ct); } - public Task DeleteAsync(TenantKey tenant, Guid credentialId, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + public Task DeleteAsync(CredentialKey key, DeleteMode mode, DateTimeOffset now, long expectedVersion, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); - - var key = (tenant, credentialId); - - if (!_byId.TryGetValue(key, out var cred)) - return Task.CompletedTask; - - if (mode == DeleteMode.Hard) - { - _byId.TryRemove(key, out _); - - if (_byUser.TryGetValue((tenant, cred.UserKey), out var set)) - { - set.TryRemove(credentialId, out _); - } - - return Task.CompletedTask; - } - - if (!cred.IsRevoked) - cred.Revoke(now); - - return Task.CompletedTask; + return base.DeleteAsync(key, expectedVersion, mode, now, ct); } - public Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_byUser.TryGetValue((tenant, userKey), out var ids)) - return Task.CompletedTask; + var credentials = Values() + .Where(c => c.Tenant == tenant && c.UserKey == userKey) + .ToList(); - foreach (var id in ids.Keys.ToList()) + foreach (var credential in credentials) { - DeleteAsync(tenant, id, mode, now, ct); + await DeleteAsync(new CredentialKey(tenant, credential.Id), mode, now, credential.Version, ct); } - - return Task.CompletedTask; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs deleted file mode 100644 index d496e7f7..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class ActivateCredentialCommand : IAccessCommand -{ - private readonly Func> _execute; - - public ActivateCredentialCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs deleted file mode 100644 index e0426f87..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class AddCredentialCommand : IAccessCommand -{ - private readonly Func> _execute; - - public AddCredentialCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs deleted file mode 100644 index 7cd3bde0..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class BeginCredentialResetCommand : IAccessCommand -{ - private readonly Func> _execute; - - public BeginCredentialResetCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs deleted file mode 100644 index f9117ea8..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class ChangeCredentialCommand: IAccessCommand -{ - private readonly Func> _execute; - - public ChangeCredentialCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs deleted file mode 100644 index 7bf3a0f6..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class CompleteCredentialResetCommand : IAccessCommand -{ - private readonly Func> _execute; - - public CompleteCredentialResetCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs deleted file mode 100644 index ae290b3d..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class DeleteCredentialCommand : IAccessCommand -{ - private readonly Func> _execute; - - public DeleteCredentialCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs deleted file mode 100644 index 098d01bc..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class GetAllCredentialsCommand : IAccessCommand -{ - private readonly Func> _execute; - - public GetAllCredentialsCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs deleted file mode 100644 index 70bf7851..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class RevokeCredentialCommand : IAccessCommand -{ - private readonly Func> _execute; - - public RevokeCredentialCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs deleted file mode 100644 index 4efed9f9..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class SetInitialCredentialCommand : IAccessCommand -{ - private readonly Func _execute; - - public SetInitialCredentialCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs index 702eb1d5..9650be6c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -1,78 +1,153 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; namespace CodeBeam.UltimateAuth.Credentials.Reference; -public sealed class PasswordCredential : ISecretCredential, ICredentialDescriptor +public sealed class PasswordCredential : ISecretCredential, ICredentialDescriptor, IVersionedEntity, IEntitySnapshot, ISoftDeletable { public Guid Id { get; init; } public TenantKey Tenant { get; init; } public UserKey UserKey { get; init; } public CredentialType Type => CredentialType.Password; - public string SecretHash { get; private set; } - - public CredentialSecurityState Security { get; private set; } - public CredentialMetadata Metadata { get; private set; } + public string SecretHash { get; private set; } = default!; + public CredentialSecurityState Security { get; private set; } = CredentialSecurityState.Active(); + public CredentialMetadata Metadata { get; private set; } = new CredentialMetadata(); public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset? UpdatedAt { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } + public long Version { get; set; } public bool IsRevoked => Security.RevokedAt is not null; + public bool IsDeleted => DeletedAt is not null; public bool IsExpired(DateTimeOffset now) => Security.ExpiresAt is not null && Security.ExpiresAt <= now; - public PasswordCredential( - Guid? id, + private PasswordCredential() { } + + private PasswordCredential( + Guid id, TenantKey tenant, UserKey userKey, string secretHash, CredentialSecurityState security, CredentialMetadata metadata, DateTimeOffset createdAt, - DateTimeOffset? updatedAt) + DateTimeOffset? updatedAt, + DateTimeOffset? deletedAt, + long version) { - Id = id ?? Guid.NewGuid(); + if (id == Guid.Empty) + throw new UAuthValidationException("credential_id_required"); + + if (string.IsNullOrWhiteSpace(secretHash)) + throw new UAuthValidationException("credential_secret_required"); + + Id = id; Tenant = tenant; UserKey = userKey; SecretHash = secretHash; - Security = security; - Metadata = metadata; + Security = security ?? CredentialSecurityState.Active(); + Metadata = metadata ?? new CredentialMetadata(); CreatedAt = createdAt; UpdatedAt = updatedAt; + DeletedAt = deletedAt; + Version = version; + } + + public PasswordCredential Snapshot() + { + return new PasswordCredential + { + Id = Id, + Tenant = Tenant, + UserKey = UserKey, + SecretHash = SecretHash, + Security = Security, + Metadata = Metadata, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + Version = Version + }; } - public void ChangeSecret(string newSecretHash, DateTimeOffset now) + public static PasswordCredential Create( + Guid? id, + TenantKey tenant, + UserKey userKey, + string secretHash, + CredentialSecurityState security, + CredentialMetadata metadata, + DateTimeOffset now) + { + return new PasswordCredential( + id ?? Guid.NewGuid(), + tenant, + userKey, + secretHash, + security ?? CredentialSecurityState.Active(), + metadata ?? new CredentialMetadata(), + now, + null, + null, + 0); + } + + public PasswordCredential ChangeSecret(string newSecretHash, DateTimeOffset now) { if (string.IsNullOrWhiteSpace(newSecretHash)) - throw new ArgumentException("Secret hash cannot be empty.", nameof(newSecretHash)); + throw new UAuthValidationException("credential_secret_required"); if (IsRevoked) - throw new InvalidOperationException("Cannot change secret of a revoked credential."); + throw new UAuthConflictException("credential_revoked"); + + if (IsExpired(now)) + throw new UAuthConflictException("credential_expired"); + + if (string.Equals(SecretHash, newSecretHash, StringComparison.Ordinal)) + throw new UAuthValidationException("credential_secret_same"); SecretHash = newSecretHash; - UpdatedAt = now; Security = Security.RotateStamp(); + UpdatedAt = now; + + return this; } - public void SetExpiry(DateTimeOffset? expiresAt, DateTimeOffset now) + public PasswordCredential SetExpiry(DateTimeOffset? expiresAt, DateTimeOffset now) { + if (IsExpired(now)) + return this; + Security = Security.SetExpiry(expiresAt); UpdatedAt = now; + + return this; } - public void UpdateSecurity(CredentialSecurityState security, DateTimeOffset now) + public PasswordCredential Revoke(DateTimeOffset now) { - Security = security; + if (IsRevoked) + return this; + + Security = Security.Revoke(now); UpdatedAt = now; + + return this; } - public void Revoke(DateTimeOffset now) + public PasswordCredential MarkDeleted(DateTimeOffset now) { - if (IsRevoked) - return; - Security = Security.Revoke(now); + if (IsDeleted) + return this; + + DeletedAt = now; UpdatedAt = now; + + return this; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs index 10b8092f..76898d71 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs @@ -1,7 +1,7 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Extensions; using Microsoft.AspNetCore.Http; @@ -12,9 +12,9 @@ public sealed class CredentialEndpointHandler : ICredentialEndpointHandler { private readonly IAuthFlowContextAccessor _authFlow; private readonly IAccessContextFactory _accessContextFactory; - private readonly IUserCredentialsService _credentials; + private readonly ICredentialManagementService _credentials; - public CredentialEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUserCredentialsService credentials) + public CredentialEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, ICredentialManagementService credentials) { _authFlow = authFlow; _accessContextFactory = accessContextFactory; @@ -53,14 +53,11 @@ public async Task AddAsync(HttpContext ctx) return Results.Ok(result); } - public async Task ChangeAsync(string type, HttpContext ctx) + public async Task ChangeSecretAsync(HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - if (!TryParseType(type, out var credentialType, out error)) - return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( @@ -69,20 +66,15 @@ public async Task ChangeAsync(string type, HttpContext ctx) resource: "credentials", resourceId: flow.UserKey!.Value); - var result = await _credentials.ChangeAsync( - accessContext, credentialType, request, ctx.RequestAborted); - + var result = await _credentials.ChangeSecretAsync(accessContext, request, ctx.RequestAborted); return Results.Ok(result); } - public async Task RevokeAsync(string type, HttpContext ctx) + public async Task RevokeAsync(HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - if (!TryParseType(type, out var credentialType, out error)) - return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( @@ -91,48 +83,42 @@ public async Task RevokeAsync(string type, HttpContext ctx) resource: "credentials", resourceId: flow.UserKey!.Value); - await _credentials.RevokeAsync(accessContext, credentialType, request, ctx.RequestAborted); + await _credentials.RevokeAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } - public async Task BeginResetAsync(string type, HttpContext ctx) + public async Task BeginResetAsync(HttpContext ctx) { - if (!TryGetSelf(out var flow, out var error)) - return error!; - - if (!TryParseType(type, out var credentialType, out error)) - return error!; + // Don't call TryGetSelf here, as the user might be locked out and thus not authenticated. + var flow = _authFlow.Current; var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, - action: UAuthActions.Credentials.BeginResetSelf, + action: UAuthActions.Credentials.BeginResetAnonymous, resource: "credentials", - resourceId: flow.UserKey!.Value); + resourceId: request.Identifier); - await _credentials.BeginResetAsync(accessContext, credentialType, request, ctx.RequestAborted); - return Results.NoContent(); + var result = await _credentials.BeginResetAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(result); } - public async Task CompleteResetAsync(string type, HttpContext ctx) + public async Task CompleteResetAsync(HttpContext ctx) { - if (!TryGetSelf(out var flow, out var error)) - return error!; - - if (!TryParseType(type, out var credentialType, out error)) - return error!; + // Don't call TryGetSelf here, as the user might be locked out and thus not authenticated. + var flow = _authFlow.Current; var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, - action: UAuthActions.Credentials.CompleteResetSelf, + action: UAuthActions.Credentials.CompleteResetAnonymous, resource: "credentials", - resourceId: flow.UserKey!.Value); + resourceId: request.Identifier); - await _credentials.CompleteResetAsync(accessContext, credentialType, request, ctx.RequestAborted); - return Results.NoContent(); + var result = await _credentials.CompleteResetAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(result); } public async Task GetAllAdminAsync(UserKey userKey, HttpContext ctx) @@ -170,15 +156,12 @@ public async Task AddAdminAsync(UserKey userKey, HttpContext ctx) return Results.Ok(result); } - public async Task RevokeAdminAsync(UserKey userKey, string type, HttpContext ctx) + public async Task RevokeAdminAsync(UserKey userKey, HttpContext ctx) { var flow = _authFlow.Current; if (!flow.IsAuthenticated) return Results.Unauthorized(); - if (!TryParseType(type, out var credentialType, out var error)) - return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( @@ -187,39 +170,18 @@ public async Task RevokeAdminAsync(UserKey userKey, string type, HttpCo resource: "credentials", resourceId: userKey.Value); - await _credentials.RevokeAsync(accessContext, credentialType, request, ctx.RequestAborted); + await _credentials.RevokeAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } - public async Task ActivateAdminAsync(UserKey userKey, string type, HttpContext ctx) + public async Task DeleteAdminAsync(UserKey userKey, HttpContext ctx) { var flow = _authFlow.Current; if (!flow.IsAuthenticated) return Results.Unauthorized(); - if (!TryParseType(type, out var credentialType, out var error)) - return error!; - - var accessContext = await _accessContextFactory.CreateAsync( - flow, - action: UAuthActions.Credentials.ActivateAdmin, - resource: "credentials", - resourceId: userKey.Value); - - await _credentials.ActivateAsync(accessContext, credentialType, ctx.RequestAborted); - - return Results.NoContent(); - } - - public async Task DeleteAdminAsync(UserKey userKey, string type, HttpContext ctx) - { - var flow = _authFlow.Current; - if (!flow.IsAuthenticated) - return Results.Unauthorized(); - - if (!TryParseType(type, out var credentialType, out var error)) - return error!; + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -227,19 +189,16 @@ public async Task DeleteAdminAsync(UserKey userKey, string type, HttpCo resource: "credentials", resourceId: userKey.Value); - await _credentials.DeleteAsync(accessContext, credentialType, ctx.RequestAborted); + await _credentials.DeleteAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } - public async Task BeginResetAdminAsync(UserKey userKey, string type, HttpContext ctx) + public async Task BeginResetAdminAsync(UserKey userKey, HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - if (!TryParseType(type, out var credentialType, out error)) - return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( @@ -248,18 +207,15 @@ public async Task BeginResetAdminAsync(UserKey userKey, string type, Ht resource: "credentials", resourceId: userKey.Value); - await _credentials.BeginResetAsync(accessContext, credentialType, request, ctx.RequestAborted); + await _credentials.BeginResetAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } - public async Task CompleteResetAdminAsync(UserKey userKey, string type, HttpContext ctx) + public async Task CompleteResetAdminAsync(UserKey userKey, HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - if (!TryParseType(type, out var credentialType, out error)) - return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( @@ -268,7 +224,7 @@ public async Task CompleteResetAdminAsync(UserKey userKey, string type, resource: "credentials", resourceId: userKey.Value); - await _credentials.CompleteResetAsync(accessContext, credentialType, request, ctx.RequestAborted); + await _credentials.CompleteResetAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } @@ -284,16 +240,4 @@ private bool TryGetSelf(out AuthFlowContext flow, out IResult? error) error = null; return true; } - - private static bool TryParseType(string type, out CredentialType credentialType, out IResult? error) - { - if (!CredentialTypeParser.TryParse(type, out credentialType)) - { - error = Results.BadRequest($"Unsupported credential type: {type}"); - return false; - } - - error = null; - return true; - } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs index 731fb046..12497321 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -10,8 +10,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthCredentialsReference(this IServiceCollection services) { - services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialFactory.cs deleted file mode 100644 index f356db6c..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialFactory.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Credentials.Contracts; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal static class PasswordCredentialFactory -{ - public static PasswordCredential Create(TenantKey tenant, UserKey userKey, string secretHash, string? source, DateTimeOffset now) - => new PasswordCredential( - id: Guid.NewGuid(), - tenant: tenant, - userKey: userKey, - secretHash: secretHash, - security: CredentialSecurityState.Active(), - metadata: new CredentialMetadata { Source = source }, - createdAt: now, - updatedAt: null); -} \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs index e1d17fcc..efd94263 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -31,17 +31,16 @@ public async Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object r var hash = _passwordHasher.Hash(r.Password); - var credential = new PasswordCredential( + var credential = PasswordCredential.Create( id: null, tenant: tenant, userKey: userKey, secretHash: hash, security: CredentialSecurityState.Active(), - metadata: new CredentialMetadata { LastUsedAt = _clock.UtcNow }, - _clock.UtcNow, - null); + metadata: new CredentialMetadata { }, + _clock.UtcNow); - await _credentialStore.AddAsync(tenant, credential, ct); + await _credentialStore.AddAsync(credential, ct); } public async Task OnUserDeletedAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, CancellationToken ct) diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs new file mode 100644 index 00000000..568a6788 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -0,0 +1,382 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference.Internal; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users; +using Microsoft.AspNetCore.Session; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +// TODO: Add unlock credential factor as admin action. +internal sealed class CredentialManagementService : ICredentialManagementService, IUserCredentialsInternalService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly ICredentialStore _credentials; + private readonly IAuthenticationSecurityManager _authenticationSecurityManager; + private readonly IOpaqueTokenGenerator _tokenGenerator; + private readonly INumericCodeGenerator _numericCodeGenerator; + private readonly IUAuthPasswordHasher _hasher; + private readonly ITokenHasher _tokenHasher; + private readonly ILoginIdentifierResolver _identifierResolver; + private readonly ISessionStoreFactory _sessionFactory; + private readonly UAuthServerOptions _options; + private readonly IClock _clock; + + public CredentialManagementService( + IAccessOrchestrator accessOrchestrator, + ICredentialStore credentials, + IAuthenticationSecurityManager authenticationSecurityManager, + IOpaqueTokenGenerator tokenGenerator, + INumericCodeGenerator numericCodeGenerator, + IUAuthPasswordHasher hasher, + ITokenHasher tokenHasher, + ILoginIdentifierResolver identifierResolver, + ISessionStoreFactory sessionFactory, + IOptions options, + IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _credentials = credentials; + _authenticationSecurityManager = authenticationSecurityManager; + _tokenGenerator = tokenGenerator; + _numericCodeGenerator = numericCodeGenerator; + _hasher = hasher; + _tokenHasher = tokenHasher; + _identifierResolver = identifierResolver; + _sessionFactory = sessionFactory; + _options = options.Value; + _clock = clock; + } + + public async Task GetAllAsync(AccessContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var credentials = await _credentials.GetByUserAsync(context.ResourceTenant, subjectUser, innerCt); + + var dtos = credentials + .OfType() + .Select(c => new CredentialDto + { + Id = c.Id, + Type = c.Type, + Status = c.Security.Status(now), + ExpiresAt = c.Security.ExpiresAt, + RevokedAt = c.Security.RevokedAt, + LastUsedAt = c.Metadata.LastUsedAt, + Source = c.Metadata.Source, + Version = c.Version, + }) + .ToArray(); + + return new GetCredentialsResult { Credentials = dtos }; + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var hash = _hasher.Hash(request.Secret); + + var credential = PasswordCredential.Create( + id: null, + tenant: context.ResourceTenant, + userKey: subjectUser, + secretHash: hash, + security: CredentialSecurityState.Active(), + metadata: new CredentialMetadata(), + now: now); + + await _credentials.AddAsync(credential, innerCt); + + return AddCredentialResult.Success(credential.Id, credential.Type); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task ChangeSecretAsync(AccessContext context, ChangeCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var credentials = await _credentials.GetByUserAsync(context.ResourceTenant, subjectUser, innerCt); + var pwd = credentials.OfType().Where(c => c.Security.IsUsable(now)).SingleOrDefault(); + + if (pwd is null) + throw new UAuthNotFoundException("credential_not_found"); + + if (pwd.UserKey != subjectUser) + throw new UAuthNotFoundException("credential_not_found"); + + if (context.IsSelfAction) + { + if (string.IsNullOrWhiteSpace(request.CurrentSecret)) + throw new UAuthNotFoundException("current_secret_required"); + + if (!_hasher.Verify(pwd.SecretHash, request.CurrentSecret)) + throw new UAuthConflictException("invalid_credentials"); + } + + if (_hasher.Verify(pwd.SecretHash, request.NewSecret)) + throw new UAuthValidationException("credential_secret_same"); + + var oldVersion = pwd.Version; + var newHash = _hasher.Hash(request.NewSecret); + var updated = pwd.ChangeSecret(newHash, now); + await _credentials.SaveAsync(updated, oldVersion, innerCt); + + var sessionStore = _sessionFactory.Create(context.ResourceTenant); + if (context.IsSelfAction && context.ActorChainId is SessionChainId chainId) + { + await sessionStore.RevokeOtherChainsAsync(context.ResourceTenant, subjectUser, chainId, now, innerCt); + } + else + { + await sessionStore.RevokeAllChainsAsync(context.ResourceTenant, subjectUser, now, innerCt); + } + + return ChangeCredentialResult.Success(pwd.Type); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task RevokeAsync(AccessContext context, RevokeCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var credential = await _credentials.GetByIdAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); + + if (credential is not PasswordCredential pwd) + return CredentialActionResult.Fail("credential_not_found"); + + if (pwd.UserKey != subjectUser) + return CredentialActionResult.Fail("credential_not_found"); + + var oldVersion = pwd.Version; + var updated = pwd.Revoke(now); + await _credentials.SaveAsync(updated, oldVersion, innerCt); + + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task BeginResetAsync(AccessContext context, BeginCredentialResetRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + if (string.IsNullOrWhiteSpace(request.Identifier)) + throw new UAuthValidationException("identifier_required"); + + var now = _clock.UtcNow; + var validity = request.Validity ?? _options.ResetCredential.TokenValidity; + + var resolution = await _identifierResolver.ResolveAsync(context.ResourceTenant, request.Identifier, innerCt); + + if (resolution?.UserKey is not UserKey userKey) + { + return new BeginCredentialResetResult + { + Token = null, + ExpiresAt = now.Add(validity) + }; + } + + var state = await _authenticationSecurityManager + .GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); + + string token; + + if (request.ResetCodeType == ResetCodeType.Token) + { + token = _tokenGenerator.Generate(); + } + else if (request.ResetCodeType == ResetCodeType.Code) + { + token = _numericCodeGenerator.Generate(_options.ResetCredential.CodeLength); + } + else + { + throw new UAuthValidationException("invalid_reset_code_type"); + } + + var tokenHash = _tokenHasher.Hash(token); + + var updatedState = state.BeginReset(tokenHash, now, validity); + await _authenticationSecurityManager.UpdateAsync(updatedState, state.SecurityVersion, innerCt); + + return new BeginCredentialResetResult + { + Token = token, + ExpiresAt = now.Add(validity) + }; + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task CompleteResetAsync(AccessContext context, CompleteCredentialResetRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + if (string.IsNullOrWhiteSpace(request.Identifier)) + throw new UAuthValidationException("identifier_required"); + + if (string.IsNullOrWhiteSpace(request.ResetToken)) + throw new UAuthValidationException("reset_token_required"); + + if (string.IsNullOrWhiteSpace(request.NewSecret)) + throw new UAuthValidationException("new_secret_required"); + + var now = _clock.UtcNow; + + var resolution = await _identifierResolver.ResolveAsync(context.ResourceTenant, request.Identifier, innerCt); + + if (resolution?.UserKey is not UserKey userKey) + { + // Enumeration protection + return CredentialActionResult.Success(); + } + + var state = await _authenticationSecurityManager + .GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); + + if (!state.HasActiveReset(now)) + throw new UAuthConflictException("reset_request_not_active"); + + if (state.IsResetExpired(now)) + { + var version2 = state.SecurityVersion; + var cleared = state.ClearReset(); + await _authenticationSecurityManager.UpdateAsync(cleared, version2, innerCt); + throw new UAuthConflictException("reset_expired"); + } + + if (!_tokenHasher.Verify(state.ResetTokenHash!, request.ResetToken)) + { + var version = state.SecurityVersion; + var failed = state.RegisterResetFailure(now, _options.ResetCredential.MaxAttempts); + await _authenticationSecurityManager.UpdateAsync(failed, version, innerCt); + throw new UAuthConflictException("invalid_reset_token"); + } + + var credentials = await _credentials.GetByUserAsync(context.ResourceTenant, userKey, innerCt); + var pwd = credentials.OfType().FirstOrDefault(c => c.Security.IsUsable(now)); + + if (pwd is null) + throw new UAuthNotFoundException("credential_not_found"); + + if (_hasher.Verify(pwd.SecretHash, request.NewSecret)) + throw new UAuthValidationException("credential_secret_same"); + + var version3 = state.SecurityVersion; + state = state.ConsumeReset(now); + await _authenticationSecurityManager.UpdateAsync(state, version3, innerCt); + + var oldVersion = pwd.Version; + var newHash = _hasher.Hash(request.NewSecret); + var updated = pwd.ChangeSecret(newHash, now); + + await _credentials.SaveAsync(updated, oldVersion, innerCt); + + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task CancelResetAsync(AccessContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var userKey = context.GetTargetUserKey(); + + var state = await _authenticationSecurityManager + .GetOrCreateFactorAsync(context.ResourceTenant, userKey, CredentialType.Password, innerCt); + + if (!state.HasActiveReset(_clock.UtcNow)) + return CredentialActionResult.Success(); + + var updated = state.ClearReset(); + await _authenticationSecurityManager.UpdateAsync(updated, state.SecurityVersion, innerCt); + + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task DeleteAsync(AccessContext context, DeleteCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var credential = await _credentials.GetByIdAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); + + if (credential is not PasswordCredential pwd) + return CredentialActionResult.Fail("credential_not_found"); + + if (pwd.UserKey != subjectUser) + return CredentialActionResult.Fail("credential_not_found"); + + var oldVersion = pwd.Version; + await _credentials.DeleteAsync(new CredentialKey(context.ResourceTenant, pwd.Id), request.Mode, now, oldVersion, innerCt); + + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + // ---------------------------------------- + // INTERNAL ONLY - NEVER CALL THEM DIRECTLY + // ---------------------------------------- + async Task IUserCredentialsInternalService.DeleteInternalAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + await _credentials.DeleteByUserAsync(tenant, userKey, DeleteMode.Soft, _clock.UtcNow, ct); + return CredentialActionResult.Success(); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialAuthenticationService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialAuthenticationService.cs new file mode 100644 index 00000000..b4ab7ec1 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialAuthenticationService.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +/// +/// Orchestrates an authentication attempt against a credential type. +/// Responsible for applying lockout policy by mutating the credential aggregate +/// and persisting it. +/// +public interface ICredentialAuthenticationService +{ + //Task AuthenticateAsync( + // AccessContext context, + // CredentialType type, + // CredentialAuthenticationRequest request, + // CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs new file mode 100644 index 00000000..549659bf --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface ICredentialManagementService +{ + Task GetAllAsync(AccessContext context, CancellationToken ct = default); + + Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default); + + Task ChangeSecretAsync(AccessContext context, ChangeCredentialRequest request, CancellationToken ct = default); + + Task RevokeAsync(AccessContext context, RevokeCredentialRequest request, CancellationToken ct = default); + + Task BeginResetAsync(AccessContext context, BeginCredentialResetRequest request, CancellationToken ct = default); + + Task CompleteResetAsync(AccessContext context, CompleteCredentialResetRequest request, CancellationToken ct = default); + + Task DeleteAsync(AccessContext context, DeleteCredentialRequest request, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs deleted file mode 100644 index eaddd57a..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Credentials.Contracts; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -public interface IUserCredentialsService -{ - Task GetAllAsync(AccessContext context, CancellationToken ct = default); - - Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default); - - Task ChangeAsync(AccessContext context, CredentialType type, ChangeCredentialRequest request, CancellationToken ct = default); - - Task RevokeAsync(AccessContext context, CredentialType type, RevokeCredentialRequest request, CancellationToken ct = default); - - Task ActivateAsync(AccessContext context, CredentialType type, CancellationToken ct = default); - - Task BeginResetAsync(AccessContext context, CredentialType type, BeginCredentialResetRequest request, CancellationToken ct = default); - - Task CompleteResetAsync(AccessContext context, CredentialType type, CompleteCredentialResetRequest request, CancellationToken ct = default); - - Task DeleteAsync(AccessContext context, CredentialType type, CancellationToken ct = default); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs deleted file mode 100644 index d74955b5..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs +++ /dev/null @@ -1,284 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Credentials.Reference.Internal; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class UserCredentialsService : IUserCredentialsService, IUserCredentialsInternalService -{ - private readonly IAccessOrchestrator _accessOrchestrator; - private readonly ICredentialStore _credentials; - private readonly IUAuthPasswordHasher _hasher; - private readonly IClock _clock; - - public UserCredentialsService( - IAccessOrchestrator accessOrchestrator, - ICredentialStore credentials, - IUAuthPasswordHasher hasher, - IClock clock) - { - _accessOrchestrator = accessOrchestrator; - _credentials = credentials; - _hasher = hasher; - _clock = clock; - } - - public async Task GetAllAsync(AccessContext context, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new GetAllCredentialsCommand( - async innerCt => - { - if (context.ActorUserKey is not UserKey userKey) - throw new UnauthorizedAccessException(); - - var creds = await _credentials.GetByUserAsync(context.ResourceTenant, userKey, innerCt); - - var dtos = creds - .OfType() - .Select(c => new CredentialDto { - Type = c.Type, - Status = c.Security.Status(_clock.UtcNow), - LastUsedAt = c.Metadata.LastUsedAt, - LockedUntil = c.Security.LockedUntil, - ExpiresAt = c.Security.ExpiresAt, - RevokedAt = c.Security.RevokedAt, - ResetRequestedAt = c.Security.ResetRequestedAt, - Source = c.Metadata.Source}) - .ToArray(); - - return new GetCredentialsResult - { - Credentials = dtos - }; - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new AddCredentialCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var alreadyHasType = (await _credentials.GetByUserAsync(context.ResourceTenant, userKey, innerCt)) - .OfType() - .Any(c => c.Type == request.Type); - - if (alreadyHasType) - return AddCredentialResult.Fail("credential_already_exists"); - - var hash = _hasher.Hash(request.Secret); - - var credential = PasswordCredentialFactory.Create( - tenant: context.ResourceTenant, - userKey: userKey, - secretHash: hash, - source: request.Source, - now: now); - - await _credentials.AddAsync(context.ResourceTenant, credential, innerCt); - - return AddCredentialResult.Success(request.Type); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - - public async Task ChangeAsync(AccessContext context, CredentialType type, ChangeCredentialRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new ChangeCredentialCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return ChangeCredentialResult.Fail("credential_not_found"); - - if (cred is PasswordCredential pwd) - { - var hash = _hasher.Hash(request.NewSecret); - pwd.ChangeSecret(hash, now); - await _credentials.UpdateAsync(context.ResourceTenant, pwd, innerCt); - } - else - { - return ChangeCredentialResult.Fail("credential_type_unsupported"); - } - - return ChangeCredentialResult.Success(type); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - - // ---------------- REVOKE ---------------- - - public async Task RevokeAsync(AccessContext context, CredentialType type, RevokeCredentialRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new RevokeCredentialCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return CredentialActionResult.Fail("credential_not_found"); - - await _credentials.RevokeAsync(context.ResourceTenant, GetId(cred), now, innerCt); - return CredentialActionResult.Success(); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task ActivateAsync(AccessContext context, CredentialType type, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new ActivateCredentialCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return CredentialActionResult.Fail("credential_not_found"); - - if (cred is ICredentialDescriptor desc && cred is PasswordCredential pwd) - { - pwd.UpdateSecurity(CredentialSecurityState.Active(pwd.Security.SecurityStamp), now); - await _credentials.UpdateAsync(context.ResourceTenant, pwd, innerCt); - return CredentialActionResult.Success(); - } - - return CredentialActionResult.Fail("credential_type_unsupported"); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task BeginResetAsync(AccessContext context, CredentialType type, BeginCredentialResetRequest request, CancellationToken ct) - { - var cmd = new BeginCredentialResetCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return CredentialActionResult.Fail("credential_not_found"); - - if (cred is PasswordCredential pwd) - { - pwd.UpdateSecurity(pwd.Security.BeginReset(now), now); - await _credentials.UpdateAsync(context.ResourceTenant, pwd, innerCt); - return CredentialActionResult.Success(); - } - - return CredentialActionResult.Fail("credential_type_unsupported"); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task CompleteResetAsync(AccessContext context, CredentialType type, CompleteCredentialResetRequest request, CancellationToken ct) - { - var cmd = new CompleteCredentialResetCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return CredentialActionResult.Fail("credential_not_found"); - - if (cred is PasswordCredential pwd) - { - var hash = _hasher.Hash(request.NewSecret); - pwd.ChangeSecret(hash, now); - pwd.UpdateSecurity(pwd.Security.CompleteReset(), now); - - await _credentials.UpdateAsync(context.ResourceTenant, pwd, innerCt); - return CredentialActionResult.Success(); - } - - return CredentialActionResult.Fail("credential_type_unsupported"); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task DeleteAsync(AccessContext context, CredentialType type, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new DeleteCredentialCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return CredentialActionResult.Fail("credential_not_found"); - - await _credentials.DeleteAsync( - tenant: context.ResourceTenant, - credentialId: GetId(cred), - mode: DeleteMode.Soft, - now: now, - ct: innerCt); - - return CredentialActionResult.Success(); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - // ---------------------------------------- - // INTERNAL ONLY - NEVER CALL THEM DIRECTLY - // ---------------------------------------- - async Task IUserCredentialsInternalService.DeleteInternalAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) - { - ct.ThrowIfCancellationRequested(); - - await _credentials.DeleteByUserAsync(tenant, userKey, DeleteMode.Soft, _clock.UtcNow, ct); - return CredentialActionResult.Success(); - } - - - private static UserKey EnsureActor(AccessContext context) - => context.ActorUserKey is UserKey uk ? uk : throw new UnauthorizedAccessException(); - - private static Guid GetId(ICredential c) - => c switch - { - PasswordCredential p => p.Id, - _ => throw new NotSupportedException("credential_id_missing") - }; - - private async Task GetSingleByTypeAsync(TenantKey tenant, UserKey userKey, CredentialType type, CancellationToken ct) - { - var creds = await _credentials.GetByUserAsync(tenant, userKey, ct); - - var found = creds.OfType().FirstOrDefault(x => x.Type == type); - - return found as ICredential; - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs index f2f9879b..a9cdc74b 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs @@ -1,10 +1,12 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Credentials; public interface ICredential { + Guid Id { get; } + TenantKey Tenant { get; init; } UserKey UserKey { get; init; } CredentialType Type { get; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs index c8a4af55..bc513d3a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs @@ -1,10 +1,13 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; namespace CodeBeam.UltimateAuth.Credentials; public interface ICredentialDescriptor { + Guid Id { get; } CredentialType Type { get; } CredentialSecurityState Security { get; } CredentialMetadata Metadata { get; } + long Version { get; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs index e55c42b6..1e2f3b86 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs @@ -8,11 +8,10 @@ namespace CodeBeam.UltimateAuth.Credentials; public interface ICredentialStore { Task>GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task GetByIdAsync(TenantKey tenant, Guid credentialId, CancellationToken ct = default); - Task AddAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default); - Task UpdateAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default); - Task RevokeAsync(TenantKey tenant, Guid credentialId, DateTimeOffset revokedAt, CancellationToken ct = default); - Task DeleteAsync(TenantKey tenant, Guid credentialId, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); + Task GetByIdAsync(CredentialKey key, CancellationToken ct = default); + Task AddAsync(ICredential credential, CancellationToken ct = default); + Task SaveAsync(ICredential credential, long expectedVersion, CancellationToken ct = default); + Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default); + Task DeleteAsync(CredentialKey key, DeleteMode mode, DateTimeOffset now, long expectedVersion, CancellationToken ct = default); Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); - Task ExistsAsync(TenantKey tenant, UserKey userKey, CredentialType type, string? secretHash, CancellationToken ct = default); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj index ce41f1eb..6db7b9d7 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj +++ b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj @@ -11,6 +11,7 @@ + diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs index f63214b0..fdf4e582 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Policies.Registry; using Microsoft.Extensions.DependencyInjection; @@ -15,8 +16,9 @@ public static void Register(AccessPolicyRegistry registry) // Intent-based registry.Add("", _ => new RequireSelfPolicy()); - registry.Add("", _ => new RequireAdminPolicy()); - registry.Add("", _ => new RequireSelfOrAdminPolicy()); registry.Add("", _ => new RequireSystemPolicy()); + + // Permission + registry.Add("", _ => new MustHavePermissionPolicy()); } } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs index 594f2723..4c6181ae 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Policies.Registry; using Microsoft.Extensions.DependencyInjection; @@ -29,8 +30,7 @@ private IPolicyScopeBuilder Add() where TPolicy : IAccessPolicy } public IPolicyScopeBuilder RequireSelf() => Add(); - public IPolicyScopeBuilder RequireAdmin() => Add(); - public IPolicyScopeBuilder RequireSelfOrAdmin() => Add(); + public IPolicyScopeBuilder RequirePermission() => Add(); public IPolicyScopeBuilder RequireAuthenticated() => Add(); public IPolicyScopeBuilder DenyCrossTenant() => Add(); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs index 1916eeee..9f400fae 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs @@ -4,7 +4,6 @@ public interface IPolicyScopeBuilder { IPolicyScopeBuilder RequireAuthenticated(); IPolicyScopeBuilder RequireSelf(); - IPolicyScopeBuilder RequireAdmin(); - IPolicyScopeBuilder RequireSelfOrAdmin(); + IPolicyScopeBuilder RequirePermission(); IPolicyScopeBuilder DenyCrossTenant(); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs index 61adab98..134ba717 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Policies.Registry; using Microsoft.Extensions.DependencyInjection; @@ -20,8 +21,7 @@ public PolicyScopeBuilder(string prefix, AccessPolicyRegistry registry, IService public IPolicyScopeBuilder RequireAuthenticated() => Add(); public IPolicyScopeBuilder RequireSelf() => Add(); - public IPolicyScopeBuilder RequireAdmin() => Add(); - public IPolicyScopeBuilder RequireSelfOrAdmin() => Add(); + public IPolicyScopeBuilder RequirePermission() => Add(); public IPolicyScopeBuilder DenyCrossTenant() => Add(); private IPolicyScopeBuilder Add() where TPolicy : IAccessPolicy diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs new file mode 100644 index 00000000..59a01b92 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization.Policies; + +public sealed class MustHavePermissionPolicy : IAccessPolicy +{ + public AccessDecision Decide(AccessContext context) + { + if (context.Attributes.TryGetValue(UAuthConstants.Access.Permissions, out var value) && value is CompiledPermissionSet permissions) + { + if (permissions.IsAllowed(context.Action)) + return AccessDecision.Allow(); + } + + return AccessDecision.Deny("missing_permission"); + } + + public bool AppliesTo(AccessContext context) + { + return context.Action.EndsWith(".admin", StringComparison.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs index fa9bc520..ba1cbd35 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; namespace CodeBeam.UltimateAuth.Policies; @@ -32,14 +33,14 @@ public bool AppliesTo(AccessContext context) if (!context.IsAuthenticated || context.IsSystemActor) return false; - return !AllowedForInactive.Any(prefix => context.Action.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + if (context.Action.EndsWith(".anonymous")) + return false; + + return !AllowedForInactive.Contains(context.Action); } private static readonly string[] AllowedForInactive = { - "users.status.change.", - "credentials.password.reset.", - "login.", - "reauth." + UAuthActions.Users.ChangeStatusSelf, }; } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs deleted file mode 100644 index 97702678..00000000 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs +++ /dev/null @@ -1,25 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Policies; - -internal sealed class RequireAdminPolicy : IAccessPolicy -{ - public AccessDecision Decide(AccessContext context) - { - if (!context.IsAuthenticated) - return AccessDecision.Deny("unauthenticated"); - - if (!context.Attributes.TryGetValue("roles", out var value)) - return AccessDecision.Deny("missing_roles"); - - if (value is not IReadOnlyCollection roles) - return AccessDecision.Deny("invalid_roles"); - - return roles.Contains("Admin", StringComparer.OrdinalIgnoreCase) - ? AccessDecision.Allow() - : AccessDecision.Deny("admin_required"); - } - - public bool AppliesTo(AccessContext context) => context.Action.EndsWith(".admin"); -} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs index 5e23dab6..cbe238d4 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs @@ -1,5 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using System.Net; namespace CodeBeam.UltimateAuth.Policies; @@ -12,5 +14,8 @@ public AccessDecision Decide(AccessContext context) : AccessDecision.Deny("unauthenticated"); } - public bool AppliesTo(AccessContext context) => true; + public bool AppliesTo(AccessContext context) + { + return !context.Action.EndsWith(".anonymous"); + } } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs deleted file mode 100644 index 52e75a09..00000000 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs +++ /dev/null @@ -1,27 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Policies; - -internal sealed class RequireSelfOrAdminPolicy : IAccessPolicy -{ - public AccessDecision Decide(AccessContext context) - { - if (!context.IsAuthenticated) - return AccessDecision.Deny("unauthenticated"); - - if (context.IsSelfAction) - return AccessDecision.Allow(); - - if (context.Attributes.TryGetValue("roles", out var value) - && value is IReadOnlyCollection roles - && roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) - { - return AccessDecision.Allow(); - } - - return AccessDecision.Deny("self_or_admin_required"); - } - - public bool AppliesTo(AccessContext context) => !context.Action.EndsWith(".self") && !context.Action.EndsWith(".admin"); -} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs index ae6a4dd4..595fde6b 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs @@ -15,5 +15,8 @@ public AccessDecision Decide(AccessContext context) : AccessDecision.Deny("not_self"); } - public bool AppliesTo(AccessContext context) => context.Action.EndsWith(".self"); + public bool AppliesTo(AccessContext context) + { + return context.Action.EndsWith(".self"); + } } diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs index c0d2dca4..3e3e4116 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using System.Text; using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; using Konscious.Security.Cryptography; namespace CodeBeam.UltimateAuth.Security.Argon2; @@ -17,7 +18,7 @@ public Argon2PasswordHasher(Argon2Options options) public string Hash(string password) { if (string.IsNullOrEmpty(password)) - throw new ArgumentException("Password cannot be null or empty.", nameof(password)); + throw new UAuthValidationException("Password cannot be null or empty."); var salt = RandomNumberGenerator.GetBytes(_options.SaltSize); diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs index 16666c7b..b7f0977d 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs @@ -33,37 +33,29 @@ protected override void OnModelCreating(ModelBuilder b) { e.HasKey(x => x.Id); - e.Property(x => x.RowVersion) - .IsRowVersion(); + e.Property(x => x.Version).IsConcurrencyToken(); e.Property(x => x.UserKey) .IsRequired(); - e.HasIndex(x => new { x.Tenant, x.UserKey }) - .IsUnique(); + e.HasIndex(x => new { x.Tenant, x.UserKey }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.RootId }).IsUnique(); e.Property(x => x.SecurityVersion) .IsRequired(); - e.Property(x => x.LastUpdatedAt) - .IsRequired(); - e.Property(x => x.RootId) .HasConversion( v => v.Value, v => SessionRootId.From(v)) .IsRequired(); - - e.HasIndex(x => new { x.Tenant, x.RootId }); - }); b.Entity(e => { e.HasKey(x => x.Id); - e.Property(x => x.RowVersion) - .IsRowVersion(); + e.Property(x => x.Version).IsConcurrencyToken(); e.Property(x => x.UserKey) .IsRequired(); @@ -90,7 +82,7 @@ protected override void OnModelCreating(ModelBuilder b) b.Entity(e => { e.HasKey(x => x.Id); - e.Property(x => x.RowVersion).IsRowVersion(); + e.Property(x => x.Version).IsConcurrencyToken(); e.HasIndex(x => new { x.Tenant, x.SessionId }).IsUnique(); e.HasIndex(x => new { x.Tenant, x.ChainId, x.RevokedAt }); diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs index 20358cf6..7106b024 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs @@ -8,20 +8,23 @@ internal sealed class SessionChainProjection public long Id { get; set; } public SessionChainId ChainId { get; set; } = default!; - public SessionRootId RootId { get; } - + public SessionRootId RootId { get; set; } public TenantKey Tenant { get; set; } public UserKey UserKey { get; set; } - public int RotationCount { get; set; } - public long SecurityVersionAtCreation { get; set; } - + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset LastSeenAt { get; set; } + public DateTimeOffset? AbsoluteExpiresAt { get; set; } + public DeviceContext Device { get; set; } public ClaimsSnapshot ClaimsSnapshot { get; set; } = ClaimsSnapshot.Empty; - public AuthSessionId? ActiveSessionId { get; set; } + public int RotationCount { get; set; } + public int TouchCount { get; set; } + public long SecurityVersionAtCreation { get; set; } - public bool IsRevoked { get; set; } public DateTimeOffset? RevokedAt { get; set; } + public long Version { get; set; } - public byte[] RowVersion { get; set; } = default!; + public bool IsRevoked => RevokedAt is not null; + public SessionChainState State => IsRevoked ? SessionChainState.Revoked : ActiveSessionId is null ? SessionChainState.Passive : SessionChainState.Active; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs index fdf642ed..58c37604 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs @@ -26,5 +26,5 @@ internal sealed class SessionProjection public ClaimsSnapshot Claims { get; set; } = ClaimsSnapshot.Empty; public SessionMetadata Metadata { get; set; } = SessionMetadata.Empty; - public byte[] RowVersion { get; set; } = default!; + public long Version { get; set; } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs index 05be286e..5d1e613f 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs @@ -10,11 +10,12 @@ internal sealed class SessionRootProjection public TenantKey Tenant { get; set; } public UserKey UserKey { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public bool IsRevoked { get; set; } public DateTimeOffset? RevokedAt { get; set; } public long SecurityVersion { get; set; } - public DateTimeOffset LastUpdatedAt { get; set; } - - public byte[] RowVersion { get; set; } = default!; + public long Version { get; set; } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 6f0f3e80..478a44e6 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -8,7 +8,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddUltimateAuthEntityFrameworkCoreSessions(this IServiceCollection services,Action configureDb)where TUserId : notnull { services.AddDbContext(configureDb); - services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs index 93c582f4..683f9453 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs @@ -11,12 +11,17 @@ public static UAuthSessionChain ToDomain(this SessionChainProjection p) p.RootId, p.Tenant, p.UserKey, - p.RotationCount, - p.SecurityVersionAtCreation, + p.CreatedAt, + p.LastSeenAt, + p.AbsoluteExpiresAt, + p.Device, p.ClaimsSnapshot, p.ActiveSessionId, - p.IsRevoked, - p.RevokedAt + p.RotationCount, + p.TouchCount, + p.SecurityVersionAtCreation, + p.RevokedAt, + p.Version ); } @@ -25,17 +30,20 @@ public static SessionChainProjection ToProjection(this UAuthSessionChain chain) return new SessionChainProjection { ChainId = chain.ChainId, + RootId = chain.RootId, Tenant = chain.Tenant, UserKey = chain.UserKey, - - RotationCount = chain.RotationCount, - SecurityVersionAtCreation = chain.SecurityVersionAtCreation, + CreatedAt = chain.CreatedAt, + LastSeenAt = chain.LastSeenAt, + AbsoluteExpiresAt = chain.AbsoluteExpiresAt, + Device = chain.Device, ClaimsSnapshot = chain.ClaimsSnapshot, - ActiveSessionId = chain.ActiveSessionId, - - IsRevoked = chain.IsRevoked, - RevokedAt = chain.RevokedAt + RotationCount = chain.RotationCount, + TouchCount = chain.TouchCount, + SecurityVersionAtCreation = chain.SecurityVersionAtCreation, + RevokedAt = chain.RevokedAt, + Version = chain.Version }; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs index da2a7fe8..9b591691 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs @@ -13,13 +13,12 @@ public static UAuthSession ToDomain(this SessionProjection p) p.ChainId, p.CreatedAt, p.ExpiresAt, - p.LastSeenAt, p.IsRevoked, p.RevokedAt, p.SecurityVersionAtCreation, - p.Device, p.Claims, - p.Metadata + p.Metadata, + p.Version ); } @@ -34,15 +33,14 @@ public static SessionProjection ToProjection(this UAuthSession s) CreatedAt = s.CreatedAt, ExpiresAt = s.ExpiresAt, - LastSeenAt = s.LastSeenAt, IsRevoked = s.IsRevoked, RevokedAt = s.RevokedAt, SecurityVersionAtCreation = s.SecurityVersionAtCreation, - Device = s.Device, Claims = s.Claims, - Metadata = s.Metadata + Metadata = s.Metadata, + Version = s.Version }; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs index a0c223ae..63d1fa41 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs @@ -4,17 +4,18 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; internal static class SessionRootProjectionMapper { - public static UAuthSessionRoot ToDomain(this SessionRootProjection root, IReadOnlyList? chains = null) + public static UAuthSessionRoot ToDomain(this SessionRootProjection root) { return UAuthSessionRoot.FromProjection( root.RootId, root.Tenant, root.UserKey, + root.CreatedAt, + root.UpdatedAt, root.IsRevoked, root.RevokedAt, root.SecurityVersion, - chains ?? Array.Empty(), - root.LastUpdatedAt + root.Version ); } @@ -26,11 +27,14 @@ public static SessionRootProjection ToProjection(this UAuthSessionRoot root) Tenant = root.Tenant, UserKey = root.UserKey, + CreatedAt = root.CreatedAt, + UpdatedAt = root.UpdatedAt, + IsRevoked = root.IsRevoked, RevokedAt = root.RevokedAt, SecurityVersion = root.SecurityVersion, - LastUpdatedAt = root.LastUpdatedAt + Version = root.Version }; } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs new file mode 100644 index 00000000..65a2a1db --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -0,0 +1,612 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; +using System.Data; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal sealed class EfCoreSessionStore : ISessionStore +{ + private readonly UltimateAuthSessionDbContext _db; + private readonly TenantContext _tenant; + + public EfCoreSessionStore(UltimateAuthSessionDbContext db, TenantContext tenant) + { + _db = db; + _tenant = tenant; + } + + public async Task ExecuteAsync(Func action, CancellationToken ct = default) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + await strategy.ExecuteAsync(async () => + { + var connection = _db.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + await connection.OpenAsync(ct); + + await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); + _db.Database.UseTransaction(tx); + + try + { + await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + await tx.RollbackAsync(ct); + throw new UAuthConcurrencyException("concurrency_conflict"); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + finally + { + _db.Database.UseTransaction(null); + } + }); + } + + public async Task ExecuteAsync(Func> action, CancellationToken ct = default) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + var connection = _db.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + await connection.OpenAsync(ct); + + await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); + _db.Database.UseTransaction(tx); + + try + { + var result = await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return result; + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + finally + { + _db.Database.UseTransaction(null); + } + }); + } + + public async Task GetSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Sessions + .AsNoTracking() + .SingleOrDefaultAsync(x => x.SessionId == sessionId); + + return projection?.ToDomain(); + } + + public async Task SaveSessionAsync(UAuthSession session, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = session.ToProjection(); + projection.Version = expectedVersion; + + _db.Entry(projection).State = EntityState.Modified; + _db.Entry(projection).Property(x => x.Version).OriginalValue = expectedVersion; + + try + { + await Task.CompletedTask; + } + catch (DbUpdateConcurrencyException) + { + throw new UAuthConcurrencyException("session_concurrency_conflict"); + } + } + + public async Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = session.ToProjection(); + + if (session.Version != 0) + throw new InvalidOperationException("New session must have version 0."); + + _db.Sessions.Add(projection); + } + + public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId); + + if (projection is null) + return false; + + var session = projection.ToDomain(); + if (session.IsRevoked) + return false; + + var revoked = session.Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + + return true; + } + + public async Task RevokeAllSessionsAsync(UserKey user, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chains = await _db.Chains.Where(x => x.UserKey == user).ToListAsync(ct); + + foreach (var chainProjection in chains) + { + var chain = chainProjection.ToDomain(); + + var sessions = await _db.Sessions.Where(x => x.ChainId == chain.ChainId).ToListAsync(ct); + + foreach (var sessionProjection in sessions) + { + var session = sessionProjection.ToDomain(); + + if (session.IsRevoked) + continue; + + var revoked = session.Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _db.Chains.Update(updatedChain.ToProjection()); + } + } + } + + public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chains = await _db.Chains.Where(x => x.UserKey == user && x.ChainId != keepChain).ToListAsync(ct); + + foreach (var chainProjection in chains) + { + var chain = chainProjection.ToDomain(); + + var sessions = await _db.Sessions.Where(x => x.ChainId == chain.ChainId).ToListAsync(ct); + + foreach (var sessionProjection in sessions) + { + var session = sessionProjection.ToDomain(); + + if (session.IsRevoked) + continue; + + var revoked = session.Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _db.Chains.Update(updatedChain.ToProjection()); + } + } + } + + public async Task GetChainAsync(SessionChainId chainId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Chains + .AsNoTracking() + .SingleOrDefaultAsync(x => x.ChainId == chainId); + + return projection?.ToDomain(); + } + + public async Task GetChainByDeviceAsync(TenantKey tenant, UserKey userKey, DeviceId deviceId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Chains + .AsNoTracking() + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey && + x.RevokedAt == null && + x.Device.DeviceId == deviceId) + .SingleOrDefaultAsync(ct); + + return projection?.ToDomain(); + } + + public Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = chain.ToProjection(); + + if (chain.Version != expectedVersion + 1) + throw new InvalidOperationException("Chain version must be incremented by domain."); + + _db.Entry(projection).State = EntityState.Modified; + + _db.Entry(projection) + .Property(x => x.Version) + .OriginalValue = expectedVersion; + + return Task.CompletedTask; + } + + public Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (chain.Version != 0) + throw new InvalidOperationException("New chain must have version 0."); + + var projection = chain.ToProjection(); + + _db.Chains.Add(projection); + + return Task.CompletedTask; + } + + public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Chains.SingleOrDefaultAsync(x => x.ChainId == chainId); + + if (projection is null) + return; + + var chain = projection.ToDomain(); + if (chain.IsRevoked) + return; + + _db.Chains.Update(chain.Revoke(at).ToProjection()); + } + + public async Task LogoutChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chainProjection = await _db.Chains.SingleOrDefaultAsync(x => x.ChainId == chainId, ct); + + if (chainProjection is null) + return; + + var chain = chainProjection.ToDomain(); + + if (chain.IsRevoked) + return; + + var sessions = await _db.Sessions.Where(x => x.ChainId == chainId).ToListAsync(ct); + + foreach (var sessionProjection in sessions) + { + var session = sessionProjection.ToDomain(); + + if (session.IsRevoked) + continue; + + var revoked = session.Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _db.Chains.Update(updatedChain.ToProjection()); + } + } + + public async Task RevokeOtherChainsAsync(TenantKey tenant, UserKey userKey, SessionChainId currentChainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await _db.Chains + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey && + x.ChainId != currentChainId && + !x.IsRevoked) + .ToListAsync(ct); + + foreach (var projection in projections) + { + var chain = projection.ToDomain(); + + if (chain.IsRevoked) + continue; + + _db.Chains.Update(chain.Revoke(at).ToProjection()); + } + } + + public async Task RevokeAllChainsAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await _db.Chains + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey && + !x.IsRevoked) + .ToListAsync(ct); + + foreach (var projection in projections) + { + var chain = projection.ToDomain(); + + if (chain.IsRevoked) + continue; + + _db.Chains.Update(chain.Revoke(at).ToProjection()); + } + } + + public async Task GetActiveSessionIdAsync(SessionChainId chainId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return await _db.Chains + .AsNoTracking() + .Where(x => x.ChainId == chainId) + .Select(x => x.ActiveSessionId) + .SingleOrDefaultAsync(); + } + + public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Chains.SingleOrDefaultAsync(x => x.ChainId == chainId); + + if (projection is null) + return; + + projection.ActiveSessionId = sessionId; + _db.Chains.Update(projection); + } + + public async Task GetRootByUserAsync(UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var rootProjection = await _db.Roots + .AsNoTracking() + .SingleOrDefaultAsync(x => x.UserKey == userKey); + + if (rootProjection is null) + return null; + + var chains = await _db.Chains + .AsNoTracking() + .Where(x => x.UserKey == userKey) + .ToListAsync(); + + return rootProjection.ToDomain(); + } + + public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = root.ToProjection(); + + if (root.Version != expectedVersion + 1) + throw new InvalidOperationException("Root version must be incremented by domain."); + + _db.Entry(projection).State = EntityState.Modified; + + _db.Entry(projection) + .Property(x => x.Version) + .OriginalValue = expectedVersion; + + return Task.CompletedTask; + } + + public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (root.Version != 0) + throw new InvalidOperationException("New root must have version 0."); + + var projection = root.ToProjection(); + + _db.Roots.Add(projection); + + return Task.CompletedTask; + } + + public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Roots.SingleOrDefaultAsync(x => x.UserKey == userKey); + + if (projection is null) + return; + + var root = projection.ToDomain(); + _db.Roots.Update(root.Revoke(at).ToProjection()); + } + + public async Task GetChainIdBySessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return await _db.Sessions + .AsNoTracking() + .Where(x => x.SessionId == sessionId) + .Select(x => (SessionChainId?)x.ChainId) + .SingleOrDefaultAsync(); + } + + public async Task> GetChainsByUserAsync(UserKey userKey, bool includeHistoricalRoots = false, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var rootsQuery = _db.Roots.AsNoTracking().Where(r => r.UserKey == userKey); + + if (!includeHistoricalRoots) + { + rootsQuery = rootsQuery.Where(r => !r.IsRevoked); + } + + var rootIds = await rootsQuery.Select(r => r.RootId).ToListAsync(); + + if (rootIds.Count == 0) + return Array.Empty(); + + var projections = await _db.Chains.AsNoTracking().Where(c => rootIds.Contains(c.RootId)).ToListAsync(); + return projections.Select(c => c.ToDomain()).ToList(); + } + + public async Task> GetChainsByRootAsync(SessionRootId rootId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await _db.Chains + .AsNoTracking() + .Where(x => x.RootId == rootId) + .ToListAsync(); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await _db.Sessions + .AsNoTracking() + .Where(x => x.ChainId == chainId) + .ToListAsync(); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task GetRootByIdAsync(SessionRootId rootId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var rootProjection = await _db.Roots + .AsNoTracking() + .SingleOrDefaultAsync(x => x.RootId == rootId); + + if (rootProjection is null) + return null; + + var chains = await _db.Chains + .AsNoTracking() + .Where(x => x.RootId == rootId) + .ToListAsync(); + + return rootProjection.ToDomain(); + } + + public async Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId, ct); + + if (projection is null) + return; + + _db.Sessions.Remove(projection); + } + + public async Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chainProjection = await _db.Chains + .SingleOrDefaultAsync(x => x.ChainId == chainId); + + if (chainProjection is null) + return; + + var sessionProjections = await _db.Sessions.Where(x => x.ChainId == chainId && !x.IsRevoked).ToListAsync(); + + foreach (var sessionProjection in sessionProjections) + { + var session = sessionProjection.ToDomain(); + var revoked = session.Revoke(at); + + _db.Sessions.Update(revoked.ToProjection()); + } + + if (!chainProjection.IsRevoked) + { + var chain = chainProjection.ToDomain(); + var revokedChain = chain.Revoke(at); + + _db.Chains.Update(revokedChain.ToProjection()); + } + } + + public async Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var rootProjection = await _db.Roots.SingleOrDefaultAsync(x => x.UserKey == userKey); + + if (rootProjection is null) + return; + + var chainProjections = await _db.Chains.Where(x => x.UserKey == userKey).ToListAsync(); + + foreach (var chainProjection in chainProjections) + { + var chainId = chainProjection.ChainId; + + var sessionProjections = await _db.Sessions.Where(x => x.ChainId == chainId && !x.IsRevoked).ToListAsync(); + + foreach (var sessionProjection in sessionProjections) + { + var session = sessionProjection.ToDomain(); + var revokedSession = session.Revoke(at); + + _db.Sessions.Update(revokedSession.ToProjection()); + } + + if (!chainProjection.IsRevoked) + { + var chain = chainProjection.ToDomain(); + var revokedChain = chain.Revoke(at); + + _db.Chains.Update(revokedChain.ToProjection()); + } + } + + if (!rootProjection.IsRevoked) + { + var root = rootProjection.ToDomain(); + var revokedRoot = root.Revoke(at); + + _db.Roots.Update(revokedRoot.ToProjection()); + } + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernelFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs similarity index 70% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernelFactory.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs index 240b9b9d..9200b8ef 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernelFactory.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs @@ -4,18 +4,18 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; -public sealed class EfCoreSessionStoreKernelFactory : ISessionStoreKernelFactory +public sealed class EfCoreSessionStoreFactory : ISessionStoreFactory { private readonly IServiceProvider _sp; - public EfCoreSessionStoreKernelFactory(IServiceProvider sp) + public EfCoreSessionStoreFactory(IServiceProvider sp) { _sp = sp; } - public ISessionStoreKernel Create(TenantKey tenant) + public ISessionStore Create(TenantKey tenant) { - return ActivatorUtilities.CreateInstance(_sp, new TenantContext(tenant)); + return ActivatorUtilities.CreateInstance(_sp, new TenantContext(tenant)); } // TODO: Implement global here diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs deleted file mode 100644 index bf34a06f..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs +++ /dev/null @@ -1,281 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using Microsoft.EntityFrameworkCore; -using System.Data; - -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; - -internal sealed class EfCoreSessionStoreKernel : ISessionStoreKernel -{ - private readonly UltimateAuthSessionDbContext _db; - private readonly TenantContext _tenant; - - public EfCoreSessionStoreKernel(UltimateAuthSessionDbContext db, TenantContext tenant) - { - _db = db; - _tenant = tenant; - } - - public async Task ExecuteAsync(Func action, CancellationToken ct = default) - { - var strategy = _db.Database.CreateExecutionStrategy(); - - await strategy.ExecuteAsync(async () => - { - var connection = _db.Database.GetDbConnection(); - if (connection.State != ConnectionState.Open) - await connection.OpenAsync(ct); - - await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); - _db.Database.UseTransaction(tx); - - try - { - await action(ct); - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); - } - catch - { - await tx.RollbackAsync(ct); - throw; - } - finally - { - _db.Database.UseTransaction(null); - } - }); - } - - public async Task ExecuteAsync(Func> action, CancellationToken ct = default) - { - var strategy = _db.Database.CreateExecutionStrategy(); - - return await strategy.ExecuteAsync(async () => - { - var connection = _db.Database.GetDbConnection(); - if (connection.State != ConnectionState.Open) - await connection.OpenAsync(ct); - - await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); - _db.Database.UseTransaction(tx); - - try - { - var result = await action(ct); - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); - return result; - } - catch - { - await tx.RollbackAsync(ct); - throw; - } - finally - { - _db.Database.UseTransaction(null); - } - }); - } - - public async Task GetSessionAsync(AuthSessionId sessionId) - { - var projection = await _db.Sessions - .AsNoTracking() - .SingleOrDefaultAsync(x => x.SessionId == sessionId); - - return projection?.ToDomain(); - } - - public async Task SaveSessionAsync(UAuthSession session) - { - var projection = session.ToProjection(); - - var exists = await _db.Sessions - .AnyAsync(x => x.SessionId == session.SessionId); - - if (exists) - _db.Sessions.Update(projection); - else - _db.Sessions.Add(projection); - } - - public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) - { - var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId); - - if (projection is null) - return false; - - var session = projection.ToDomain(); - if (session.IsRevoked) - return false; - - var revoked = session.Revoke(at); - _db.Sessions.Update(revoked.ToProjection()); - - return true; - } - - public async Task GetChainAsync(SessionChainId chainId) - { - var projection = await _db.Chains - .AsNoTracking() - .SingleOrDefaultAsync(x => x.ChainId == chainId); - - return projection?.ToDomain(); - } - - public async Task SaveChainAsync(UAuthSessionChain chain) - { - var projection = chain.ToProjection(); - - var exists = await _db.Chains - .AnyAsync(x => x.ChainId == chain.ChainId); - - if (exists) - _db.Chains.Update(projection); - else - _db.Chains.Add(projection); - } - - public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) - { - var projection = await _db.Chains - .SingleOrDefaultAsync(x => x.ChainId == chainId); - - if (projection is null) - return; - - var chain = projection.ToDomain(); - if (chain.IsRevoked) - return; - - _db.Chains.Update(chain.Revoke(at).ToProjection()); - } - - public async Task GetActiveSessionIdAsync(SessionChainId chainId) - { - return await _db.Chains - .AsNoTracking() - .Where(x => x.ChainId == chainId) - .Select(x => x.ActiveSessionId) - .SingleOrDefaultAsync(); - } - - public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId) - { - var projection = await _db.Chains - .SingleOrDefaultAsync(x => x.ChainId == chainId); - - if (projection is null) - return; - - projection.ActiveSessionId = sessionId; - _db.Chains.Update(projection); - } - - public async Task GetSessionRootByUserAsync(UserKey userKey) - { - var rootProjection = await _db.Roots - .AsNoTracking() - .SingleOrDefaultAsync(x => x.UserKey == userKey); - - if (rootProjection is null) - return null; - - var chains = await _db.Chains - .AsNoTracking() - .Where(x => x.UserKey == userKey) - .ToListAsync(); - - return rootProjection.ToDomain(chains.Select(c => c.ToDomain()).ToList()); - } - - public async Task SaveSessionRootAsync(UAuthSessionRoot root) - { - var projection = root.ToProjection(); - - var exists = await _db.Roots - .AnyAsync(x => x.RootId == root.RootId); - - if (exists) - _db.Roots.Update(projection); - else - _db.Roots.Add(projection); - } - - public async Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at) - { - var projection = await _db.Roots.SingleOrDefaultAsync(x => x.UserKey == userKey); - - if (projection is null) - return; - - var root = projection.ToDomain(); - _db.Roots.Update(root.Revoke(at).ToProjection()); - } - - public async Task GetChainIdBySessionAsync(AuthSessionId sessionId) - { - return await _db.Sessions - .AsNoTracking() - .Where(x => x.SessionId == sessionId) - .Select(x => (SessionChainId?)x.ChainId) - .SingleOrDefaultAsync(); - } - - public async Task> GetChainsByUserAsync(UserKey userKey) - { - var projections = await _db.Chains - .AsNoTracking() - .Where(x => x.UserKey == userKey) - .ToListAsync(); - - return projections.Select(x => x.ToDomain()).ToList(); - } - - public async Task> GetSessionsByChainAsync(SessionChainId chainId) - { - var projections = await _db.Sessions - .AsNoTracking() - .Where(x => x.ChainId == chainId) - .ToListAsync(); - - return projections.Select(x => x.ToDomain()).ToList(); - } - - public async Task GetSessionRootByIdAsync(SessionRootId rootId) - { - var rootProjection = await _db.Roots - .AsNoTracking() - .SingleOrDefaultAsync(x => x.RootId == rootId); - - if (rootProjection is null) - return null; - - var chains = await _db.Chains - .AsNoTracking() - .Where(x => x.RootId == rootId) - .ToListAsync(); - - return rootProjection.ToDomain(chains.Select(c => c.ToDomain()).ToList()); - } - - - public async Task DeleteExpiredSessionsAsync(DateTimeOffset at) - { - var projections = await _db.Sessions - .Where(x => x.ExpiresAt <= at && !x.IsRevoked) - .ToListAsync(); - - foreach (var p in projections) - { - var revoked = p.ToDomain().Revoke(at); - _db.Sessions.Update(revoked.ToProjection()); - } - } - -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs new file mode 100644 index 00000000..73fc9452 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -0,0 +1,509 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Sessions.InMemory; + +internal sealed class InMemorySessionStore : ISessionStore +{ + private readonly SemaphoreSlim _tx = new(1, 1); + private readonly object _lock = new(); + + private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _chains = new(); + private readonly ConcurrentDictionary _roots = new(); + + public async Task ExecuteAsync(Func action, CancellationToken ct = default) + { + await _tx.WaitAsync(ct); + try + { + await action(ct); + } + finally + { + _tx.Release(); + } + } + + public async Task ExecuteAsync(Func> action, CancellationToken ct = default) + { + await _tx.WaitAsync(ct); + try + { + return await action(ct); + } + finally + { + _tx.Release(); + } + } + + public Task GetSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(_sessions.TryGetValue(sessionId, out var s) ? s : null); + } + + public Task SaveSessionAsync(UAuthSession session, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_sessions.TryGetValue(session.SessionId, out var current)) + throw new UAuthNotFoundException("session_not_found"); + + if (current.Version != expectedVersion) + throw new UAuthConcurrencyException("session_concurrency_conflict"); + + _sessions[session.SessionId] = session; + return Task.CompletedTask; + } + + public Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (_sessions.ContainsKey(session.SessionId)) + throw new UAuthConcurrencyException("session_already_exists"); + + if (session.Version != 0) + throw new InvalidOperationException("New session must have version 0."); + + _sessions[session.SessionId] = session; + } + + return Task.CompletedTask; + } + + public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_sessions.TryGetValue(sessionId, out var session)) + return Task.FromResult(false); + + if (session.IsRevoked) + return Task.FromResult(false); + + var revoked = session.Revoke(at); + _sessions[sessionId] = revoked; + + if (_chains.TryGetValue(session.ChainId, out var chain)) + { + if (chain.ActiveSessionId == sessionId) + { + var updatedChain = chain.DetachSession(at); + _chains[chain.ChainId] = updatedChain; + } + } + + return Task.FromResult(true); + } + + public Task RevokeAllSessionsAsync(UserKey user, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + foreach (var (id, chain) in _chains) + { + if (chain.UserKey != user) + continue; + + var sessions = _sessions.Values.Where(s => s.ChainId == id).ToList(); + + foreach (var session in sessions) + { + if (!session.IsRevoked) + { + var revoked = session.Revoke(at); + _sessions[session.SessionId] = revoked; + } + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _chains[id] = updatedChain; + } + } + } + + return Task.CompletedTask; + } + + public Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + foreach (var (id, chain) in _chains) + { + if (chain.UserKey != user) + continue; + + if (id == keepChain) + continue; + + var sessions = _sessions.Values.Where(s => s.ChainId == id).ToList(); + + foreach (var session in sessions) + { + if (!session.IsRevoked) + { + var revoked = session.Revoke(at); + _sessions[session.SessionId] = revoked; + } + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _chains[id] = updatedChain; + } + } + } + + return Task.CompletedTask; + } + + public Task GetChainAsync(SessionChainId chainId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(_chains.TryGetValue(chainId, out var c) ? c : null); + } + + public Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_chains.TryGetValue(chain.ChainId, out var current)) + throw new UAuthNotFoundException("chain_not_found"); + + if (current.Version != expectedVersion) + throw new UAuthConcurrencyException("chain_concurrency_conflict"); + + _chains[chain.ChainId] = chain; + return Task.CompletedTask; + } + + public Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (_chains.ContainsKey(chain.ChainId)) + throw new UAuthConcurrencyException("chain_already_exists"); + + if (chain.Version != 0) + throw new InvalidOperationException("New chain must have version 0."); + + _chains[chain.ChainId] = chain; + } + + return Task.CompletedTask; + } + + public Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_chains.TryGetValue(chainId, out var chain)) + { + _chains[chainId] = chain.Revoke(at); + } + return Task.CompletedTask; + } + + public Task RevokeOtherChainsAsync(TenantKey tenant, UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + foreach (var (id, chain) in _chains) + { + if (chain.Tenant != tenant) + continue; + + if (chain.UserKey != user) + continue; + + if (id == keepChain) + continue; + + if (!chain.IsRevoked) + _chains[id] = chain.Revoke(at); + } + + return Task.CompletedTask; + } + + public Task RevokeAllChainsAsync(TenantKey tenant, UserKey user, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + foreach (var (id, chain) in _chains) + { + if (chain.Tenant != tenant) + continue; + + if (chain.UserKey != user) + continue; + + if (!chain.IsRevoked) + _chains[id] = chain.Revoke(at); + } + + return Task.CompletedTask; + } + + public Task GetRootByUserAsync(UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(_roots.TryGetValue(userKey, out var r) ? r : null); + } + + public Task GetRootByIdAsync(SessionRootId rootId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(_roots.Values.FirstOrDefault(r => r.RootId == rootId)); + } + + public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_roots.TryGetValue(root.UserKey, out var current)) + throw new UAuthNotFoundException("root_not_found"); + + if (current.Version != expectedVersion) + throw new UAuthConcurrencyException("root_concurrency_conflict"); + + _roots[root.UserKey] = root; + return Task.CompletedTask; + } + + public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (_roots.ContainsKey(root.UserKey)) + throw new UAuthConcurrencyException("root_already_exists"); + + if (root.Version != 0) + throw new InvalidOperationException("New root must have version 0."); + + _roots[root.UserKey] = root; + } + + return Task.CompletedTask; + } + + public Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_roots.TryGetValue(userKey, out var root)) + { + _roots[userKey] = root.Revoke(at); + } + return Task.CompletedTask; + } + + public Task GetChainIdBySessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_sessions.TryGetValue(sessionId, out var session)) + return Task.FromResult(session.ChainId); + + return Task.FromResult(null); + } + + public Task> GetChainsByUserAsync(UserKey userKey,bool includeHistoricalRoots = false, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var roots = _roots.Values.Where(r => r.UserKey == userKey); + + if (!includeHistoricalRoots) + { + roots = roots.Where(r => !r.IsRevoked); + } + + var rootIds = roots.Select(r => r.RootId).ToHashSet(); + + var result = _chains.Values.Where(c => rootIds.Contains(c.RootId)).ToList().AsReadOnly(); + return Task.FromResult>(result); + } + + public Task> GetChainsByRootAsync(SessionRootId rootId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var result = _chains.Values.Where(c => c.RootId == rootId).ToList().AsReadOnly(); + return Task.FromResult>(result); + } + + public Task GetChainByDeviceAsync(TenantKey tenant, UserKey userKey, DeviceId deviceId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chain = _chains.Values + .FirstOrDefault(c => + c.Tenant == tenant && + c.UserKey == userKey && + !c.IsRevoked && + c.Device.DeviceId == deviceId); + + return Task.FromResult(chain); + } + + public Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var result = _sessions.Values.Where(s => s.ChainId == chainId).ToList(); + return Task.FromResult>(result); + } + + public Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (!_sessions.TryGetValue(sessionId, out var session)) + return Task.CompletedTask; + + _sessions.TryRemove(sessionId, out _); + + if (_chains.TryGetValue(session.ChainId, out var chain)) + { + if (chain.ActiveSessionId == sessionId) + { + var updatedChain = chain.DetachSession(DateTimeOffset.UtcNow); + _chains[chain.ChainId] = updatedChain; + } + } + } + + return Task.CompletedTask; + } + + public Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (!_chains.TryGetValue(chainId, out var chain)) + return Task.CompletedTask; + + if (!chain.IsRevoked) + { + var revokedChain = chain.Revoke(at); + _chains[chainId] = revokedChain; + } + + var sessions = _sessions.Values.Where(s => s.ChainId == chainId).ToList(); + + foreach (var session in sessions) + { + if (!session.IsRevoked) + { + var revokedSession = session.Revoke(at); + _sessions[session.SessionId] = revokedSession; + } + } + } + + return Task.CompletedTask; + } + + public Task LogoutChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (!_chains.TryGetValue(chainId, out var chain)) + return Task.CompletedTask; + + if (chain.IsRevoked) + return Task.CompletedTask; + + var sessions = _sessions.Values.Where(s => s.ChainId == chainId).ToList(); + + foreach (var session in sessions) + { + if (!session.IsRevoked) + { + var revokedSession = session.Revoke(at); + _sessions[session.SessionId] = revokedSession; + } + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _chains[chainId] = updatedChain; + } + } + + return Task.CompletedTask; + } + + public Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (!_roots.TryGetValue(userKey, out var root)) + return Task.CompletedTask; + + var chains = _chains.Values + .Where(c => c.UserKey == userKey && c.Tenant == root.Tenant) + .ToList(); + + foreach (var chain in chains) + { + if (!chain.IsRevoked) + { + var revokedChain = chain.Revoke(at); + _chains[chain.ChainId] = revokedChain; + } + + var sessions = _sessions.Values + .Where(s => s.ChainId == chain.ChainId) + .ToList(); + + foreach (var session in sessions) + { + if (!session.IsRevoked) + { + var revokedSession = session.Revoke(at); + _sessions[session.SessionId] = revokedSession; + } + } + } + + if (!root.IsRevoked) + { + var revokedRoot = root.Revoke(at); + _roots[userKey] = revokedRoot; + } + } + + return Task.CompletedTask; + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernelFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs similarity index 63% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernelFactory.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs index 6bd845eb..af6b5e99 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernelFactory.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs @@ -4,12 +4,12 @@ namespace CodeBeam.UltimateAuth.Sessions.InMemory; -public sealed class InMemorySessionStoreKernelFactory : ISessionStoreKernelFactory +public sealed class InMemorySessionStoreFactory : ISessionStoreFactory { - private readonly ConcurrentDictionary _kernels = new(); + private readonly ConcurrentDictionary _kernels = new(); - public ISessionStoreKernel Create(TenantKey tenant) + public ISessionStore Create(TenantKey tenant) { - return _kernels.GetOrAdd(tenant, _ => new InMemorySessionStoreKernel()); + return _kernels.GetOrAdd(tenant, _ => new InMemorySessionStore()); } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs deleted file mode 100644 index 893a73b0..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs +++ /dev/null @@ -1,155 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using System.Collections.Concurrent; - -namespace CodeBeam.UltimateAuth.Sessions.InMemory; - -internal sealed class InMemorySessionStoreKernel : ISessionStoreKernel -{ - private readonly SemaphoreSlim _tx = new(1, 1); - - private readonly ConcurrentDictionary _sessions = new(); - private readonly ConcurrentDictionary _chains = new(); - private readonly ConcurrentDictionary _roots = new(); - private readonly ConcurrentDictionary _activeSessions = new(); - - public async Task ExecuteAsync(Func action, CancellationToken ct = default) - { - await _tx.WaitAsync(ct); - try - { - await action(ct); - } - finally - { - _tx.Release(); - } - } - - public async Task ExecuteAsync(Func> action, CancellationToken ct = default) - { - await _tx.WaitAsync(ct); - try - { - return await action(ct); - } - finally - { - _tx.Release(); - } - } - - public Task GetSessionAsync(AuthSessionId sessionId) => Task.FromResult(_sessions.TryGetValue(sessionId, out var s) ? s : null); - - public Task SaveSessionAsync(UAuthSession session) - { - _sessions[session.SessionId] = session; - return Task.CompletedTask; - } - - public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) - { - if (!_sessions.TryGetValue(sessionId, out var session)) - return Task.FromResult(false); - - if (session.IsRevoked) - return Task.FromResult(false); - - _sessions[sessionId] = session.Revoke(at); - return Task.FromResult(true); - } - - public Task GetChainAsync(SessionChainId chainId) - => Task.FromResult(_chains.TryGetValue(chainId, out var c) ? c : null); - - public Task SaveChainAsync(UAuthSessionChain chain) - { - _chains[chain.ChainId] = chain; - return Task.CompletedTask; - } - - public Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) - { - if (_chains.TryGetValue(chainId, out var chain)) - { - _chains[chainId] = chain.Revoke(at); - } - return Task.CompletedTask; - } - - public Task GetActiveSessionIdAsync(SessionChainId chainId) - => Task.FromResult(_activeSessions.TryGetValue(chainId, out var id) ? id : null); - - public Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId) - { - _activeSessions[chainId] = sessionId; - return Task.CompletedTask; - } - - public Task GetSessionRootByUserAsync(UserKey userKey) - => Task.FromResult(_roots.TryGetValue(userKey, out var r) ? r : null); - - public Task GetSessionRootByIdAsync(SessionRootId rootId) - => Task.FromResult(_roots.Values.FirstOrDefault(r => r.RootId == rootId)); - - public Task SaveSessionRootAsync(UAuthSessionRoot root) - { - _roots[root.UserKey] = root; - return Task.CompletedTask; - } - - public Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at) - { - if (_roots.TryGetValue(userKey, out var root)) - { - _roots[userKey] = root.Revoke(at); - } - return Task.CompletedTask; - } - - public Task GetChainIdBySessionAsync(AuthSessionId sessionId) - { - if (_sessions.TryGetValue(sessionId, out var session)) - return Task.FromResult(session.ChainId); - - return Task.FromResult(null); - } - - public Task> GetChainsByUserAsync(UserKey userKey) - { - if (!_roots.TryGetValue(userKey, out var root)) - return Task.FromResult>(Array.Empty()); - - return Task.FromResult>(root.Chains.ToList()); - } - - public Task> GetSessionsByChainAsync(SessionChainId chainId) - { - var result = _sessions.Values - .Where(s => s.ChainId == chainId) - .ToList(); - - return Task.FromResult>(result); - } - - public Task DeleteExpiredSessionsAsync(DateTimeOffset at) - { - foreach (var kvp in _sessions) - { - var session = kvp.Value; - - if (session.ExpiresAt <= at) - { - var revoked = session.Revoke(at); - _sessions[kvp.Key] = revoked; - - if (_activeSessions.TryGetValue(revoked.ChainId, out var activeId) && activeId == revoked.SessionId) - { - _activeSessions.TryRemove(revoked.ChainId, out _); - } - } - } - - return Task.CompletedTask; - } -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs index aebffb25..054fcffc 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs @@ -7,7 +7,7 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthInMemorySessions(this IServiceCollection services) { - services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs index 138f800d..d1167f36 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs @@ -20,5 +20,5 @@ internal sealed class RefreshTokenProjection public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset? RevokedAt { get; set; } - public byte[] RowVersion { get; set; } = default!; + public long Version { get; set; } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs index 0dc5a1e5..72418bec 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs @@ -12,5 +12,5 @@ internal sealed class RevokedTokenIdProjection public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset RevokedAt { get; set; } - public byte[] RowVersion { get; set; } = default!; + public long Version { get; set; } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs index be84d6e1..f8c371ea 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs @@ -18,8 +18,7 @@ protected override void OnModelCreating(ModelBuilder b) { e.HasKey(x => x.Id); - e.Property(x => x.RowVersion) - .IsRowVersion(); + e.Property(x => x.Version).IsConcurrencyToken(); e.Property(x => x.TokenHash) .IsRequired(); @@ -40,8 +39,7 @@ protected override void OnModelCreating(ModelBuilder b) { e.HasKey(x => x.Id); - e.Property(x => x.RowVersion) - .IsRowVersion(); + e.Property(x => x.Version).IsConcurrencyToken(); e.Property(x => x.Jti) .IsRequired(); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceQuery.cs new file mode 100644 index 00000000..7906dd6e --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceQuery.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record IdentifierExistenceQuery( + TenantKey Tenant, + UserIdentifierType Type, + string NormalizedValue, + IdentifierExistenceScope Scope, + UserKey? UserKey = null, + Guid? ExcludeIdentifierId = null + ); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceScope.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceScope.cs new file mode 100644 index 00000000..ba2d8f33 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceScope.cs @@ -0,0 +1,19 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public enum IdentifierExistenceScope +{ + /// + /// Checks only within the same user. + /// + WithinUser, + + /// + /// Checks within tenant but only primary identifiers. + /// + TenantPrimaryOnly, + + /// + /// Checks within tenant regardless of primary flag. + /// + TenantAny +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfUserStatus.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfUserStatus.cs new file mode 100644 index 00000000..752fc7eb --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfUserStatus.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public enum SelfUserStatus +{ + Active = 0, + SelfSuspended = 10, +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserLifecycleSnapshot.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserLifecycleSnapshot.cs new file mode 100644 index 00000000..0b1b6b39 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserLifecycleSnapshot.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserLifecycleSnapshot +{ + public UserKey UserKey { get; init; } + public UserStatus Status { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileSnapshot.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserProfileSnapshot.cs similarity index 100% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileSnapshot.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserProfileSnapshot.cs diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs index a4d91eec..e8fc7fa7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs @@ -1,12 +1,16 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; -public sealed record UserIdentifierDto +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserIdentifierDto : IVersionedEntity { public Guid Id { get; set; } public required UserIdentifierType Type { get; set; } public required string Value { get; set; } + public string NormalizedValue { get; set; } = default!; public bool IsPrimary { get; set; } public bool IsVerified { get; set; } public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset? VerifiedAt { get; set; } + public long Version { get; set; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs index ce89dea8..051da1bc 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs @@ -14,6 +14,9 @@ public sealed record UserViewDto public string? Bio { get; init; } public DateOnly? BirthDate { get; init; } public string? Gender { get; init; } + public string? Language { get; init; } + public string? TimeZone { get; init; } + public string? Culture { get; init; } public bool EmailVerified { get; init; } public bool PhoneVerified { get; init; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs new file mode 100644 index 00000000..b1b30e2b --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public static class UserStatusMapper +{ + public static UserStatus ToUserStatus(this SelfUserStatus selfStatus) + { + switch (selfStatus) + { + case SelfUserStatus.Active: + return UserStatus.Active; + case SelfUserStatus.SelfSuspended: + return UserStatus.SelfSuspended; + default: + throw new NotImplementedException(); + } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs index 5b7561e3..cb0d9521 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Users.Contracts; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs index dba43740..f9c82d6e 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs @@ -2,5 +2,5 @@ public class ChangeUserStatusSelfRequest { - public required UserStatus NewStatus { get; init; } + public required SelfUserStatus NewStatus { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs index ff720ce8..e078eb69 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs @@ -2,6 +2,13 @@ public sealed record CreateUserRequest { + public string? UserName { get; init; } + public bool UserNameVerified { get; init; } + public string? Email { get; init; } + public bool EmailVerified { get; init; } + public string? Phone { get; init; } + public bool PhoneVerified { get; init; } + public string? FirstName { get; init; } public string? LastName { get; init; } public string? DisplayName { get; init; } @@ -15,8 +22,4 @@ public sealed record CreateUserRequest public string? TimeZone { get; init; } public string? Culture { get; init; } public IReadOnlyDictionary? Metadata { get; init; } - - public UserIdentifierType? PrimaryIdentifierType { get; init; } - public string? PrimaryIdentifierValue { get; init; } - public bool PrimaryIdentifierVerified { get; init; } = false; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/IdentifierExistsRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/IdentifierExistsRequest.cs new file mode 100644 index 00000000..c2d9ef05 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/IdentifierExistsRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class IdentifierExistsRequest +{ + public UserIdentifierType Type { get; set; } + public string Value { get; set; } = default!; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceAdminRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceAdminRequest.cs new file mode 100644 index 00000000..d2a75bdd --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceAdminRequest.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class LogoutDeviceAdminRequest +{ + public required UserKey UserKey { get; init; } + public required SessionChainId ChainId { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceSelfRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceSelfRequest.cs new file mode 100644 index 00000000..82ef6c99 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceSelfRequest.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class LogoutDeviceSelfRequest +{ + public required SessionChainId ChainId { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesAdminRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesAdminRequest.cs new file mode 100644 index 00000000..e9d053e8 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesAdminRequest.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class LogoutOtherDevicesAdminRequest +{ + public required UserKey UserKey { get; init; } + public required SessionChainId CurrentChainId { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesSelfRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesSelfRequest.cs new file mode 100644 index 00000000..6f6b4e97 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesSelfRequest.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class LogoutOtherDevicesSelfRequest +{ + public required SessionChainId CurrentChainId { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserIdentifierQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserIdentifierQuery.cs new file mode 100644 index 00000000..f425570d --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserIdentifierQuery.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class UserIdentifierQuery : PageRequest +{ + public UserKey? UserKey { get; set; } + + public bool IncludeDeleted { get; init; } = false; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistenceResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistenceResult.cs new file mode 100644 index 00000000..ec234ff4 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistenceResult.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record IdentifierExistenceResult( + bool Exists, + UserKey? OwnerUserKey = null, + Guid? OwnerIdentifierId = null, + bool OwnerIsPrimary = false + ); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistsResponse.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistsResponse.cs new file mode 100644 index 00000000..0d2150d1 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistsResponse.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class IdentifierExistsResponse +{ + public bool Exists { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierValidationResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierValidationResult.cs new file mode 100644 index 00000000..f79173ef --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierValidationResult.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class IdentifierValidationResult +{ + public bool IsValid { get; } + + public IReadOnlyList Errors { get; } + + private IdentifierValidationResult(bool isValid, IReadOnlyList errors) + { + IsValid = isValid; + Errors = errors; + } + + public static IdentifierValidationResult Success() + => new(true, Array.Empty()); + + public static IdentifierValidationResult Failed(IEnumerable errors) + => new(false, errors.ToList()); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateValidatorResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateValidatorResult.cs new file mode 100644 index 00000000..90f48c27 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateValidatorResult.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class UserCreateValidatorResult +{ + public bool IsValid { get; } + + public IReadOnlyList Errors { get; } + + private UserCreateValidatorResult(bool isValid, IReadOnlyList errors) + { + IsValid = isValid; + Errors = errors; + } + + public static UserCreateValidatorResult Success() + => new(true, Array.Empty()); + + public static UserCreateValidatorResult Failed(IEnumerable errors) + => new(false, errors.ToList().AsReadOnly()); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs index e355cd9a..25706af4 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; public sealed record UserStatusChangeResult { diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs index 01db0848..9492398b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -14,9 +14,6 @@ public static IServiceCollection AddUltimateAuthUsersInMemory(this IServiceColle services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddScoped(); - services.TryAddScoped(); services.TryAddSingleton, InMemoryUserIdProvider>(); // Seed never try add diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs deleted file mode 100644 index 71ec819a..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.InMemory; - -internal sealed class InMemoryUserSecurityState : IUserSecurityState -{ - public long SecurityVersion { get; init; } - public int FailedLoginAttempts { get; init; } - public DateTimeOffset? LockedUntil { get; init; } - public bool RequiresReauthentication { get; init; } - public DateTimeOffset? LastFailedAt { get; init; } - - public bool IsLocked => LockedUntil.HasValue && LockedUntil.Value > DateTimeOffset.UtcNow; -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs deleted file mode 100644 index b0331abd..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Users.InMemory; - -internal sealed class InMemoryUserSecurityStateProvider : IUserSecurityStateProvider -{ - private readonly InMemoryUserSecurityStore _store; - - public InMemoryUserSecurityStateProvider(InMemoryUserSecurityStore store) - { - _store = store; - } - - public Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - return Task.FromResult(_store.Get(tenant, userKey)); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs deleted file mode 100644 index a97f1f44..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs +++ /dev/null @@ -1,53 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Users.InMemory; - -internal sealed class InMemoryUserSecurityStateWriter : IUserSecurityStateWriter -{ - private readonly InMemoryUserSecurityStore _store; - - public InMemoryUserSecurityStateWriter(InMemoryUserSecurityStore store) - { - _store = store; - } - - public Task RecordFailedLoginAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) - { - var current = _store.Get(tenant, userKey); - - var next = new InMemoryUserSecurityState - { - SecurityVersion = (current?.SecurityVersion ?? 0) + 1, - FailedLoginAttempts = (current?.FailedLoginAttempts ?? 0) + 1, - LockedUntil = current?.LockedUntil, - RequiresReauthentication = current?.RequiresReauthentication ?? false, - LastFailedAt = at - }; - - _store.Set(tenant, userKey, next); - return Task.CompletedTask; - } - - public Task LockUntilAsync(TenantKey tenant, UserKey userKey, DateTimeOffset lockedUntil, CancellationToken ct = default) - { - var current = _store.Get(tenant, userKey); - - var next = new InMemoryUserSecurityState - { - SecurityVersion = (current?.SecurityVersion ?? 0) + 1, - FailedLoginAttempts = current?.FailedLoginAttempts ?? 0, - LockedUntil = lockedUntil, - RequiresReauthentication = current?.RequiresReauthentication ?? false - }; - - _store.Set(tenant, userKey, next); - return Task.CompletedTask; - } - - public Task ResetFailuresAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - _store.Clear(tenant, userKey); - return Task.CompletedTask; - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs index c5312f51..2335b208 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; @@ -15,6 +16,7 @@ internal sealed class InMemoryUserSeedContributor : ISeedContributor private readonly IUserProfileStore _profiles; private readonly IUserIdentifierStore _identifiers; private readonly IInMemoryUserIdProvider _ids; + private readonly IIdentifierNormalizer _identifierNormalizer; private readonly IClock _clock; public InMemoryUserSeedContributor( @@ -22,12 +24,14 @@ public InMemoryUserSeedContributor( IUserProfileStore profiles, IUserIdentifierStore identifiers, IInMemoryUserIdProvider ids, + IIdentifierNormalizer identifierNormalizer, IClock clock) { _lifecycle = lifecycle; _profiles = profiles; _ids = ids; _identifiers = identifiers; + _identifierNormalizer = identifierNormalizer; _clock = clock; } @@ -37,67 +41,49 @@ public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) await SeedUserAsync(tenant, _ids.GetUserUserId(), "Standard User", "user", "user@ultimateauth.com", "9876543210", ct); } - private async Task SeedUserAsync(TenantKey tenant, UserKey userKey, string displayName, string primaryUsername, - string primaryEmail, string primaryPhone, CancellationToken ct) + private async Task SeedUserAsync(TenantKey tenant, UserKey userKey, string displayName, string username, string email, string phone, CancellationToken ct) { - if (await _lifecycle.ExistsAsync(tenant, userKey, ct)) + var userLifecycleKey = new UserLifecycleKey(tenant, userKey); + if (await _lifecycle.ExistsAsync(userLifecycleKey, ct)) return; - await _lifecycle.CreateAsync(tenant, - new UserLifecycle - { - Tenant = tenant, - UserKey = userKey, - Status = UserStatus.Active, - CreatedAt = _clock.UtcNow - }, ct); + await _lifecycle.AddAsync(UserLifecycle.Create(tenant, userKey, _clock.UtcNow), ct); + await _profiles.AddAsync(UserProfile.Create(_clock.UtcNow, tenant, userKey, displayName: displayName), ct); - await _profiles.CreateAsync(tenant, - new UserProfile - { - Tenant = tenant, - UserKey = userKey, - DisplayName = displayName, - CreatedAt = _clock.UtcNow - }, ct); + await _identifiers.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + tenant, + userKey, + UserIdentifierType.Username, + username, + _identifierNormalizer.Normalize(UserIdentifierType.Username, username).Normalized, + _clock.UtcNow, + true, + _clock.UtcNow), ct); - await _identifiers.CreateAsync(tenant, - new UserIdentifier - { - Id = Guid.NewGuid(), - Tenant = tenant, - UserKey = userKey, - Type = UserIdentifierType.Username, - Value = primaryUsername, - IsPrimary = true, - IsVerified = true, - CreatedAt = _clock.UtcNow - }, ct); + await _identifiers.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + tenant, + userKey, + UserIdentifierType.Email, + email, + _identifierNormalizer.Normalize(UserIdentifierType.Email, email).Normalized, + _clock.UtcNow, + true, + _clock.UtcNow), ct); - await _identifiers.CreateAsync(tenant, - new UserIdentifier - { - Id = Guid.NewGuid(), - Tenant = tenant, - UserKey = userKey, - Type = UserIdentifierType.Email, - Value = primaryEmail, - IsPrimary = true, - IsVerified = true, - CreatedAt = _clock.UtcNow - }, ct); - - await _identifiers.CreateAsync(tenant, - new UserIdentifier - { - Id = Guid.NewGuid(), - Tenant = tenant, - UserKey = userKey, - Type = UserIdentifierType.Phone, - Value = primaryPhone, - IsPrimary = true, - IsVerified = true, - CreatedAt = _clock.UtcNow - }, ct); + await _identifiers.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + tenant, + userKey, + UserIdentifierType.Phone, + phone, + _identifierNormalizer.Normalize(UserIdentifierType.Phone, phone).Normalized, + _clock.UtcNow, + true, + _clock.UtcNow), ct); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index b18a1272..d898c5de 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -7,54 +8,72 @@ namespace CodeBeam.UltimateAuth.Users.InMemory; -public sealed class InMemoryUserIdentifierStore : IUserIdentifierStore +public sealed class InMemoryUserIdentifierStore : InMemoryVersionedStore, IUserIdentifierStore { - private readonly Dictionary _store = new(); + protected override Guid GetKey(UserIdentifier entity) => entity.Id; + private readonly object _primaryLock = new(); - public Task ExistsAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default) + public Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var exists = _store.Values.Any(x => - x.Tenant == tenant && - x.Type == type && - x.Value == value && - !x.IsDeleted); + var candidates = Values() + .Where(x => + x.Tenant == query.Tenant && + x.Type == query.Type && + x.NormalizedValue == query.NormalizedValue && + !x.IsDeleted); - return Task.FromResult(exists); + if (query.ExcludeIdentifierId.HasValue) + candidates = candidates.Where(x => x.Id != query.ExcludeIdentifierId.Value); + + candidates = query.Scope switch + { + IdentifierExistenceScope.WithinUser => + candidates.Where(x => x.UserKey == query.UserKey), + + IdentifierExistenceScope.TenantPrimaryOnly => + candidates.Where(x => x.IsPrimary), + + IdentifierExistenceScope.TenantAny => + candidates, + + _ => candidates + }; + + var match = candidates.FirstOrDefault(); + + if (match is null) + return Task.FromResult(new IdentifierExistenceResult(false)); + + return Task.FromResult( + new IdentifierExistenceResult(true, match.UserKey, match.Id, match.IsPrimary)); } - public Task GetAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default) + public Task GetAsync(TenantKey tenant, UserIdentifierType type, string normalizedValue, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var identifier = _store.Values.FirstOrDefault(x => - x.Tenant == tenant && - x.Type == type && - x.Value == value && - !x.IsDeleted); + var identifier = Values() + .FirstOrDefault(x => + x.Tenant == tenant && + x.Type == type && + x.NormalizedValue == normalizedValue && + !x.IsDeleted); return Task.FromResult(identifier); } public Task GetByIdAsync(Guid id, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); - - if (!_store.TryGetValue(id, out var identifier)) - return Task.FromResult(null); - - if (identifier.IsDeleted) - return Task.FromResult(null); - - return Task.FromResult(identifier); + return GetAsync(id, ct); } public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var result = _store.Values + var result = Values() .Where(x => x.Tenant == tenant) .Where(x => x.UserKey == userKey) .Where(x => !x.IsDeleted) @@ -65,155 +84,128 @@ public Task> GetByUserAsync(TenantKey tenant, User return Task.FromResult>(result); } - public Task CreateAsync(TenantKey tenant, UserIdentifier identifier, CancellationToken ct = default) + protected override void BeforeAdd(UserIdentifier entity) { - ct.ThrowIfCancellationRequested(); - - if (identifier.Id == Guid.Empty) - identifier.Id = Guid.NewGuid(); - - var duplicate = _store.Values.Any(x => - x.Tenant == tenant && - x.Type == identifier.Type && - x.Value == identifier.Value && - !x.IsDeleted); - - if (duplicate) - throw new UAuthConflictException("identifier_already_exists"); - - identifier.Tenant = tenant; - - _store[identifier.Id] = identifier; - - return Task.CompletedTask; + if (!entity.IsPrimary) + return; + + foreach (var other in InternalValues().Where(x => + x.Tenant == entity.Tenant && + x.UserKey == entity.UserKey && + x.Type == entity.Type && + x.Id != entity.Id && + x.IsPrimary && + !x.IsDeleted)) + { + other.UnsetPrimary(entity.UpdatedAt ?? entity.CreatedAt); + } } - public Task UpdateValueAsync(Guid id, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default) + public override Task AddAsync(UserIdentifier entity, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); - - if (!_store.TryGetValue(id, out var identifier) || identifier.IsDeleted) - throw new InvalidOperationException("identifier_not_found"); - - if (identifier.Value == newValue) - throw new InvalidOperationException("identifier_value_unchanged"); - - var duplicate = _store.Values.Any(x => - x.Id != id && - x.Tenant == identifier.Tenant && - x.Type == identifier.Type && - x.Value == newValue && - !x.IsDeleted); - - if (duplicate) - throw new InvalidOperationException("identifier_value_already_exists"); - - identifier.Value = newValue; - identifier.IsVerified = false; - identifier.VerifiedAt = null; - identifier.UpdatedAt = updatedAt; - - return Task.CompletedTask; + lock (_primaryLock) + { + return base.AddAsync(entity, ct); + } } - public Task MarkVerifiedAsync(Guid id, DateTimeOffset verifiedAt, CancellationToken ct = default) + protected override void BeforeSave(UserIdentifier entity, UserIdentifier current, long expectedVersion) { - ct.ThrowIfCancellationRequested(); - - if (!_store.TryGetValue(id, out var identifier) || identifier.IsDeleted) - throw new InvalidOperationException("identifier_not_found"); - - if (identifier.IsVerified) - return Task.CompletedTask; - - identifier.IsVerified = true; - identifier.VerifiedAt = verifiedAt; - identifier.UpdatedAt = verifiedAt; + if (!entity.IsPrimary) + return; + + foreach (var other in InternalValues().Where(x => + x.Tenant == entity.Tenant && + x.UserKey == entity.UserKey && + x.Type == entity.Type && + x.Id != entity.Id && + x.IsPrimary && + !x.IsDeleted)) + { + other.UnsetPrimary(entity.UpdatedAt ?? entity.CreatedAt); + } + } - return Task.CompletedTask; + public override Task SaveAsync(UserIdentifier entity, long expectedVersion, CancellationToken ct = default) + { + lock (_primaryLock) + { + return base.SaveAsync(entity, expectedVersion, ct); + } } - public Task SetPrimaryAsync(Guid id, CancellationToken ct = default) + public Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_store.TryGetValue(id, out var target) || target.IsDeleted) - throw new InvalidOperationException("identifier_not_found"); + if (query.UserKey is null) + throw new UAuthIdentifierValidationException("userKey_required"); - foreach (var idf in _store.Values.Where(x => - x.Tenant == target.Tenant && - x.UserKey == target.UserKey && - x.Type == target.Type && - x.IsPrimary)) - { - idf.IsPrimary = false; - } + var normalized = query.Normalize(); - target.IsPrimary = true; + var baseQuery = Values() + .Where(x => x.Tenant == tenant) + .Where(x => x.UserKey == query.UserKey.Value); - return Task.CompletedTask; - } + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => !x.IsDeleted); - public Task UnsetPrimaryAsync(Guid id, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + baseQuery = query.SortBy switch + { + nameof(UserIdentifier.Type) => query.Descending + ? baseQuery.OrderByDescending(x => x.Type) + : baseQuery.OrderBy(x => x.Type), - if (!_store.TryGetValue(id, out var identifier) || identifier.IsDeleted) - throw new InvalidOperationException("identifier_not_found"); + nameof(UserIdentifier.CreatedAt) => query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), - identifier.IsPrimary = false; - identifier.UpdatedAt = DateTimeOffset.UtcNow; + nameof(UserIdentifier.UpdatedAt) => query.Descending + ? baseQuery.OrderByDescending(x => x.UpdatedAt) + : baseQuery.OrderBy(x => x.UpdatedAt), - return Task.CompletedTask; - } + nameof(UserIdentifier.Value) => query.Descending + ? baseQuery.OrderByDescending(x => x.Value) + : baseQuery.OrderBy(x => x.Value), - public Task DeleteAsync(Guid id, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + nameof(UserIdentifier.NormalizedValue) => query.Descending + ? baseQuery.OrderByDescending(x => x.NormalizedValue) + : baseQuery.OrderBy(x => x.NormalizedValue), - if (!_store.TryGetValue(id, out var identifier)) - return Task.CompletedTask; + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; - if (mode == DeleteMode.Hard) - { - _store.Remove(id); - return Task.CompletedTask; - } + var totalCount = baseQuery.Count(); - if (identifier.IsDeleted) - return Task.CompletedTask; + var items = baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .Select(x => x.Snapshot()) + .ToList() + .AsReadOnly(); - identifier.IsDeleted = true; - identifier.DeletedAt = deletedAt; - identifier.IsPrimary = false; - identifier.UpdatedAt = deletedAt; + return Task.FromResult( + new PagedResult( + items, + totalCount, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending)); - return Task.CompletedTask; } - public Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var identifiers = _store.Values.Where(x => x.Tenant == tenant && x.UserKey == userKey).ToList(); + var identifiers = Values() + .Where(x => x.Tenant == tenant && x.UserKey == userKey && !x.IsDeleted) + .ToList(); foreach (var identifier in identifiers) { - if (mode == DeleteMode.Hard) - { - _store.Remove(identifier.Id); - } - else - { - if (identifier.IsDeleted) - continue; - - identifier.IsDeleted = true; - identifier.DeletedAt = deletedAt; - identifier.IsPrimary = false; - } + await DeleteAsync(identifier.Id, identifier.Version, mode, deletedAt, ct); } - - return Task.CompletedTask; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs index aea09138..912fd90b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs @@ -1,35 +1,22 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; namespace CodeBeam.UltimateAuth.Users.InMemory; -public sealed class InMemoryUserLifecycleStore : IUserLifecycleStore +public sealed class InMemoryUserLifecycleStore : InMemoryVersionedStore, IUserLifecycleStore { - private readonly Dictionary<(TenantKey, UserKey), UserLifecycle> _store = new(); + protected override UserLifecycleKey GetKey(UserLifecycle entity) + => new(entity.Tenant, entity.UserKey); - public Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - return Task.FromResult(_store.TryGetValue((tenant, userKey), out var entity) && !entity.IsDeleted); - } - - public Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + public Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default) { - if (!_store.TryGetValue((tenant, userKey), out var entity)) - return Task.FromResult(null); + ct.ThrowIfCancellationRequested(); - if (entity.IsDeleted) - return Task.FromResult(null); + var normalized = query.Normalize(); - return Task.FromResult(entity); - } - - public Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default) - { - var baseQuery = _store.Values - .Where(x => x?.UserKey != null) + var baseQuery = Values() .Where(x => x.Tenant == tenant); if (!query.IncludeDeleted) @@ -38,74 +25,44 @@ public Task> QueryAsync(TenantKey tenant, UserLifecyc if (query.Status != null) baseQuery = baseQuery.Where(x => x.Status == query.Status); - var totalCount = baseQuery.Count(); - - var items = baseQuery - .OrderBy(x => x.CreatedAt) - .Skip(query.Skip) - .Take(query.Take) - .ToList() - .AsReadOnly(); - - return Task.FromResult(new PagedResult(items, totalCount)); - } - - public Task CreateAsync(TenantKey tenant, UserLifecycle lifecycle, CancellationToken ct = default) - { - var key = (tenant, lifecycle.UserKey); - - if (_store.ContainsKey(key)) - throw new InvalidOperationException("UserLifecycle already exists."); - - _store[key] = lifecycle; - return Task.CompletedTask; - } - - public Task ChangeStatusAsync(TenantKey tenant, UserKey userKey, UserStatus newStatus, DateTimeOffset updatedAt, CancellationToken ct = default) - { - if (!_store.TryGetValue((tenant, userKey), out var entity) || entity.IsDeleted) - throw new InvalidOperationException("UserLifecycle not found."); - - entity.Status = newStatus; - entity.UpdatedAt = updatedAt; - - return Task.CompletedTask; - } - - public Task ChangeSecurityStampAsync(TenantKey tenant, UserKey userKey, Guid newSecurityStamp, DateTimeOffset updatedAt, CancellationToken ct = default) - { - if (!_store.TryGetValue((tenant, userKey), out var entity) || entity.IsDeleted) - throw new InvalidOperationException("UserLifecycle not found."); - - if (entity.SecurityStamp == newSecurityStamp) - return Task.CompletedTask; - - entity.SecurityStamp = newSecurityStamp; - entity.UpdatedAt = updatedAt; - - return Task.CompletedTask; - } - - public Task DeleteAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) - { - var key = (tenant, userKey); - - if (!_store.TryGetValue(key, out var entity)) - return Task.CompletedTask; - - if (mode == DeleteMode.Hard) + baseQuery = query.SortBy switch { - _store.Remove(key); - return Task.CompletedTask; - } + nameof(UserLifecycle.Id) => + query.Descending + ? baseQuery.OrderByDescending(x => x.Id) + : baseQuery.OrderBy(x => x.Id), + + nameof(UserLifecycle.CreatedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), + + nameof(UserLifecycle.Status) => + query.Descending + ? baseQuery.OrderByDescending(x => x.Status) + : baseQuery.OrderBy(x => x.Status), + + nameof(UserLifecycle.Tenant) => + query.Descending + ? baseQuery.OrderByDescending(x => x.Tenant.Value) + : baseQuery.OrderBy(x => x.Tenant.Value), + + nameof(UserLifecycle.UserKey) => + query.Descending + ? baseQuery.OrderByDescending(x => x.UserKey.Value) + : baseQuery.OrderBy(x => x.UserKey.Value), + + nameof(UserLifecycle.DeletedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.DeletedAt) + : baseQuery.OrderBy(x => x.DeletedAt), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; - // Soft delete (idempotent) - if (entity.IsDeleted) - return Task.CompletedTask; - - entity.IsDeleted = true; - entity.DeletedAt = deletedAt; + var totalCount = baseQuery.Count(); + var items = baseQuery.Skip((normalized.PageNumber - 1) * normalized.PageSize).Take(normalized.PageSize).ToList().AsReadOnly(); - return Task.CompletedTask; + return Task.FromResult(new PagedResult(items, totalCount, normalized.PageNumber, normalized.PageSize, query.SortBy, query.Descending)); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs index a902bc57..dee140cf 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -1,102 +1,68 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Reference; namespace CodeBeam.UltimateAuth.Users.InMemory; -public sealed class InMemoryUserProfileStore : IUserProfileStore +public sealed class InMemoryUserProfileStore : InMemoryVersionedStore, IUserProfileStore { - private readonly Dictionary<(TenantKey Tenant, UserKey UserKey), UserProfile> _store = new(); - - public Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - return Task.FromResult(_store.TryGetValue((tenant, userKey), out var profile) && profile.DeletedAt == null); - } - - public Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - if (!_store.TryGetValue((tenant, userKey), out var profile)) - return Task.FromResult(null); - - if (profile.DeletedAt != null) - return Task.FromResult(null); - - return Task.FromResult(profile); - } + protected override UserProfileKey GetKey(UserProfile entity) + => new(entity.Tenant, entity.UserKey); public Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default) { - var baseQuery = _store.Values.Where(x => x.Tenant == tenant); + ct.ThrowIfCancellationRequested(); - if (!query.IncludeDeleted) - baseQuery = baseQuery.Where(x => x.DeletedAt == null); + var normalized = query.Normalize(); - var totalCount = baseQuery.Count(); - - var items = baseQuery - .OrderBy(x => x.CreatedAt) - .Skip(query.Skip) - .Take(query.Take) - .ToList() - .AsReadOnly(); - - return Task.FromResult(new PagedResult(items, totalCount)); - } + var baseQuery = Values() + .Where(x => x.Tenant == tenant); - public Task CreateAsync(TenantKey tenant, UserProfile profile, CancellationToken ct = default) - { - var key = (tenant, profile.UserKey); - - if (_store.ContainsKey(key)) - throw new InvalidOperationException("UserProfile already exists."); - - _store[key] = profile; - return Task.CompletedTask; - } - - public Task UpdateAsync(TenantKey tenant, UserKey userKey, UserProfileUpdate update, DateTimeOffset updatedAt, CancellationToken ct = default) - { - var key = (tenant, userKey); - - if (!_store.TryGetValue(key, out var existing) || existing.DeletedAt != null) - throw new InvalidOperationException("UserProfile not found."); - - existing.FirstName = update.FirstName; - existing.LastName = update.LastName; - existing.DisplayName = update.DisplayName; - existing.BirthDate = update.BirthDate; - existing.Gender = update.Gender; - existing.Bio = update.Bio; - existing.Language = update.Language; - existing.TimeZone = update.TimeZone; - existing.Culture = update.Culture; - existing.Metadata = update.Metadata; + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => !x.IsDeleted); - existing.UpdatedAt = updatedAt; + baseQuery = query.SortBy switch + { + nameof(UserProfile.CreatedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), - return Task.CompletedTask; - } + nameof(UserProfile.DisplayName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.DisplayName) + : baseQuery.OrderBy(x => x.DisplayName), - public Task DeleteAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) - { - var key = (tenant, userKey); + nameof(UserProfile.FirstName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.FirstName) + : baseQuery.OrderBy(x => x.FirstName), - if (!_store.TryGetValue(key, out var profile)) - return Task.CompletedTask; + nameof(UserProfile.LastName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.LastName) + : baseQuery.OrderBy(x => x.LastName), - if (mode == DeleteMode.Hard) - { - _store.Remove(key); - return Task.CompletedTask; - } + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; - if (profile.IsDeleted) - return Task.CompletedTask; + var totalCount = baseQuery.Count(); - profile.IsDeleted = true; - profile.DeletedAt = deletedAt; + var items = baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .ToList() + .AsReadOnly(); - return Task.CompletedTask; + return Task.FromResult( + new PagedResult( + items, + totalCount, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending)); } -} +} \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs deleted file mode 100644 index cbbd3fc0..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs +++ /dev/null @@ -1,22 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using System.Collections.Concurrent; - -namespace CodeBeam.UltimateAuth.Users.InMemory; - -internal sealed class InMemoryUserSecurityStore : IUserSecurityStateDebugView -{ - private readonly ConcurrentDictionary<(TenantKey, UserKey), InMemoryUserSecurityState> _states = new(); - - public InMemoryUserSecurityState? Get(TenantKey tenant, UserKey userKey) - => _states.TryGetValue((tenant, userKey), out var state) ? state : null; - - public void Set(TenantKey tenant, UserKey userKey, InMemoryUserSecurityState state) - => _states[(tenant, userKey)] = state; - - public void Clear(TenantKey tenant, UserKey userKey) - => _states.TryRemove((tenant, userKey), out _); - - public IUserSecurityState? GetState(TenantKey tenant, UserKey userKey) - => Get(tenant, userKey); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs deleted file mode 100644 index 1b7b8e20..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class AddUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public AddUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs deleted file mode 100644 index 4279660d..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class ChangeUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public ChangeUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs deleted file mode 100644 index 954c6fa6..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class ChangeUserStatusCommand : IAccessCommand -{ - private readonly Func _execute; - - public ChangeUserStatusCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs deleted file mode 100644 index 675ebde9..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class CreateUserCommand : IAccessCommand -{ - private readonly Func> _execute; - - public CreateUserCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs deleted file mode 100644 index da4acc96..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class DeleteUserCommand : IAccessCommand -{ - private readonly Func _execute; - - public DeleteUserCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs deleted file mode 100644 index 59b9d079..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class DeleteUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public DeleteUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs deleted file mode 100644 index 90302ae1..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class GetMeCommand : IAccessCommand -{ - private readonly Func> _execute; - - public GetMeCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs deleted file mode 100644 index d5eb10f2..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class GetUserIdentifierCommand : IAccessCommand -{ - private readonly Func> _execute; - - public GetUserIdentifierCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs deleted file mode 100644 index a8219862..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class GetUserIdentifiersCommand : IAccessCommand> -{ - private readonly Func>> _execute; - - public GetUserIdentifiersCommand(Func>> execute) - { - _execute = execute; - } - - public Task> ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs deleted file mode 100644 index 82e7fe12..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class GetUserProfileCommand : IAccessCommand -{ - private readonly Func> _execute; - - public GetUserProfileCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs deleted file mode 100644 index 8a56df8c..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class SetPrimaryUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public SetPrimaryUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs deleted file mode 100644 index 48a7ad89..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UnsetPrimaryUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public UnsetPrimaryUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs deleted file mode 100644 index 1005453d..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UpdateUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public UpdateUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs deleted file mode 100644 index aa38706a..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UpdateUserProfileAdminCommand : IAccessCommand -{ - private readonly Func _execute; - - public UpdateUserProfileAdminCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs deleted file mode 100644 index b102c86b..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UpdateUserProfileCommand : IAccessCommand -{ - private readonly Func _execute; - - public UpdateUserProfileCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs deleted file mode 100644 index 28602a36..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UserIdentifierExistsCommand : IAccessCommand -{ - private readonly Func> _execute; - - public UserIdentifierExistsCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs deleted file mode 100644 index 186433d6..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class VerifyUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public VerifyUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs index a6c6a944..b1dc66e0 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs @@ -1,12 +1,9 @@ -using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -public sealed class UserLifecycleQuery +public sealed class UserLifecycleQuery : PageRequest { public bool IncludeDeleted { get; init; } public UserStatus? Status { get; init; } - - public int Skip { get; init; } - public int Take { get; init; } = 50; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs index d0cd9262..7b2808a9 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs @@ -1,9 +1,8 @@ -namespace CodeBeam.UltimateAuth.Users.Reference; +using CodeBeam.UltimateAuth.Core.Contracts; -public sealed class UserProfileQuery +namespace CodeBeam.UltimateAuth.Users.Reference; + +public sealed class UserProfileQuery : PageRequest { public bool IncludeDeleted { get; init; } - - public int Skip { get; init; } - public int Take { get; init; } = 50; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs deleted file mode 100644 index 68f2948c..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.Reference; - -public sealed record UserProfileUpdate -{ - public string? FirstName { get; init; } - public string? LastName { get; init; } - public string? DisplayName { get; init; } - public DateOnly? BirthDate { get; init; } - public string? Gender { get; init; } - public string? Bio { get; init; } - - public string? Language { get; init; } - public string? TimeZone { get; init; } - public string? Culture { get; init; } - - public IReadOnlyDictionary? Metadata { get; init; } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs index 5d06adc5..88151a2b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -1,27 +1,162 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; - -public sealed record UserIdentifier +public sealed class UserIdentifier : IVersionedEntity, ISoftDeletable, IEntitySnapshot { - - public Guid Id { get; set; } - public TenantKey Tenant { get; set; } - + public Guid Id { get; private set; } + public TenantKey Tenant { get; private set; } public UserKey UserKey { get; init; } public UserIdentifierType Type { get; init; } // Email, Phone, Username - public string Value { get; set; } = default!; - - public bool IsPrimary { get; set; } - public bool IsVerified { get; set; } + public string Value { get; private set; } = default!; + public string NormalizedValue { get; private set; } = default!; - public bool IsDeleted { get; set; } + public bool IsPrimary { get; private set; } public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? VerifiedAt { get; set; } - public DateTimeOffset? UpdatedAt { get; set; } - public DateTimeOffset? DeletedAt { get; set; } + public DateTimeOffset? VerifiedAt { get; private set; } + public DateTimeOffset? UpdatedAt { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } + + public long Version { get; set; } + + + public bool IsDeleted => DeletedAt is not null; + public bool IsVerified => VerifiedAt is not null; + + public UserIdentifier Snapshot() + { + return new UserIdentifier + { + Id = Id, + Tenant = Tenant, + UserKey = UserKey, + Type = Type, + Value = Value, + NormalizedValue = NormalizedValue, + IsPrimary = IsPrimary, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + VerifiedAt = VerifiedAt, + DeletedAt = DeletedAt, + Version = Version + }; + } + + public static UserIdentifier Create( + Guid? id, + TenantKey tenant, + UserKey userKey, + UserIdentifierType type, + string value, + string normalizedValue, + DateTimeOffset now, + bool isPrimary = false, + DateTimeOffset? verifiedAt = null) + { + return new UserIdentifier + { + Id = id ?? Guid.NewGuid(), + Tenant = tenant, + UserKey = userKey, + Type = type, + Value = value, + NormalizedValue = normalizedValue, + IsPrimary = isPrimary, + VerifiedAt = verifiedAt, + CreatedAt = now, + Version = 0 + }; + } + + public UserIdentifier ChangeValue(string newRawValue, string newNormalizedValue, DateTimeOffset now) + { + if (IsDeleted) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + if (NormalizedValue == newNormalizedValue) + throw new UAuthIdentifierConflictException("identifier_value_unchanged"); + + Value = newRawValue; + NormalizedValue = newNormalizedValue; + + VerifiedAt = null; + UpdatedAt = now; + + return this; + } + + public UserIdentifier MarkVerified(DateTimeOffset at) + { + if (IsDeleted) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + if (IsVerified) + throw new UAuthIdentifierConflictException("identifier_already_verified"); + + VerifiedAt = at; + UpdatedAt = at; + + return this; + } + + public UserIdentifier SetPrimary(DateTimeOffset at) + { + if (IsDeleted) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + if (IsPrimary) + return this; + + IsPrimary = true; + UpdatedAt = at; + + return this; + } + + public UserIdentifier UnsetPrimary(DateTimeOffset at) + { + if (IsDeleted) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + if (!IsPrimary) + throw new UAuthIdentifierConflictException("identifier_is_not_primary"); + + IsPrimary = false; + UpdatedAt = at; + + return this; + } + + public UserIdentifier MarkDeleted(DateTimeOffset at) + { + if (IsDeleted) + throw new UAuthIdentifierConflictException("identifier_already_deleted"); + + DeletedAt = at; + IsPrimary = false; + UpdatedAt = at; + + return this; + } + + public UserIdentifierDto ToDto() + { + return new UserIdentifierDto() + { + Id = Id, + Type = Type, + Value = Value, + NormalizedValue = NormalizedValue, + CreatedAt = CreatedAt, + IsPrimary = IsPrimary, + IsVerified = IsVerified, + VerifiedAt = VerifiedAt, + Version = Version + }; + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs index 085a2e71..8ee2470b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs @@ -1,21 +1,96 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -public sealed record class UserLifecycle +public sealed class UserLifecycle : IVersionedEntity, ISoftDeletable, IEntitySnapshot { - public TenantKey Tenant { get; set; } + private UserLifecycle() { } - public UserKey UserKey { get; init; } = default!; + public Guid Id { get; private set; } + public TenantKey Tenant { get; private set; } = default!; + public UserKey UserKey { get; private set; } = default!; - public UserStatus Status { get; set; } = UserStatus.Active; - public Guid SecurityStamp { get; set; } + public UserStatus Status { get; private set; } - public bool IsDeleted { get; set; } + public long SecurityVersion { get; private set; } - public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? UpdatedAt { get; set; } - public DateTimeOffset? DeletedAt { get; set; } + public DateTimeOffset CreatedAt { get; private set; } + public DateTimeOffset? UpdatedAt { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } + + public long Version { get; set; } + + public bool IsDeleted => DeletedAt != null; + public bool IsActive => !IsDeleted && Status == UserStatus.Active; + + public UserLifecycle Snapshot() + { + return new UserLifecycle + { + Id = Id, + Tenant = Tenant, + UserKey = UserKey, + Status = Status, + SecurityVersion = SecurityVersion, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + DeletedAt = DeletedAt, + Version = Version + }; + } + + public static UserLifecycle Create(TenantKey tenant, UserKey userKey, DateTimeOffset now, Guid? id = null) + { + return new UserLifecycle + { + Id = id ?? Guid.NewGuid(), + Tenant = tenant, + UserKey = userKey, + Status = UserStatus.Active, + CreatedAt = now, + SecurityVersion = 0, + Version = 0 + }; + } + + public UserLifecycle MarkDeleted(DateTimeOffset now) + { + if (IsDeleted) + return this; + + DeletedAt = now; + UpdatedAt = now; + SecurityVersion++; + + return this; + } + + public UserLifecycle Activate(DateTimeOffset now) + { + if (Status == UserStatus.Active) + return this; + + Status = UserStatus.Active; + UpdatedAt = now; + return this; + } + + public UserLifecycle ChangeStatus(DateTimeOffset now, UserStatus newStatus) + { + if (Status == newStatus) + return this; + + Status = newStatus; + UpdatedAt = now; + return this; + } + + public UserLifecycle IncrementSecurityVersion() + { + SecurityVersion++; + return this; + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycleKey.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycleKey.cs new file mode 100644 index 00000000..8e1ad17b --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycleKey.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public readonly record struct UserLifecycleKey( + TenantKey Tenant, + UserKey UserKey); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs index 15c9ee22..ff0227b4 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs @@ -1,32 +1,167 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Users.Reference; // TODO: Multi profile (e.g., public profiles, private profiles, profiles per application, etc. with ProfileKey) -public sealed record class UserProfile +public sealed class UserProfile : IVersionedEntity, ISoftDeletable, IEntitySnapshot { - public TenantKey Tenant { get; set; } + private UserProfile() { } - public UserKey UserKey { get; init; } = default!; + public Guid Id { get; private set; } + public TenantKey Tenant { get; private set; } - public string? FirstName { get; set; } - public string? LastName { get; set; } - public string? DisplayName { get; set; } + public UserKey UserKey { get; init; } = default!; - public DateOnly? BirthDate { get; set; } - public string? Gender { get; set; } - public string? Bio { get; set; } + public string? FirstName { get; private set; } + public string? LastName { get; private set; } + public string? DisplayName { get; private set; } - public string? Language { get; set; } - public string? TimeZone { get; set; } - public string? Culture { get; set; } + public DateOnly? BirthDate { get; private set; } + public string? Gender { get; private set; } + public string? Bio { get; private set; } - public IReadOnlyDictionary? Metadata { get; set; } + public string? Language { get; private set; } + public string? TimeZone { get; private set; } + public string? Culture { get; private set; } - public bool IsDeleted { get; set; } + public IReadOnlyDictionary? Metadata { get; private set; } public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? UpdatedAt { get; set; } - public DateTimeOffset? DeletedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } + + public long Version { get; set; } + + public bool IsDeleted => DeletedAt != null; + public string EffectiveDisplayName => DisplayName ?? $"{FirstName} {LastName}"; + + public UserProfile Snapshot() + { + return new UserProfile + { + Id = Id, + Tenant = Tenant, + UserKey = UserKey, + FirstName = FirstName, + LastName = LastName, + DisplayName = DisplayName, + BirthDate = BirthDate, + Gender = Gender, + Bio = Bio, + Language = Language, + TimeZone = TimeZone, + Culture = Culture, + Metadata = Metadata, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + DeletedAt = DeletedAt, + Version = Version + }; + } + + public static UserProfile Create( + DateTimeOffset createdAt, + TenantKey tenant, + UserKey userKey, + Guid? id = null, + string? firstName = null, + string? lastName = null, + string? displayName = null, + DateOnly? birthDate = null, + string? gender = null, + string? bio = null, + string? language = null, + string? timezone = null, + string? culture = null) + { + return new UserProfile + { + Id = id ?? Guid.NewGuid(), + Tenant = tenant, + UserKey = userKey, + FirstName = firstName, + LastName = lastName, + DisplayName = displayName, + BirthDate = birthDate, + Gender = gender, + Bio = bio, + Language = language, + TimeZone = timezone, + Culture = culture, + CreatedAt = createdAt, + UpdatedAt = null, + DeletedAt = null, + Metadata = null, + Version = 0 + }; + } + + public UserProfile UpdateName(string? firstName, string? lastName, string? displayName, DateTimeOffset now) + { + if (FirstName == firstName && + LastName == lastName && + DisplayName == displayName) + return this; + + FirstName = firstName; + LastName = lastName; + DisplayName = displayName; + UpdatedAt = now; + + return this; + } + + public UserProfile UpdatePersonalInfo(DateOnly? birthDate, string? gender, string? bio, DateTimeOffset now) + { + if (BirthDate == birthDate && + Gender == gender && + Bio == bio) + return this; + + BirthDate = birthDate; + Gender = gender; + Bio = bio; + UpdatedAt = now; + + return this; + } + + public UserProfile UpdateLocalization(string? language, string? timeZone, string? culture, DateTimeOffset now) + { + if (Language == language && + TimeZone == timeZone && + Culture == culture) + return this; + + Language = language; + TimeZone = timeZone; + Culture = culture; + UpdatedAt = now; + + return this; + } + + public UserProfile UpdateMetadata(IReadOnlyDictionary? metadata, DateTimeOffset now) + { + if (Metadata == metadata) + return this; + + Metadata = metadata; + UpdatedAt = now; + + return this; + } + + public UserProfile MarkDeleted(DateTimeOffset now) + { + if (IsDeleted) + return this; + + DeletedAt = now; + UpdatedAt = now; + + return this; + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs new file mode 100644 index 00000000..c197d94f --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public readonly record struct UserProfileKey( + TenantKey Tenant, + UserKey UserKey); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs index 3b67efe7..a4fee3e5 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Users.Contracts; @@ -24,14 +24,12 @@ public UserEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFact public async Task CreateAsync(HttpContext ctx) { var flow = _authFlow.Current; - if (!flow.IsAuthenticated) - return Results.Unauthorized(); var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( authFlow: flow, - action: UAuthActions.Users.Create, + action: UAuthActions.Users.CreateAnonymous, resource: "users"); var result = await _users.CreateUserAsync(accessContext, request, ctx.RequestAborted); @@ -145,6 +143,22 @@ public async Task UpdateUserAsync(UserKey userKey, HttpContext ctx) return Results.Ok(); } + public async Task DeleteMeAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.Users.DeleteSelf, + resource: "users", + resourceId: flow?.UserKey?.Value); + + await _users.DeleteMeAsync(accessContext, ctx.RequestAborted); + return Results.Ok(); + } + public async Task DeleteAsync(UserKey userKey, HttpContext ctx) { var flow = _authFlow.Current; @@ -173,13 +187,15 @@ public async Task GetMyIdentifiersAsync(HttpContext ctx) if (!flow.IsAuthenticated) return Results.Unauthorized(); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted) ?? new UserIdentifierQuery(); + var accessContext = await _accessContextFactory.CreateAsync( authFlow: flow, action: UAuthActions.UserIdentifiers.GetSelf, resource: "users", resourceId: flow.UserKey!.Value); - var result = await _users.GetIdentifiersByUserAsync(accessContext,ctx.RequestAborted); + var result = await _users.GetIdentifiersByUserAsync(accessContext, request, ctx.RequestAborted); return Results.Ok(result); } @@ -190,17 +206,73 @@ public async Task GetUserIdentifiersAsync(UserKey userKey, HttpContext if (!flow.IsAuthenticated) return Results.Unauthorized(); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted) ?? new UserIdentifierQuery(); + var accessContext = await _accessContextFactory.CreateAsync( authFlow: flow, action: UAuthActions.UserIdentifiers.GetAdmin, resource: "users", resourceId: userKey.Value); - var result = await _users.GetIdentifiersByUserAsync(accessContext, ctx.RequestAborted); + var result = await _users.GetIdentifiersByUserAsync(accessContext, request, ctx.RequestAborted); return Results.Ok(result); } + public async Task IdentifierExistsSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.GetSelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + var exists = await _users.UserIdentifierExistsAsync( + accessContext, + request.Type, + request.Value, + IdentifierExistenceScope.WithinUser, + ctx.RequestAborted); + + return Results.Ok(new IdentifierExistsResponse + { + Exists = exists + }); + } + + public async Task IdentifierExistsAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.GetAdmin, + resource: "users", + resourceId: userKey.Value); + + var exists = await _users.UserIdentifierExistsAsync( + accessContext, + request.Type, + request.Value, + IdentifierExistenceScope.TenantAny, + ctx.RequestAborted); + + return Results.Ok(new IdentifierExistsResponse + { + Exists = exists + }); + } + public async Task AddUserIdentifierSelfAsync(HttpContext ctx) { var flow = _authFlow.Current; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index 19040a2e..2cfabc66 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -18,6 +18,7 @@ public static IServiceCollection AddUltimateAuthUsersReference(this IServiceColl services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.AddScoped(); return services; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs index 918e00e7..b134b9a0 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs @@ -39,7 +39,7 @@ public LoginIdentifierResolver( var builtInType = DetectBuiltInType(normalized); - if (!_options.AllowedBuiltIns.Contains(builtInType)) + if (!_options.AllowedTypes.Contains(builtInType)) { if (_options.EnableCustomResolvers && !_options.CustomResolversFirst) return await TryCustomAsync(tenant, normalized, ct); @@ -112,8 +112,7 @@ public LoginIdentifierResolver( return null; } - private static string Normalize(string identifier) - => identifier.Trim(); + private static string Normalize(string identifier) => identifier.Trim(); private static UserIdentifierType DetectBuiltInType(string normalized) { diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserLifecycleSnaphotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserLifecycleSnaphotProvider.cs new file mode 100644 index 00000000..82350491 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserLifecycleSnaphotProvider.cs @@ -0,0 +1,29 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class UserLifecycleSnapshotProvider : IUserLifecycleSnapshotProvider +{ + private readonly IUserLifecycleStore _store; + + public UserLifecycleSnapshotProvider(IUserLifecycleStore store) + { + _store = store; + } + + public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var profile = await _store.GetAsync(new UserLifecycleKey(tenant, userKey), ct); + + if (profile is null || profile.IsDeleted) + return null; + + return new UserLifecycleSnapshot + { + UserKey = profile.UserKey, + Status = profile.Status + }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs index 3044076e..f3ccc480 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs @@ -15,7 +15,7 @@ public UserProfileSnapshotProvider(IUserProfileStore store) public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var profile = await _store.GetAsync(tenant, userKey, ct); + var profile = await _store.GetAsync(new UserProfileKey(tenant, userKey), ct); if (profile is null || profile.IsDeleted) return null; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs index e999d2d5..f3ad993f 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs @@ -10,9 +10,11 @@ public static UserIdentifierDto ToDto(UserIdentifier record) Id = record.Id, Type = record.Type, Value = record.Value, + NormalizedValue = record.NormalizedValue, IsPrimary = record.IsPrimary, IsVerified = record.IsVerified, CreatedAt = record.CreatedAt, - VerifiedAt = record.VerifiedAt + VerifiedAt = record.VerifiedAt, + Version = record.Version }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs index 5d0a536c..d1713b6b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs @@ -15,21 +15,9 @@ public static UserViewDto ToDto(UserProfile profile) BirthDate = profile.BirthDate, CreatedAt = profile.CreatedAt, Gender = profile.Gender, + Culture = profile.Culture, + Language = profile.Language, + TimeZone = profile.TimeZone, Metadata = profile.Metadata }; - - public static UserProfileUpdate ToUpdate(UpdateProfileRequest request) - => new() - { - FirstName = request.FirstName, - LastName = request.LastName, - DisplayName = request.DisplayName, - BirthDate = request.BirthDate, - Gender = request.Gender, - Bio = request.Bio, - Language = request.Language, - TimeZone = request.TimeZone, - Culture = request.Culture, - Metadata = request.Metadata - }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs index b01bc97f..fab67a4b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs @@ -14,11 +14,11 @@ public interface IUserApplicationService Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default); - Task> GetIdentifiersByUserAsync(AccessContext context, CancellationToken ct = default); + Task> GetIdentifiersByUserAsync(AccessContext context, UserIdentifierQuery query, CancellationToken ct = default); Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default); - Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default); + Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, IdentifierExistenceScope scope = IdentifierExistenceScope.TenantPrimaryOnly, CancellationToken ct = default); Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifierRequest request, CancellationToken ct = default); @@ -32,5 +32,6 @@ public interface IUserApplicationService Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIdentifierRequest request, CancellationToken ct = default); + Task DeleteMeAsync(AccessContext context, CancellationToken ct = default); Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 08089c61..38634126 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -16,8 +16,12 @@ internal sealed class UserApplicationService : IUserApplicationService private readonly IUserLifecycleStore _lifecycleStore; private readonly IUserProfileStore _profileStore; private readonly IUserIdentifierStore _identifierStore; + private readonly IUserCreateValidator _userCreateValidator; + private readonly IIdentifierValidator _identifierValidator; private readonly IEnumerable _integrations; - private readonly UAuthUserIdentifierOptions _identifierOptions; + private readonly IIdentifierNormalizer _identifierNormalizer; + private readonly ISessionStoreFactory _sessionStoreFactory; + private readonly UAuthServerOptions _options; private readonly IClock _clock; public UserApplicationService( @@ -25,7 +29,11 @@ public UserApplicationService( IUserLifecycleStore lifecycleStore, IUserProfileStore profileStore, IUserIdentifierStore identifierStore, + IUserCreateValidator userCreateValidator, + IIdentifierValidator identifierValidator, IEnumerable integrations, + IIdentifierNormalizer identifierNormalizer, + ISessionStoreFactory sessionStoreFactory, IOptions options, IClock clock) { @@ -33,95 +41,95 @@ public UserApplicationService( _lifecycleStore = lifecycleStore; _profileStore = profileStore; _identifierStore = identifierStore; + _userCreateValidator = userCreateValidator; + _identifierValidator = identifierValidator; _integrations = integrations; - _identifierOptions = options.Value.UserIdentifiers; + _identifierNormalizer = identifierNormalizer; + _sessionStoreFactory = sessionStoreFactory; + _options = options.Value; _clock = clock; } - public async Task GetMeAsync(AccessContext context, CancellationToken ct = default) - { - var command = new GetMeCommand(async innerCt => - { - if (context.ActorUserKey is null) - throw new UnauthorizedAccessException(); - - return await BuildUserViewAsync(context.ResourceTenant, context.ActorUserKey.Value, innerCt); - }); - - return await _accessOrchestrator.ExecuteAsync(context, command, ct); - } - - public async Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default) - { - var command = new GetUserProfileCommand(async innerCt => - { - // Target user MUST exist in context - var targetUserKey = context.GetTargetUserKey(); - - return await BuildUserViewAsync(context.ResourceTenant, targetUserKey, innerCt); - - }); - - return await _accessOrchestrator.ExecuteAsync(context, command, ct); - } + #region User Lifecycle public async Task CreateUserAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) { - var command = new CreateUserCommand(async innerCt => + var command = new AccessCommand(async innerCt => { + var validationResult = await _userCreateValidator.ValidateAsync(context, request, innerCt); + if (validationResult.IsValid != true) + { + throw new UAuthValidationException(string.Join(", ", validationResult.Errors)); + } + var now = _clock.UtcNow; var userKey = UserKey.New(); - if (!string.IsNullOrWhiteSpace(request.PrimaryIdentifierValue) && request.PrimaryIdentifierType is null) + await _lifecycleStore.AddAsync(UserLifecycle.Create(context.ResourceTenant, userKey, now), innerCt); + + await _profileStore.AddAsync( + UserProfile.Create( + now, + context.ResourceTenant, + userKey, + firstName: request.FirstName, + lastName: request.LastName, + displayName: request.DisplayName ?? request.UserName ?? request.Email ?? request.Phone, + birthDate: request.BirthDate, + gender: request.Gender, + bio: request.Bio, + language: request.Language, + timezone: request.TimeZone, + culture: request.Culture), innerCt); + + if (!string.IsNullOrWhiteSpace(request.UserName)) { - return UserCreateResult.Failed("primary_identifier_type_required"); + await _identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + UserIdentifierType.Username, + request.UserName, + _identifierNormalizer.Normalize(UserIdentifierType.Username, request.UserName).Normalized, + now, + true, + request.UserNameVerified ? now : null), innerCt); } - await _lifecycleStore.CreateAsync(context.ResourceTenant, - new UserLifecycle - { - UserKey = userKey, - Status = UserStatus.Active, - CreatedAt = now - }, - innerCt); - - await _profileStore.CreateAsync(context.ResourceTenant, - new UserProfile - { - UserKey = userKey, - FirstName = request.FirstName, - LastName = request.LastName, - DisplayName = request.DisplayName, - BirthDate = request.BirthDate, - Gender = request.Gender, - Bio = request.Bio, - Language = request.Language, - TimeZone = request.TimeZone, - Culture = request.Culture, - Metadata = request.Metadata, - CreatedAt = now - }, - innerCt); + if (!string.IsNullOrWhiteSpace(request.Email)) + { + await _identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + UserIdentifierType.Email, + request.Email, + _identifierNormalizer.Normalize(UserIdentifierType.Email, request.Email).Normalized, + now, + true, + request.EmailVerified ? now : null), innerCt); + } - if (!string.IsNullOrWhiteSpace(request.PrimaryIdentifierValue) && request.PrimaryIdentifierType is not null) + if (!string.IsNullOrWhiteSpace(request.Phone)) { - await _identifierStore.CreateAsync(context.ResourceTenant, - new UserIdentifier - { - UserKey = userKey, - Type = request.PrimaryIdentifierType.Value, - Value = request.PrimaryIdentifierValue, - IsPrimary = true, - IsVerified = request.PrimaryIdentifierVerified, - CreatedAt = now, - VerifiedAt = request.PrimaryIdentifierVerified ? now : null - }, - innerCt); + await _identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + UserIdentifierType.Phone, + request.Phone, + _identifierNormalizer.Normalize(UserIdentifierType.Phone, request.Phone).Normalized, + now, + true, + request.PhoneVerified ? now : null), innerCt); } foreach (var integration in _integrations) { + // Credential creation handle on here await integration.OnUserCreatedAsync(context.ResourceTenant, userKey, request, innerCt); } @@ -133,59 +141,189 @@ await _identifierStore.CreateAsync(context.ResourceTenant, public async Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default) { - var command = new ChangeUserStatusCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var newStatus = request switch { - ChangeUserStatusSelfRequest r => r.NewStatus, + ChangeUserStatusSelfRequest r => UserStatusMapper.ToUserStatus(r.NewStatus), ChangeUserStatusAdminRequest r => r.NewStatus, _ => throw new InvalidOperationException("invalid_request") }; var targetUserKey = context.GetTargetUserKey(); - var current = await _lifecycleStore.GetAsync(context.ResourceTenant, targetUserKey, innerCt); + var userLifecycleKey = new UserLifecycleKey(context.ResourceTenant, targetUserKey); + var current = await _lifecycleStore.GetAsync(userLifecycleKey, innerCt); + var now = _clock.UtcNow; if (current is null) - throw new InvalidOperationException("user_not_found"); + throw new UAuthNotFoundException("user_not_found"); if (context.IsSelfAction && !IsSelfTransitionAllowed(current.Status, newStatus)) - throw new InvalidOperationException("self_transition_not_allowed"); + throw new UAuthConflictException("self_transition_not_allowed"); if (!context.IsSelfAction) { - if (newStatus is UserStatus.SelfSuspended or UserStatus.Deactivated) - throw new InvalidOperationException("admin_cannot_set_self_status"); + if (newStatus is UserStatus.SelfSuspended) + throw new UAuthConflictException("admin_cannot_set_self_status"); } - - await _lifecycleStore.ChangeStatusAsync(context.ResourceTenant, targetUserKey, newStatus, _clock.UtcNow, innerCt); + var newEntity = current.ChangeStatus(now, newStatus); + await _lifecycleStore.SaveAsync(newEntity, current.Version, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); } - public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default) + public async Task DeleteMeAsync(AccessContext context, CancellationToken ct = default) { - var command = new UpdateUserProfileCommand(async innerCt => + var command = new AccessCommand(async innerCt => + { + var userKey = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var lifecycleKey = new UserLifecycleKey(context.ResourceTenant, userKey); + var lifecycle = await _lifecycleStore.GetAsync(lifecycleKey, innerCt); + + if (lifecycle is null) + throw new UAuthNotFoundException(); + + var profileKey = new UserProfileKey(context.ResourceTenant, userKey); + var profile = await _profileStore.GetAsync(profileKey, innerCt); + + await _lifecycleStore.DeleteAsync(lifecycleKey, lifecycle.Version, DeleteMode.Soft, now, innerCt); + await _identifierStore.DeleteByUserAsync(context.ResourceTenant, userKey, DeleteMode.Soft, now, innerCt); + + if (profile is not null) + { + await _profileStore.DeleteAsync(profileKey, profile.Version, DeleteMode.Soft, now, innerCt); + } + + foreach (var integration in _integrations) + { + await integration.OnUserDeletedAsync(context.ResourceTenant, userKey, DeleteMode.Soft, innerCt); + } + + var sessionStore = _sessionStoreFactory.Create(context.ResourceTenant); + await sessionStore.RevokeAllChainsAsync(context.ResourceTenant, userKey, now, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => { var targetUserKey = context.GetTargetUserKey(); - var update = UserProfileMapper.ToUpdate(request); + var now = _clock.UtcNow; + var userLifecycleKey = new UserLifecycleKey(context.ResourceTenant, targetUserKey); + + var lifecycle = await _lifecycleStore.GetAsync(userLifecycleKey, innerCt); + + if (lifecycle is null) + throw new UAuthNotFoundException(); - await _profileStore.UpdateAsync(context.ResourceTenant, targetUserKey, update, _clock.UtcNow, innerCt); + var profileKey = new UserProfileKey(context.ResourceTenant, targetUserKey); + var profile = await _profileStore.GetAsync(profileKey, innerCt); + await _lifecycleStore.DeleteAsync(userLifecycleKey, lifecycle.Version, request.Mode, now, innerCt); + await _identifierStore.DeleteByUserAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); + + if (profile is not null) + { + await _profileStore.DeleteAsync(profileKey, profile.Version, request.Mode, now, innerCt); + } + + foreach (var integration in _integrations) + { + await integration.OnUserDeletedAsync(context.ResourceTenant, targetUserKey, request.Mode, innerCt); + } }); await _accessOrchestrator.ExecuteAsync(context, command, ct); } + #endregion + + + #region User Profile + + public async Task GetMeAsync(AccessContext context, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + if (context.ActorUserKey is null) + throw new UnauthorizedAccessException(); + + return await BuildUserViewAsync(context.ResourceTenant, context.ActorUserKey.Value, innerCt); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var targetUserKey = context.GetTargetUserKey(); + return await BuildUserViewAsync(context.ResourceTenant, targetUserKey, innerCt); + + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var tenant = context.ResourceTenant; + var userKey = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var key = new UserProfileKey(tenant, userKey); + + var profile = await _profileStore.GetAsync(key, innerCt); + + if (profile is null) + throw new UAuthNotFoundException(); + + var expectedVersion = profile.Version; + + profile + .UpdateName(request.FirstName, request.LastName, request.DisplayName, now) + .UpdatePersonalInfo(request.BirthDate, request.Gender, request.Bio, now) + .UpdateLocalization(request.Language, request.TimeZone, request.Culture, now) + .UpdateMetadata(request.Metadata, now); + + await _profileStore.SaveAsync(profile, expectedVersion, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + #endregion + + #region Identifiers - public async Task> GetIdentifiersByUserAsync(AccessContext context, CancellationToken ct = default) + public async Task> GetIdentifiersByUserAsync(AccessContext context, UserIdentifierQuery query, CancellationToken ct = default) { - var command = new GetUserIdentifiersCommand(async innerCt => + var command = new AccessCommand>(async innerCt => { var targetUserKey = context.GetTargetUserKey(); - var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenant, targetUserKey, innerCt); - return identifiers.Select(UserIdentifierMapper.ToDto).ToList().AsReadOnly(); + query ??= new UserIdentifierQuery(); + query.UserKey = targetUserKey; + + var result = await _identifierStore.QueryAsync(context.ResourceTenant, query, innerCt); + var dtoItems = result.Items.Select(UserIdentifierMapper.ToDto).ToList().AsReadOnly(); + + return new PagedResult( + dtoItems, + result.TotalCount, + result.PageNumber, + result.PageSize, + result.SortBy, + result.Descending); }); return await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -193,20 +331,31 @@ public async Task> GetIdentifiersByUserAsync(Ac public async Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default) { - var command = new GetUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { - var identifier = await _identifierStore.GetAsync(context.ResourceTenant, type, value, innerCt); + var normalized = _identifierNormalizer.Normalize(type, value); + if (!normalized.IsValid) + return null; + + var identifier = await _identifierStore.GetAsync(context.ResourceTenant, type, normalized.Normalized, innerCt); return identifier is null ? null : UserIdentifierMapper.ToDto(identifier); }); return await _accessOrchestrator.ExecuteAsync(context, command, ct); } - public async Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default) + public async Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, IdentifierExistenceScope scope = IdentifierExistenceScope.TenantPrimaryOnly, CancellationToken ct = default) { - var command = new UserIdentifierExistsCommand(async innerCt => + var command = new AccessCommand(async innerCt => { - return await _identifierStore.ExistsAsync(context.ResourceTenant, type, value, innerCt); + var normalized = _identifierNormalizer.Normalize(type, value); + if (!normalized.IsValid) + return false; + + UserKey? userKey = scope == IdentifierExistenceScope.WithinUser ? context.GetTargetUserKey() : null; + + var result = await _identifierStore.ExistsAsync(new IdentifierExistenceQuery(context.ResourceTenant, type, normalized.Normalized, scope, userKey), innerCt); + return result.Exists; }); return await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -214,32 +363,69 @@ public async Task UserIdentifierExistsAsync(AccessContext context, UserIde public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifierRequest request, CancellationToken ct = default) { - var command = new AddUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { + var validationDto = new UserIdentifierDto() { Type = request.Type, Value = request.Value }; + var validationResult = await _identifierValidator.ValidateAsync(context, validationDto, innerCt); + if (validationResult.IsValid != true) + { + throw new UAuthValidationException(string.Join(", ", validationResult.Errors)); + } + + EnsureOverrideAllowed(context); var userKey = context.GetTargetUserKey(); + var normalized = _identifierNormalizer.Normalize(request.Type, request.Value); + if (!normalized.IsValid) + throw new UAuthIdentifierValidationException(normalized.ErrorCode ?? "identifier_invalid"); + var existing = await _identifierStore.GetByUserAsync(context.ResourceTenant, userKey, innerCt); - EnsureOverrideAllowed(context); EnsureMultipleIdentifierAllowed(request.Type, existing); + var userScopeResult = await _identifierStore.ExistsAsync( + new IdentifierExistenceQuery(context.ResourceTenant, request.Type, normalized.Normalized, IdentifierExistenceScope.WithinUser, UserKey: userKey), innerCt); + + if (userScopeResult.Exists) + throw new UAuthIdentifierConflictException("identifier_already_exists_for_user"); + + var mustBeUnique = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers || + (request.IsPrimary && _options.LoginIdentifiers.AllowedTypes.Contains(request.Type)); + + if (mustBeUnique) + { + var scope = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers + ? IdentifierExistenceScope.TenantAny + : IdentifierExistenceScope.TenantPrimaryOnly; + + var globalResult = await _identifierStore.ExistsAsync( + new IdentifierExistenceQuery( + context.ResourceTenant, + request.Type, + normalized.Normalized, + scope), + innerCt); + + if (globalResult.Exists) + throw new UAuthIdentifierConflictException("identifier_already_exists"); + } + if (request.IsPrimary) { // new identifiers are not verified by default, so we check against the requirement even if the request doesn't explicitly set it to true. // This prevents adding a primary identifier that doesn't meet verification requirements. - EnsureVerificationRequirements(request.Type, isVerified: false ); + EnsureVerificationRequirements(request.Type, isVerified: false); } - await _identifierStore.CreateAsync(context.ResourceTenant, - new UserIdentifier - { - UserKey = userKey, - Type = request.Type, - Value = request.Value, - IsPrimary = request.IsPrimary, - IsVerified = false, - CreatedAt = _clock.UtcNow - }, - innerCt); + await _identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + request.Type, + request.Value, + normalized.Normalized, + _clock.UtcNow, + request.IsPrimary), innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -247,24 +433,73 @@ await _identifierStore.CreateAsync(context.ResourceTenant, public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIdentifierRequest request, CancellationToken ct = default) { - var command = new UpdateUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { + EnsureOverrideAllowed(context); + var identifier = await _identifierStore.GetByIdAsync(request.Id, innerCt); if (identifier is null || identifier.IsDeleted) throw new UAuthIdentifierNotFoundException("identifier_not_found"); - EnsureOverrideAllowed(context); - - if (identifier.Type == UserIdentifierType.Username && !_identifierOptions.AllowUsernameChange) + if (identifier.Type == UserIdentifierType.Username && !_options.Identifiers.AllowUsernameChange) { throw new UAuthIdentifierValidationException("username_change_not_allowed"); } - if (string.Equals(identifier.Value, request.NewValue, StringComparison.Ordinal)) + var validationDto = identifier.ToDto(); + var validationResult = await _identifierValidator.ValidateAsync(context, validationDto, innerCt); + if (validationResult.IsValid != true) + { + throw new UAuthValidationException(string.Join(", ", validationResult.Errors)); + } + + var normalized = _identifierNormalizer.Normalize(identifier.Type, request.NewValue); + if (!normalized.IsValid) + throw new UAuthIdentifierValidationException(normalized.ErrorCode ?? "identifier_invalid"); + + if (string.Equals(identifier.NormalizedValue, normalized.Normalized, StringComparison.Ordinal)) throw new UAuthIdentifierValidationException("identifier_value_unchanged"); - await _identifierStore.UpdateValueAsync(identifier.Id, request.NewValue, _clock.UtcNow, innerCt); + var withinUserResult = await _identifierStore.ExistsAsync( + new IdentifierExistenceQuery( + identifier.Tenant, + identifier.Type, + normalized.Normalized, + IdentifierExistenceScope.WithinUser, + UserKey: identifier.UserKey, + ExcludeIdentifierId: identifier.Id), + innerCt); + + if (withinUserResult.Exists) + throw new UAuthIdentifierConflictException("identifier_already_exists_for_user"); + + var mustBeUnique = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers || + (identifier.IsPrimary && _options.LoginIdentifiers.AllowedTypes.Contains(identifier.Type)); + + if (mustBeUnique) + { + var scope = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers + ? IdentifierExistenceScope.TenantAny + : IdentifierExistenceScope.TenantPrimaryOnly; + + var result = await _identifierStore.ExistsAsync( + new IdentifierExistenceQuery( + identifier.Tenant, + identifier.Type, + normalized.Normalized, + scope, + ExcludeIdentifierId: identifier.Id), + innerCt); + + if (result.Exists) + throw new UAuthIdentifierConflictException("identifier_already_exists"); + } + + var expectedVersion = identifier.Version; + identifier.ChangeValue(request.NewValue, normalized.Normalized, _clock.UtcNow); + + await _identifierStore.SaveAsync(identifier, expectedVersion, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -272,7 +507,7 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimaryUserIdentifierRequest request, CancellationToken ct = default) { - var command = new SetPrimaryUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { EnsureOverrideAllowed(context); @@ -280,9 +515,20 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar if (identifier is null) throw new UAuthIdentifierNotFoundException("identifier_not_found"); + if (identifier.IsPrimary) + throw new UAuthIdentifierValidationException("identifier_already_primary"); + EnsureVerificationRequirements(identifier.Type, identifier.IsVerified); - await _identifierStore.SetPrimaryAsync(request.IdentifierId, innerCt); + var result = await _identifierStore.ExistsAsync( + new IdentifierExistenceQuery(identifier.Tenant, identifier.Type, identifier.NormalizedValue, IdentifierExistenceScope.TenantPrimaryOnly, ExcludeIdentifierId: identifier.Id), innerCt); + + if (result.Exists) + throw new UAuthIdentifierConflictException("identifier_already_exists"); + + var expectedVersion = identifier.Version; + identifier.SetPrimary(_clock.UtcNow); + await _identifierStore.SaveAsync(identifier, expectedVersion, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -290,7 +536,7 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPrimaryUserIdentifierRequest request, CancellationToken ct = default) { - var command = new UnsetPrimaryUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { EnsureOverrideAllowed(context); @@ -299,20 +545,27 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr throw new UAuthIdentifierNotFoundException("identifier_not_found"); if (!identifier.IsPrimary) - throw new UAuthIdentifierValidationException("identifier_not_primary"); + throw new UAuthIdentifierValidationException("identifier_already_not_primary"); - var identifiers = await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); + var userIdentifiers = + await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); - var otherLoginIdentifiers = identifiers - .Where(i => !i.IsDeleted && - IsLoginIdentifier(i.Type) && - i.Id != identifier.Id) + var activeLoginPrimaries = userIdentifiers + .Where(i => + !i.IsDeleted && + i.IsPrimary && + _options.LoginIdentifiers.AllowedTypes.Contains(i.Type)) .ToList(); - if (otherLoginIdentifiers.Count == 0) - throw new UAuthIdentifierConflictException("cannot_unset_last_primary_login_identifier"); + if (activeLoginPrimaries.Count == 1 && + activeLoginPrimaries[0].Id == identifier.Id) + { + throw new UAuthIdentifierConflictException("cannot_unset_last_login_identifier"); + } - await _identifierStore.UnsetPrimaryAsync(request.IdentifierId, innerCt); + var expectedVersion = identifier.Version; + identifier.UnsetPrimary(_clock.UtcNow); + await _identifierStore.SaveAsync(identifier, expectedVersion, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -320,10 +573,17 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIdentifierRequest request, CancellationToken ct = default) { - var command = new VerifyUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { EnsureOverrideAllowed(context); - await _identifierStore.MarkVerifiedAsync(request.IdentifierId, _clock.UtcNow, innerCt); + + var identifier = await _identifierStore.GetByIdAsync(request.IdentifierId, innerCt); + if (identifier is null) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + var expectedVersion = identifier.Version; + identifier.MarkVerified(_clock.UtcNow); + await _identifierStore.SaveAsync(identifier, expectedVersion, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -331,7 +591,7 @@ public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIde public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIdentifierRequest request, CancellationToken ct = default) { - var command = new DeleteUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { EnsureOverrideAllowed(context); @@ -345,8 +605,7 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde if (identifier.IsPrimary) throw new UAuthIdentifierValidationException("cannot_delete_primary_identifier"); - if (_identifierOptions.RequireUsernameIdentifier && - identifier.Type == UserIdentifierType.Username) + if (_options.Identifiers.RequireUsernameIdentifier && identifier.Type == UserIdentifierType.Username) { var activeUsernames = identifiers .Where(i => !i.IsDeleted && i.Type == UserIdentifierType.Username) @@ -357,9 +616,19 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde } if (IsLoginIdentifier(identifier.Type) && loginIdentifiers.Count == 1) - throw new UAuthIdentifierConflictException("cannot_delete_last_login_identifier"); + throw new UAuthIdentifierConflictException("cannot_delete_last_login_identifier"); + + var expectedVersion = identifier.Version; - await _identifierStore.DeleteAsync(request.IdentifierId, request.Mode, _clock.UtcNow, innerCt); + if (request.Mode == DeleteMode.Hard) + { + await _identifierStore.DeleteAsync(identifier.Id, expectedVersion, DeleteMode.Hard, _clock.UtcNow, innerCt); + } + else + { + identifier.MarkDeleted(_clock.UtcNow); + await _identifierStore.SaveAsync(identifier, expectedVersion, innerCt); + } }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -367,29 +636,12 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde #endregion - public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) - { - var command = new DeleteUserCommand(async innerCt => - { - var targetUserKey = context.GetTargetUserKey(); - var now = _clock.UtcNow; - await _lifecycleStore.DeleteAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); - await _identifierStore.DeleteByUserAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); - await _profileStore.DeleteAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); - - foreach (var integration in _integrations) - { - await integration.OnUserDeletedAsync(context.ResourceTenant, targetUserKey, request.Mode, innerCt); - } - }); - - await _accessOrchestrator.ExecuteAsync(context, command, ct); - } + #region Helpers private async Task BuildUserViewAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) { - var profile = await _profileStore.GetAsync(tenant, userKey, ct); + var profile = await _profileStore.GetAsync(new UserProfileKey(tenant, userKey), ct); if (profile is null || profile.IsDeleted) throw new InvalidOperationException("user_profile_not_found"); @@ -419,36 +671,36 @@ private void EnsureMultipleIdentifierAllowed(UserIdentifierType type, IReadOnlyL if (!hasSameType) return; - if (type == UserIdentifierType.Username && !_identifierOptions.AllowMultipleUsernames) - throw new InvalidOperationException("multiple_usernames_not_allowed"); + if (type == UserIdentifierType.Username && !_options.Identifiers.AllowMultipleUsernames) + throw new UAuthValidationException("multiple_usernames_not_allowed"); - if (type == UserIdentifierType.Email && !_identifierOptions.AllowMultipleEmail) - throw new InvalidOperationException("multiple_emails_not_allowed"); + if (type == UserIdentifierType.Email && !_options.Identifiers.AllowMultipleEmail) + throw new UAuthValidationException("multiple_emails_not_allowed"); - if (type == UserIdentifierType.Phone && !_identifierOptions.AllowMultiplePhone) - throw new InvalidOperationException("multiple_phones_not_allowed"); + if (type == UserIdentifierType.Phone && !_options.Identifiers.AllowMultiplePhone) + throw new UAuthValidationException("multiple_phones_not_allowed"); } private void EnsureVerificationRequirements(UserIdentifierType type, bool isVerified) { - if (type == UserIdentifierType.Email && _identifierOptions.RequireEmailVerification && !isVerified) + if (type == UserIdentifierType.Email && _options.Identifiers.RequireEmailVerification && !isVerified) { - throw new InvalidOperationException("email_verification_required"); + throw new UAuthValidationException("email_verification_required"); } - if (type == UserIdentifierType.Phone && _identifierOptions.RequirePhoneVerification && !isVerified) + if (type == UserIdentifierType.Phone && _options.Identifiers.RequirePhoneVerification && !isVerified) { - throw new InvalidOperationException("phone_verification_required"); + throw new UAuthValidationException("phone_verification_required"); } } private void EnsureOverrideAllowed(AccessContext context) { - if (context.IsSelfAction && !_identifierOptions.AllowUserOverride) - throw new InvalidOperationException("user_override_not_allowed"); + if (context.IsSelfAction && !_options.Identifiers.AllowUserOverride) + throw new UAuthConflictException("user_override_not_allowed"); - if (!context.IsSelfAction && !_identifierOptions.AllowAdminOverride) - throw new InvalidOperationException("admin_override_not_allowed"); + if (!context.IsSelfAction && !_options.Identifiers.AllowAdminOverride) + throw new UAuthConflictException("admin_override_not_allowed"); } private static bool IsSelfTransitionAllowed(UserStatus from, UserStatus to) @@ -456,7 +708,6 @@ private static bool IsSelfTransitionAllowed(UserStatus from, UserStatus to) { (UserStatus.Active, UserStatus.SelfSuspended) => true, (UserStatus.SelfSuspended, UserStatus.Active) => true, - (UserStatus.Active or UserStatus.SelfSuspended, UserStatus.Deactivated) => true, _ => false }; @@ -466,4 +717,5 @@ UserIdentifierType.Username or UserIdentifierType.Email or UserIdentifierType.Phone; + #endregion } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs index 7b7aaa2a..2560e51b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs @@ -1,30 +1,20 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -public interface IUserIdentifierStore +public interface IUserIdentifierStore : IVersionedStore { - Task ExistsAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default); + Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default); Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task GetAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default); - Task CreateAsync(TenantKey tenant, UserIdentifier identifier, CancellationToken ct = default); - - Task UpdateValueAsync(Guid id, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default); - - Task MarkVerifiedAsync(Guid id, DateTimeOffset verifiedAt, CancellationToken ct = default); - - Task SetPrimaryAsync(Guid id, CancellationToken ct = default); - - Task UnsetPrimaryAsync(Guid id, CancellationToken ct = default); - - Task DeleteAsync(Guid id, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); + Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default); Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs index 38b0c42f..c930be38 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs @@ -1,23 +1,12 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -public interface IUserLifecycleStore +public interface IUserLifecycleStore : IVersionedStore { - Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - - Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default); - - Task CreateAsync(TenantKey tenant, UserLifecycle lifecycle, CancellationToken ct = default); - - Task ChangeStatusAsync(TenantKey tenant, UserKey userKey, UserStatus newStatus, DateTimeOffset updatedAt, CancellationToken ct = default); - - Task ChangeSecurityStampAsync(TenantKey tenant, UserKey userKey, Guid newSecurityStamp, DateTimeOffset updatedAt, CancellationToken ct = default); - - Task DeleteAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs index 8c34959d..c8151c27 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs @@ -1,20 +1,10 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Users.Reference; -public interface IUserProfileStore +public interface IUserProfileStore : IVersionedStore { - Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - - Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default); - - Task CreateAsync(TenantKey tenant, UserProfile profile, CancellationToken ct = default); - - Task UpdateAsync(TenantKey tenant, UserKey userKey, UserProfileUpdate update, DateTimeOffset updatedAt, CancellationToken ct = default); - - Task DeleteAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs index b864f6e8..4a4e16fb 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; @@ -16,7 +16,8 @@ public UserRuntimeStateProvider(IUserLifecycleStore lifecycleStore) public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var lifecycle = await _lifecycleStore.GetAsync(tenant, userKey, ct); + var userLifecycleKey = new UserLifecycleKey(tenant, userKey); + var lifecycle = await _lifecycleStore.GetAsync(userLifecycleKey, ct); if (lifecycle is null) return null; @@ -25,6 +26,7 @@ public UserRuntimeStateProvider(IUserLifecycleStore lifecycleStore) { UserKey = lifecycle.UserKey, IsActive = lifecycle.Status == UserStatus.Active, + CanAuthenticate = lifecycle.Status == UserStatus.Active || lifecycle.Status == UserStatus.SelfSuspended || lifecycle.Status == UserStatus.Suspended, IsDeleted = lifecycle.IsDeleted, Exists = true }; diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleSnapshotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleSnapshotProvider.cs new file mode 100644 index 00000000..afaad513 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleSnapshotProvider.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users; + +public interface IUserLifecycleSnapshotProvider +{ + Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs index 4c1e0cbd..01dea3d1 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs @@ -1,8 +1,10 @@ -namespace CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Core.Domain; -public interface IUserSecurityEvents +namespace CodeBeam.UltimateAuth.Users; + +public interface IUserSecurityEvents { - Task OnUserActivatedAsync(TUserId userId); - Task OnUserDeactivatedAsync(TUserId userId); - Task OnSecurityInvalidatedAsync(TUserId userId); + Task OnUserActivatedAsync(UserKey userKey); + Task OnUserDeactivatedAsync(UserKey userKey); + Task OnSecurityInvalidatedAsync(UserKey userKey); } diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs deleted file mode 100644 index 74fa08d0..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users; - -public interface IUserSecurityState -{ - long SecurityVersion { get; } - int FailedLoginAttempts { get; } - DateTimeOffset? LockedUntil { get; } - bool RequiresReauthentication { get; } - DateTimeOffset? LastFailedAt { get; } - - bool IsLocked => LockedUntil.HasValue && LockedUntil > DateTimeOffset.UtcNow; -} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs deleted file mode 100644 index 6c97bc7e..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs +++ /dev/null @@ -1,10 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Users; - -internal interface IUserSecurityStateDebugView -{ - IUserSecurityState? GetState(TenantKey tenant, UserKey userKey); - void Clear(TenantKey tenant, UserKey userKey); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs deleted file mode 100644 index b22955f0..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs +++ /dev/null @@ -1,9 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Users; - -public interface IUserSecurityStateProvider -{ - Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs deleted file mode 100644 index 20cb713b..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Users; - -public interface IUserSecurityStateWriter -{ - Task RecordFailedLoginAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default); - Task ResetFailuresAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task LockUntilAsync(TenantKey tenant, UserKey userKey, DateTimeOffset lockedUntil, CancellationToken ct = default); -} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/CompiledPermissionSetTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/CompiledPermissionSetTests.cs new file mode 100644 index 00000000..74578556 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/CompiledPermissionSetTests.cs @@ -0,0 +1,113 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class CompiledPermissionSetTests +{ + [Fact] + public void ExactPermission_Allows_Action() + { + var permissions = new[] + { + Permission.From("users.delete.admin") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.True(set.IsAllowed("users.delete.admin")); + } + + [Fact] + public void ExactPermission_DoesNotAllow_OtherAction() + { + var permissions = new[] + { + Permission.From("users.delete.admin") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.False(set.IsAllowed("users.create.admin")); + } + + [Fact] + public void WildcardPermission_AllowsEverything() + { + var permissions = new[] + { + Permission.Wildcard + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.True(set.IsAllowed("users.delete.admin")); + Assert.True(set.IsAllowed("anything.really.admin")); + } + + [Fact] + public void PrefixPermission_AllowsChildren() + { + var permissions = new[] + { + Permission.From("users.*") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.True(set.IsAllowed("users.delete.admin")); + Assert.True(set.IsAllowed("users.profile.update.admin")); + } + + [Fact] + public void PrefixPermission_DoesNotAllowOtherResource() + { + var permissions = new[] + { + Permission.From("users.*") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.False(set.IsAllowed("sessions.revoke.admin")); + } + + [Fact] + public void NestedPrefixPermission_Works() + { + var permissions = new[] + { + Permission.From("users.profile.*") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.True(set.IsAllowed("users.profile.update.admin")); + Assert.True(set.IsAllowed("users.profile.get.self")); + } + + [Fact] + public void NestedPrefixPermission_DoesNotMatchParent() + { + var permissions = new[] + { + Permission.From("users.profile.*") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.False(set.IsAllowed("users.delete.admin")); + } + + [Fact] + public void PrefixPermission_DoesNotMatchSimilarPrefix() + { + var permissions = new[] + { + Permission.From("users.*") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.False(set.IsAllowed("usersettings.update.admin")); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UAuthActionsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UAuthActionsTests.cs new file mode 100644 index 00000000..45df809b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UAuthActionsTests.cs @@ -0,0 +1,47 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Actions; + +public class UAuthActionsTests +{ + [Fact] + public void Create_WithResourceOperationScope_Works() + { + var action = UAuthActions.Create("users", "delete", ActionScope.Admin); + Assert.Equal("users.delete.admin", action); + } + + [Fact] + public void Create_WithSubResource_Works() + { + var action = UAuthActions.Create("users", "update", ActionScope.Self, "profile"); + Assert.Equal("users.profile.update.self", action); + } + + [Fact] + public void Create_ProducesLowercaseScope() + { + var action = UAuthActions.Create("users", "delete", ActionScope.Admin); + Assert.Equal("users.delete.admin", action); + } + + [Fact] + public void Create_DifferentScopes_Work() + { + var self = UAuthActions.Create("users", "update", ActionScope.Self); + var admin = UAuthActions.Create("users", "delete", ActionScope.Admin); + var anon = UAuthActions.Create("users", "create", ActionScope.Anonymous); + + Assert.Equal("users.update.self", self); + Assert.Equal("users.delete.admin", admin); + Assert.Equal("users.create.anonymous", anon); + } + + [Fact] + public void Create_DoesNotCreateDoubleDots_WhenSubResourceNull() + { + var action = UAuthActions.Create("users", "delete", ActionScope.Admin); + Assert.DoesNotContain("..", action); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs index 01d86911..aca9fa48 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs @@ -16,6 +16,7 @@ public async Task CreateAsync_should_return_snapshot_when_valid() { var provider = new Mock(); var pprovider = new Mock(); + var lprovider = new Mock(); provider.Setup(x => x.GetAsync(It.IsAny(), It.IsAny(), default)) .ReturnsAsync(new PrimaryUserIdentifiers @@ -23,7 +24,7 @@ public async Task CreateAsync_should_return_snapshot_when_valid() UserName = "admin" }); - var factory = new AuthStateSnapshotFactory(provider.Object, pprovider.Object); + var factory = new AuthStateSnapshotFactory(provider.Object, pprovider.Object, lprovider.Object); var validation = SessionValidationResult.Active( TenantKey.FromInternal("__single__"), @@ -46,8 +47,9 @@ public async Task CreateAsync_should_return_null_when_invalid() { var provider = new Mock(); var pprovider = new Mock(); + var lprovider = new Mock(); - var factory = new AuthStateSnapshotFactory(provider.Object, pprovider.Object); + var factory = new AuthStateSnapshotFactory(provider.Object, pprovider.Object, lprovider.Object); var validation = SessionValidationResult.Invalid(SessionState.NotFound); var snapshot = await factory.CreateAsync(validation); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs index f8302b0d..d87c40f2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs @@ -1,12 +1,13 @@ -using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; -using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; using Moq; -using CodeBeam.UltimateAuth.Client; -using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -15,6 +16,7 @@ public class SessionCoordinatorTests [Fact] public async Task StartAsync_should_not_start_when_auto_refresh_disabled() { + var clock = new TestClock(); var client = new Mock(); var nav = new Mock(); var diagnostics = new UAuthClientDiagnostics(); @@ -27,7 +29,7 @@ public async Task StartAsync_should_not_start_when_auto_refresh_disabled() } }); - var coordinator = new SessionCoordinator(client.Object, nav.Object, options, diagnostics); + var coordinator = new SessionCoordinator(client.Object, nav.Object, options, diagnostics, clock); await coordinator.StartAsync(); Assert.False(diagnostics.IsRunning); @@ -36,6 +38,8 @@ public async Task StartAsync_should_not_start_when_auto_refresh_disabled() [Fact] public async Task ReauthRequired_should_raise_event() { + var clock = new TestClock(); + var client = new Mock(); var nav = new Mock(); var diagnostics = new UAuthClientDiagnostics(); @@ -51,7 +55,7 @@ public async Task ReauthRequired_should_raise_event() AutoRefresh = new UAuthClientAutoRefreshOptions { Enabled = true, - Interval = TimeSpan.FromMilliseconds(10) + Interval = TimeSpan.FromSeconds(5) }, Reauth = new UAuthClientReauthOptions { @@ -59,12 +63,11 @@ public async Task ReauthRequired_should_raise_event() } }); - var coordinator = new SessionCoordinator(client.Object, nav.Object, options, diagnostics); + var coordinator = new SessionCoordinator(client.Object, nav.Object, options, diagnostics, clock); var triggered = false; coordinator.ReauthRequired += () => triggered = true; - await coordinator.StartAsync(); - await Task.Delay(50); + await coordinator.TickAsync(); Assert.True(triggered); Assert.True(diagnostics.IsTerminated); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs index 2fd7b759..1ee40234 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Authentication; +using CodeBeam.UltimateAuth.Client.Events; using CodeBeam.UltimateAuth.Client.Services; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; @@ -37,10 +38,11 @@ public async Task EnsureAsync_should_not_validate_when_authenticated_and_not_sta var client = new Mock(); client.Setup(x => x.Flows).Returns(flowClient.Object); + var events = new Mock(); var clock = new Mock(); clock.Setup(x => x.UtcNow).Returns(DateTimeOffset.UtcNow); - var manager = new UAuthStateManager(client.Object, clock.Object); + var manager = new UAuthStateManager(client.Object, events.Object, clock.Object); await manager.EnsureAsync(); await manager.EnsureAsync(); @@ -49,29 +51,39 @@ public async Task EnsureAsync_should_not_validate_when_authenticated_and_not_sta } [Fact] - public async Task EnsureAsync_force_should_always_validate() + public async Task EnsureAsync_should_deduplicate_concurrent_calls() { - var client = new Mock(); - var clock = new Mock(); + var flows = new Mock(); - client.Setup(x => x.Flows.ValidateAsync()) - .ReturnsAsync(new AuthValidationResult + flows.Setup(x => x.ValidateAsync()) + .Returns(async () => { - State = SessionState.Invalid + await Task.Delay(50); + return new AuthValidationResult { State = SessionState.Invalid }; }); - var manager = new UAuthStateManager(client.Object, clock.Object); + var client = new Mock(); + client.SetupGet(x => x.Flows).Returns(flows.Object); - await manager.EnsureAsync(force: true); - await manager.EnsureAsync(force: true); + var events = new Mock(); + var clock = new Mock(); + + var manager = new UAuthStateManager(client.Object, events.Object, clock.Object); + + await Task.WhenAll( + manager.EnsureAsync(true), + manager.EnsureAsync(true), + manager.EnsureAsync(true) + ); - client.Verify(x => x.Flows.ValidateAsync(), Times.Exactly(2)); + flows.Verify(x => x.ValidateAsync(), Times.Once); } [Fact] public async Task EnsureAsync_invalid_should_clear_state() { var client = new Mock(); + var events = new Mock(); var clock = new Mock(); client.Setup(x => x.Flows.ValidateAsync()) @@ -80,10 +92,8 @@ public async Task EnsureAsync_invalid_should_clear_state() State = SessionState.Invalid }); - var manager = new UAuthStateManager(client.Object, clock.Object); - + var manager = new UAuthStateManager(client.Object, events.Object, clock.Object); await manager.EnsureAsync(); - manager.State.IsAuthenticated.Should().BeFalse(); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj index 6e11feeb..f0adf68b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -18,6 +18,7 @@ + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs index 952588b5..3e967fce 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs @@ -36,7 +36,7 @@ public async Task Invalid_When_Token_Not_Found() Tenant = TenantKey.Single, RefreshToken = "non-existing", Now = DateTimeOffset.UtcNow, - Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), }); Assert.False(result.IsValid); @@ -73,7 +73,7 @@ public async Task Reuse_Detected_When_Token_is_Revoked() Tenant = TenantKey.Single, RefreshToken = rawToken, Now = now, - Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), }); Assert.False(result.IsValid); @@ -106,7 +106,7 @@ public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() RefreshToken = "hash-2", ExpectedSessionId = TestIds.Session("session-2-cccccccccccccccccccccc"), Now = now, - Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), }); Assert.False(result.IsValid); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs index 8c010928..2219f194 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -20,8 +21,12 @@ public void New_chain_has_expected_initial_state() SessionRootId.New(), tenant: TenantKey.Single, userKey: UserKey.FromString("user-1"), - securityVersion: 0, - ClaimsSnapshot.Empty); + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); Assert.Equal(0, chain.RotationCount); Assert.Null(chain.ActiveSessionId); @@ -34,13 +39,17 @@ public void Rotating_chain_sets_active_session_and_increments_rotation() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - TenantKey.Single, - UserKey.FromString("user-1"), - 0, - ClaimsSnapshot.Empty); + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); var sessionId = CreateSessionId("s1"); - var rotated = chain.RotateSession(sessionId); + var rotated = chain.RotateSession(sessionId, DateTimeOffset.UtcNow); Assert.Equal(1, rotated.RotationCount); Assert.Equal(sessionId, rotated.ActiveSessionId); @@ -53,13 +62,17 @@ public void Multiple_rotations_increment_rotation_count() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - TenantKey.Single, - UserKey.FromString("user-1"), - 0, - ClaimsSnapshot.Empty); + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); - var first = chain.RotateSession(CreateSessionId("s1")); - var second = first.RotateSession(CreateSessionId("s2")); + var first = chain.RotateSession(CreateSessionId("s1"), DateTimeOffset.UtcNow); + var second = first.RotateSession(CreateSessionId("s2"), DateTimeOffset.UtcNow); Assert.Equal(2, second.RotationCount); Assert.Equal(CreateSessionId("s2"), second.ActiveSessionId); @@ -73,13 +86,17 @@ public void Revoked_chain_does_not_rotate() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - TenantKey.Single, - UserKey.FromString("user-1"), - 0, - ClaimsSnapshot.Empty); + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); var revoked = chain.Revoke(now); - var rotated = revoked.RotateSession(CreateSessionId("s2")); + var rotated = revoked.RotateSession(CreateSessionId("s2"), DateTimeOffset.UtcNow); Assert.Same(revoked, rotated); Assert.True(rotated.IsRevoked); @@ -93,10 +110,14 @@ public void Revoking_chain_sets_revocation_fields() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - TenantKey.Single, - UserKey.FromString("user-1"), - 0, - ClaimsSnapshot.Empty); + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); var revoked = chain.Revoke(now); @@ -112,10 +133,14 @@ public void Revoking_already_revoked_chain_is_idempotent() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - TenantKey.Single, - UserKey.FromString("user-1"), - 0, - ClaimsSnapshot.Empty); + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); var revoked1 = chain.Revoke(now); var revoked2 = revoked1.Revoke(now.AddMinutes(1)); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs index 6a858508..e988dc1d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs @@ -21,7 +21,7 @@ public void Revoke_marks_session_as_revoked() chainId: SessionChainId.New(), now, now.AddMinutes(10), - DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + 0, ClaimsSnapshot.Empty, SessionMetadata.Empty); @@ -45,7 +45,7 @@ public void Revoking_twice_returns_same_instance() SessionChainId.New(), now, now.AddMinutes(10), - DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + 0, ClaimsSnapshot.Empty, SessionMetadata.Empty); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs new file mode 100644 index 00000000..0413196d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs @@ -0,0 +1,133 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ChangePasswordTests +{ + [Fact] + public async Task Change_password_with_correct_current_should_succeed() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UAuthActions.Credentials.ChangeSelf); + + var result = await service.ChangeSecretAsync(context, + new ChangeCredentialRequest + { + CurrentSecret = "user", + NewSecret = "newpass123" + }); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Change_password_with_wrong_current_should_throw() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UAuthActions.Credentials.ChangeSelf); + + Func act = async () => + await service.ChangeSecretAsync(context, + new ChangeCredentialRequest + { + CurrentSecret = "wrong", + NewSecret = "newpass123" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Change_password_to_same_should_throw() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UAuthActions.Credentials.ChangeSelf); + + Func act = async () => + await service.ChangeSecretAsync(context, + new ChangeCredentialRequest + { + CurrentSecret = "user", + NewSecret = "user" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Change_password_should_increment_version() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UAuthActions.Credentials.ChangeSelf); + + var before = await service.GetAllAsync(context); + var versionBefore = before.Credentials.Single().Version; + + await service.ChangeSecretAsync(context, + new ChangeCredentialRequest + { + CurrentSecret = "user", + NewSecret = "newpass123" + }); + + var after = await service.GetAllAsync(context); + var versionAfter = after.Credentials.Single().Version; + + versionAfter.Should().Be(versionBefore + 1); + } + + [Fact] + public async Task Old_password_should_not_work_after_change() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var context = TestAccessContext.ForUser(TestUsers.User, UAuthActions.Credentials.ChangeSelf); + + await service.ChangeSecretAsync(context, + new ChangeCredentialRequest + { + CurrentSecret = "user", + NewSecret = "newpass123" + }); + + var oldLogin = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user" + }); + + oldLogin.IsSuccess.Should().BeFalse(); + + var newLogin = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "newpass123" + }); + + newLogin.IsSuccess.Should().BeTrue(); + } + + +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs new file mode 100644 index 00000000..431b8579 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs @@ -0,0 +1,245 @@ +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ResetPasswordTests +{ + [Fact] + public async Task Begin_reset_with_token_should_return_token() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); + + var result = await service.BeginResetAsync(context, + new BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + result.Token.Should().NotBeNull(); + result.Token!.Length.Should().BeGreaterThan(20); + result.ExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow); + } + + [Fact] + public async Task Begin_reset_with_code_should_return_numeric_code() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); + + var result = await service.BeginResetAsync(context, + new BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Code + }); + + result.Token.Should().NotBeNull(); + result.Token!.Should().MatchRegex("^[0-9]{6}$"); + } + + [Fact] + public async Task Begin_reset_for_unknown_user_should_not_fail() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); + + var result = await service.BeginResetAsync(context, + new BeginCredentialResetRequest + { + Identifier = "unknown@test.com", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + result.Token.Should().BeNull(); + } + + [Fact] + public async Task Reset_password_with_valid_token_should_succeed() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var begin = await service.BeginResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), + new BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + var result = await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "newpass123" + }); + + result.Succeeded.Should().BeTrue(); + } + + [Fact] + public async Task Reset_password_with_same_password_should_fail() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var begin = await service.BeginResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), + new BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + Func act = async () => + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "admin" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Reset_token_should_lock_after_max_attempts() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var begin = await service.BeginResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), + new BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Code + }); + + for (int i = 0; i < 3; i++) + { + try + { + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin!.Token == "000000" ? "000001" : "000000", + NewSecret = "newpass123" + }); + } + catch { } + } + + Func act = async () => + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "newpass123" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Reset_token_should_be_single_use() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var begin = await service.BeginResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), + new BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "newpass123" + }); + + Func act = async () => + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "anotherpass" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Reset_token_should_fail_if_expired() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + var clock = runtime.Clock; + + var begin = await service.BeginResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), + new BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + clock.Advance(TimeSpan.FromMinutes(45)); + + Func act = async () => + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "newpass123" + }); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs index f1572c2a..df05950c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Client.Services; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; using System.Security.Claims; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -50,11 +51,41 @@ public Task LoginAsync(LoginRequest request, string? returnUrl) throw new NotImplementedException(); } + public Task LogoutAllDevicesAdminAsync(UserKey userKey) + { + throw new NotImplementedException(); + } + + public Task LogoutAllDevicesSelfAsync() + { + throw new NotImplementedException(); + } + public Task LogoutAsync() { throw new NotImplementedException(); } + public Task> LogoutDeviceAdminAsync(UserKey userKey, SessionChainId chainId) + { + throw new NotImplementedException(); + } + + public Task> LogoutDeviceSelfAsync(LogoutDeviceSelfRequest request) + { + throw new NotImplementedException(); + } + + public Task LogoutOtherDevicesAdminAsync(LogoutOtherDevicesAdminRequest request) + { + throw new NotImplementedException(); + } + + public Task LogoutOtherDevicesSelfAsync() + { + throw new NotImplementedException(); + } + public Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string? returnUrl = null) { throw new NotImplementedException(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs index 67a4b94d..46de8a34 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs @@ -25,9 +25,9 @@ public static AuthFlowContext LoginSuccess(ReturnUrlInfo? returnUrlInfo = null, originalOptions: TestServerOptions.Default(), effectiveOptions: TestServerOptions.Effective(), response: new EffectiveAuthResponse( - sessionIdDelivery: CredentialResponseOptions.Disabled(CredentialKind.Session), - accessTokenDelivery: CredentialResponseOptions.Disabled(CredentialKind.AccessToken), - refreshTokenDelivery: CredentialResponseOptions.Disabled(CredentialKind.RefreshToken), + sessionIdDelivery: CredentialResponseOptions.Disabled(GrantKind.Session), + accessTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.AccessToken), + refreshTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.RefreshToken), redirect: redirect ?? EffectiveRedirectResponse.Disabled ), primaryTokenKind: PrimaryTokenKind.Session, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs index 137f63b9..def622e2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; @@ -12,6 +13,7 @@ public static AccessContext WithAction(string action) actorTenant: TenantKey.Single, isAuthenticated: false, isSystemActor: false, + actorChainId: null, resource: "test", targetUserKey: null, resourceTenant: TenantKey.Single, @@ -19,4 +21,22 @@ public static AccessContext WithAction(string action) attributes: EmptyAttributes.Instance ); } + + public static AccessContext ForUser(UserKey userKey, string action, TenantKey? tenant = null) + { + var t = tenant ?? TenantKey.Single; + + return new AccessContext( + actorUserKey: userKey, + actorTenant: t, + isAuthenticated: true, + isSystemActor: false, + actorChainId: null, + resource: "identifier", + targetUserKey: userKey, + resourceTenant: t, + action: action, + attributes: EmptyAttributes.Instance + ); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs index 368fa3bb..26e6d480 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -1,15 +1,19 @@ -using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; +using CodeBeam.UltimateAuth.Authentication.InMemory; +using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; +using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; @@ -24,9 +28,11 @@ namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; internal sealed class TestAuthRuntime where TUserId : notnull { public IServiceProvider Services { get; } + public TestClock Clock { get; } public TestAuthRuntime(Action? configureServer = null, Action? configureCore = null) { + Clock = new TestClock(); var services = new ServiceCollection(); services.AddLogging(); @@ -43,17 +49,22 @@ public TestAuthRuntime(Action? configureServer = null, Actio services.AddUltimateAuthCredentialsInMemory(); services.AddUltimateAuthInMemorySessions(); services.AddUltimateAuthInMemoryTokens(); + services.AddUltimateAuthInMemoryAuthenticationSecurity(); services.AddUltimateAuthAuthorizationInMemory(); - services.AddUltimateAuthAuthorizationReference(); services.AddUltimateAuthUsersReference(); + services.AddUltimateAuthAuthorizationReference(); + services.AddUltimateAuthCredentialsReference(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(sp => + sp.GetRequiredService()); var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); services.AddSingleton(configuration); - + services.AddSingleton(Clock); Services = services.BuildServiceProvider(); Services.GetRequiredService().RunAsync(null).GetAwaiter().GetResult(); @@ -67,4 +78,31 @@ public ValueTask CreateLoginFlowAsync(TenantKey? tenant = null) var httpContext = TestHttpContext.Create(tenant); return Services.GetRequiredService().CreateAsync(httpContext, AuthFlowType.Login); } + + public IUserApplicationService GetUserApplicationService() + { + var scope = Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + + public ICredentialManagementService GetCredentialManagementService() + { + var scope = Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + + public async Task LoginAsync(AuthFlowContext flow) + { + using var scope = Services.CreateScope(); + + var orchestrator = scope.ServiceProvider + .GetRequiredService(); + + return await orchestrator.LoginAsync(flow, new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user" + }); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClock.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClock.cs new file mode 100644 index 00000000..02256391 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClock.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal sealed class TestClock : IClock +{ + private DateTimeOffset _utcNow; + + public TestClock(DateTimeOffset? initial = null) + { + _utcNow = initial ?? DateTimeOffset.UtcNow; + } + + public DateTimeOffset UtcNow => _utcNow; + + public void Advance(TimeSpan duration) + { + _utcNow = _utcNow.Add(duration); + } + + public void Set(DateTimeOffset time) + { + _utcNow = time; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs index 7265c154..87daa2e2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs @@ -4,5 +4,5 @@ namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; internal static class TestDevice { - public static DeviceContext Default() => DeviceContext.FromDeviceId(DeviceId.Create("test-device-000-000-000-000-01")); + public static DeviceContext Default() => DeviceContext.Create(DeviceId.Create("test-device-000-000-000-000-01"), null, null, null, null, null); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs index ef3d3deb..4545b326 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs @@ -13,7 +13,8 @@ public static HttpContext Create(TenantKey? tenant = null, UAuthClientProfile cl var resolvedTenant = tenant ?? TenantKey.Single; ctx.Items[UAuthConstants.HttpItems.TenantContextKey] = UAuthTenantContext.Resolved(resolvedTenant); - + + ctx.Request.Headers["X-UDID"] = "test-device-000-000-000-000-01"; ctx.Request.Headers["User-Agent"] = "UltimateAuth-Test"; ctx.Request.Scheme = "https"; ctx.Request.Host = new HostString("app.example.com"); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs index bb7e05d6..b64b32db 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs @@ -1,26 +1,60 @@ -using CodeBeam.UltimateAuth.Policies; +using CodeBeam.UltimateAuth.Authorization.Policies; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; namespace CodeBeam.UltimateAuth.Tests.Unit; public class ActionTextTests { + private readonly MustHavePermissionPolicy _policy = new(); + [Theory] [InlineData("users.profile.get.admin", true)] - [InlineData("users.profile.get.self", false)] - [InlineData("users.profile.get", false)] - public void RequireAdminPolicy_AppliesTo_Works(string action, bool expected) + [InlineData("users.delete.admin", true)] + [InlineData("sessions.revoke.admin", true)] + public void AppliesTo_ReturnsTrue_ForAdminScope(string action, bool expected) + { + var context = TestAccessContext.WithAction(action); + var result = _policy.AppliesTo(context); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("users.profile.get.self")] + [InlineData("users.profile.get")] + [InlineData("users.profile.get.anonymous")] + public void AppliesTo_ReturnsFalse_ForNonAdminScope(string action) { var context = TestAccessContext.WithAction(action); - var policy = new RequireAdminPolicy(); - Assert.Equal(expected, policy.AppliesTo(context)); + var result = _policy.AppliesTo(context); + Assert.False(result); } [Fact] - public void RequireAdminPolicy_DoesNotMatch_Substrings() + public void AppliesTo_DoesNotMatch_Substrings() { var context = TestAccessContext.WithAction("users.profile.get.administrator"); - var policy = new RequireAdminPolicy(); - Assert.False(policy.AppliesTo(context)); + var result = _policy.AppliesTo(context); + Assert.False(result); + } + + [Fact] + public void AppliesTo_IsCaseInsensitive() + { + var context = TestAccessContext.WithAction("users.profile.get.ADMIN"); + var result = _policy.AppliesTo(context); + Assert.True(result); + } + + [Theory] + [InlineData("users.create.admin", true)] + [InlineData("users.create.self", false)] + [InlineData("users.create.anonymous", false)] + [InlineData("users.create", false)] + [InlineData("users.create.admin.extra", false)] + public void AppliesTo_AdminScopeDetection(string action, bool expected) + { + var context = TestAccessContext.WithAction(action); + var result = _policy.AppliesTo(context); + Assert.Equal(expected, result); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs index 604465d5..2f6aabb7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs @@ -1,13 +1,12 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Authentication.InMemory; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Events; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; -using CodeBeam.UltimateAuth.Users.InMemory; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; -using System.Security; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -26,7 +25,6 @@ public async Task Successful_login_should_return_success_result() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default(), }); result.IsSuccess.Should().BeTrue(); @@ -45,7 +43,6 @@ public async Task Successful_login_should_create_session() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default(), }); result.SessionId.Should().NotBeNull(); @@ -68,14 +65,11 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService(); - - var state = store.GetState(TenantKey.Single, TestUsers.User); - - state!.FailedLoginAttempts.Should().Be(1); + var store = runtime.Services.GetRequiredService(); + var state = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + state?.FailedAttempts.Should().Be(1); } [Fact] @@ -95,7 +89,6 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); await orchestrator.LoginAsync(flow, @@ -104,13 +97,11 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "user", // valid password - Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService(); - - var state = store.GetState(TenantKey.Single, TestUsers.User); - state.Should().BeNull(); + var store = runtime.Services.GetRequiredService(); + var state = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + state?.FailedAttempts.Should().Be(0); } [Fact] @@ -126,7 +117,6 @@ public async Task Invalid_password_should_fail_login() Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); result.IsSuccess.Should().BeFalse(); @@ -145,7 +135,6 @@ public async Task Non_existent_user_should_fail_login_gracefully() Tenant = TenantKey.Single, Identifier = "ghost", Secret = "whatever", - Device = TestDevice.Default(), }); result.IsSuccess.Should().BeFalse(); @@ -168,13 +157,12 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService(); - var state = store.GetState(TenantKey.Single, TestUsers.User); + var store = runtime.Services.GetRequiredService(); + var state = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); - state!.IsLocked.Should().BeTrue(); + state!.IsLocked(DateTimeOffset.UtcNow).Should().BeTrue(); } [Fact] @@ -188,24 +176,20 @@ public async Task Locked_user_should_not_login_even_with_correct_password() var orchestrator = runtime.GetLoginOrchestrator(); var flow = await runtime.CreateLoginFlowAsync(); - // lock await orchestrator.LoginAsync(flow, new LoginRequest { Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); - // try again with correct password var result = await orchestrator.LoginAsync(flow, new LoginRequest { Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default(), }); result.IsSuccess.Should().BeFalse(); @@ -228,24 +212,20 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService(); - var state1 = store.GetState(TenantKey.Single, TestUsers.User); + var store = runtime.Services.GetRequiredService(); + var state1 = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); await orchestrator.LoginAsync(flow, new LoginRequest { Tenant = TenantKey.Single, Identifier = "user", - Secret = "wrong", - Device = TestDevice.Default(), }); - var state2 = store.GetState(TenantKey.Single, TestUsers.User); - - state2!.FailedLoginAttempts.Should().Be(state1!.FailedLoginAttempts); + var state2 = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + state2?.FailedAttempts.Should().Be(state1!.FailedAttempts); } [Fact] @@ -267,34 +247,14 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); } - var store = runtime.Services.GetRequiredService(); - var state = store.GetState(TenantKey.Single, TestUsers.User); - - state!.IsLocked.Should().BeFalse(); - state.FailedLoginAttempts.Should().Be(5); - } - - [Fact] - public async Task Invalid_device_id_should_throw_security_exception() - { - var runtime = new TestAuthRuntime(); - var orchestrator = runtime.GetLoginOrchestrator(); - var flow = await runtime.CreateLoginFlowAsync(); - - Func act = () => orchestrator.LoginAsync(flow, - new LoginRequest - { - Tenant = TenantKey.Single, - Identifier = "user", - Secret = "user", - Device = DeviceContext.FromDeviceId(DeviceId.Create("x")), // too short - }); + var store = runtime.Services.GetRequiredService(); + var state = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); - await act.Should().ThrowAsync(); + state?.IsLocked(DateTimeOffset.UtcNow).Should().BeFalse(); + state?.FailedAttempts.Should().Be(5); } [Fact] @@ -315,11 +275,10 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService(); - var state1 = store.GetState(TenantKey.Single, TestUsers.User); + var store = runtime.Services.GetRequiredService(); + var state1 = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); var lockedUntil = state1!.LockedUntil; @@ -329,11 +288,10 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), }); - var state2 = store.GetState(TenantKey.Single, TestUsers.User); - state2!.LockedUntil.Should().Be(lockedUntil); + var state2 = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + state2?.LockedUntil.Should().Be(lockedUntil); } [Fact] @@ -358,7 +316,6 @@ public async Task Login_success_should_trigger_UserLoggedIn_event() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default() }); captured.Should().NotBeNull(); @@ -387,7 +344,6 @@ public async Task Login_success_should_trigger_OnAnyEvent() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default() }); count.Should().BeGreaterThan(0); @@ -409,7 +365,6 @@ public async Task Event_handler_exception_should_not_break_login_flow() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default() }); result.IsSuccess.Should().BeTrue(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs index f25dfb04..90b20a6f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs @@ -243,8 +243,8 @@ public void UserIdentifiers_both_admin_and_user_override_disabled_should_fail() services.AddOptions() .Configure(o => { - o.UserIdentifiers.AllowAdminOverride = false; - o.UserIdentifiers.AllowUserOverride = false; + o.Identifiers.AllowAdminOverride = false; + o.Identifiers.AllowUserOverride = false; }); services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); @@ -268,14 +268,14 @@ public void UserIdentifiers_at_least_one_override_enabled_should_pass() services.AddOptions() .Configure(o => { - o.UserIdentifiers.AllowAdminOverride = true; - o.UserIdentifiers.AllowUserOverride = false; + o.Identifiers.AllowAdminOverride = true; + o.Identifiers.AllowUserOverride = false; }); services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); var provider = services.BuildServiceProvider(); var options = provider.GetRequiredService>().Value; - options.UserIdentifiers.AllowAdminOverride.Should().BeTrue(); + options.Identifiers.AllowAdminOverride.Should().BeTrue(); } [Fact] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs new file mode 100644 index 00000000..dec7a17f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs @@ -0,0 +1,136 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class SessionTests +{ + [Fact] + public async Task Login_should_cleanup_old_sessions_when_limit_exceeded() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Session.MaxSessionsPerChain = 3; + }); + + var flow = await runtime.CreateLoginFlowAsync(); + for (int i = 0; i < 5; i++) + { + await runtime.LoginAsync(flow); + } + + var store = runtime.Services.GetRequiredService().Create(TenantKey.Single); + var chains = await store.GetChainsByUserAsync(TestUsers.User); + var chain = chains.First(); + var sessions = await store.GetSessionsByChainAsync(chain.ChainId); + + sessions.Count.Should().BeLessThanOrEqualTo(3); + } + + [Fact] + public async Task Logout_device_should_revoke_sessions_but_keep_chain() + { + var runtime = new TestAuthRuntime(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var result = await orchestrator.LoginAsync(flow, new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user" + }); + + var store = runtime.Services.GetRequiredService().Create(TenantKey.Single); + var chainId = await store.GetChainIdBySessionAsync(result.SessionId!.Value); + await store.LogoutChainAsync(chainId!.Value, runtime.Clock.UtcNow); + var chain = await store.GetChainAsync(chainId.Value); + + chain.Should().NotBeNull(); + chain!.ActiveSessionId.Should().BeNull(); + + var sessions = await store.GetSessionsByChainAsync(chainId.Value); + + sessions.All(x => x.IsRevoked).Should().BeTrue(); + } + + [Fact] + public async Task Logout_other_devices_should_keep_current_chain() + { + var runtime = new TestAuthRuntime(); + + var flow1 = await runtime.CreateLoginFlowAsync(); + var flow2 = await runtime.CreateLoginFlowAsync(); + + await runtime.LoginAsync(flow1); + + await runtime.LoginAsync(flow2); + + var store = runtime.Services.GetRequiredService() + .Create(TenantKey.Single); + + var chains = await store.GetChainsByUserAsync(TestUsers.User); + + var current = chains.First(); + + await store.RevokeOtherSessionsAsync(current.UserKey, current.ChainId, runtime.Clock.UtcNow); + + var updatedChains = await store.GetChainsByUserAsync(current.UserKey); + + updatedChains.Count(x => x.State == SessionChainState.Active).Should().Be(1); + } + + [Fact] + public async Task Get_chain_detail_should_return_sessions() + { + var runtime = new TestAuthRuntime(); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user" + }); + + var service = runtime.Services.GetRequiredService(); + var context = TestAccessContext.ForUser(TestUsers.User, "session.query"); + var store = runtime.Services.GetRequiredService().Create(TenantKey.Single); + var chains = await store.GetChainsByUserAsync(TestUsers.User); + var result = await service.GetUserChainDetailAsync(context, chains.First().UserKey, chains.First().ChainId); + + result.Should().NotBeNull(); + result.Sessions.Should().NotBeEmpty(); + } + + [Fact] + public async Task Revoke_chain_should_revoke_all_sessions() + { + var runtime = new TestAuthRuntime(); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user" + }); + + var store = runtime.Services.GetRequiredService().Create(TenantKey.Single); + var chains = await store.GetChainsByUserAsync(TestUsers.User); + await store.RevokeChainCascadeAsync(chains.First().ChainId, runtime.Clock.UtcNow); + var sessions = await store.GetSessionsByChainAsync(chains.First().ChainId); + + sessions.All(x => x.IsRevoked).Should().BeTrue(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs new file mode 100644 index 00000000..a66d3dd7 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs @@ -0,0 +1,355 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.InMemory; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class IdentifierConcurrencyTests +{ + [Fact] + public async Task Save_should_increment_version() + { + var store = new InMemoryUserIdentifierStore(); + var now = DateTimeOffset.UtcNow; + var id = Guid.NewGuid(); + + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + await store.AddAsync(identifier); + + var copy = await store.GetByIdAsync(id); + var expected = copy!.Version; + + copy.ChangeValue("b@test.com", "b@test.com", now); + await store.SaveAsync(copy, expected); + + var updated = await store.GetByIdAsync(id); + + Assert.Equal(expected + 1, updated!.Version); + } + + [Fact] + public async Task Delete_should_throw_when_version_conflicts() + { + var store = new InMemoryUserIdentifierStore(); + var now = DateTimeOffset.UtcNow; + var id = Guid.NewGuid(); + + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + await store.AddAsync(identifier); + + var copy1 = await store.GetByIdAsync(id); + var copy2 = await store.GetByIdAsync(id); + + var expected1 = copy1!.Version; + copy1.MarkDeleted(now); + await store.SaveAsync(copy1, expected1); + + await Assert.ThrowsAsync(async () => + { + await store.DeleteAsync(copy2!.Id, copy2!.Version, DeleteMode.Soft, now); + }); + } + + [Fact] + public async Task Parallel_SetPrimary_should_conflict_deterministic() + { + var store = new InMemoryUserIdentifierStore(); + var now = DateTimeOffset.UtcNow; + var id = Guid.NewGuid(); + + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + await store.AddAsync(identifier); + + int success = 0; + int conflicts = 0; + + var barrier = new Barrier(2); + + var tasks = Enumerable.Range(0, 2) + .Select(_ => Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id); + + barrier.SignalAndWait(); + + var expected = copy!.Version; + copy.SetPrimary(now); + await store.SaveAsync(copy, expected); + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + })) + .ToArray(); + + await Task.WhenAll(tasks); + + Assert.Equal(1, success); + Assert.Equal(1, conflicts); + + var final = await store.GetByIdAsync(id); + Assert.True(final!.IsPrimary); + Assert.Equal(1, final.Version); + } + + [Fact] + public async Task Update_should_throw_concurrency_when_versions_conflict() + { + var store = new InMemoryUserIdentifierStore(); + var id = Guid.NewGuid(); + var now = DateTimeOffset.UtcNow; + var tenant = TenantKey.Single; + + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + await store.AddAsync(identifier); + + var copy1 = await store.GetByIdAsync(id); + var copy2 = await store.GetByIdAsync(id); + + var expected1 = copy1!.Version; + copy1.ChangeValue("b@test.com", "b@test.com", now); + await store.SaveAsync(copy1, expected1); + + var expected2 = copy2!.Version; + copy2.ChangeValue("c@test.com", "c@test.com", now); + + await Assert.ThrowsAsync(async () => + { + await store.SaveAsync(copy2, expected2); + }); + } + + [Fact] + public async Task Parallel_updates_should_result_in_single_success_deterministic() + { + var store = new InMemoryUserIdentifierStore(); + var now = DateTimeOffset.UtcNow; + var tenant = TenantKey.Single; + var id = Guid.NewGuid(); + + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + await store.AddAsync(identifier); + + int success = 0; + int conflicts = 0; + + var barrier = new Barrier(2); + + var tasks = Enumerable.Range(0, 2) + .Select(i => Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id); + barrier.SignalAndWait(); + var expected = copy!.Version; + + var newValue = i == 0 + ? "x@test.com" + : "y@test.com"; + + copy.ChangeValue(newValue, newValue, now); + await store.SaveAsync(copy, expected); + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + })) + .ToArray(); + + await Task.WhenAll(tasks); + + Assert.Equal(1, success); + Assert.Equal(1, conflicts); + + var final = await store.GetByIdAsync(id); + + Assert.NotNull(final); + Assert.Equal(1, final!.Version); + } + + [Fact] + public async Task High_contention_updates_should_allow_only_one_success() + { + var store = new InMemoryUserIdentifierStore(); + var now = DateTimeOffset.UtcNow; + var tenant = TenantKey.Single; + var id = Guid.NewGuid(); + + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "initial@test.com", "initial@test.com", now); + await store.AddAsync(identifier); + + int success = 0; + int conflicts = 0; + + var tasks = Enumerable.Range(0, 20) + .Select(i => Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id); + var expected = copy!.Version; + + var newValue = $"user{i}@test.com"; + + copy.ChangeValue(newValue, newValue, now); + + await store.SaveAsync(copy, expected); + + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + })) + .ToArray(); + + await Task.WhenAll(tasks); + + Assert.True(success >= 1); + Assert.Equal(20, success + conflicts); + + var final = await store.GetByIdAsync(id); + Assert.Equal(success, final!.Version); + } + + [Fact] + public async Task High_contention_SetPrimary_should_allow_only_one_deterministic() + { + var store = new InMemoryUserIdentifierStore(); + var now = DateTimeOffset.UtcNow; + var tenant = TenantKey.Single; + + var id = Guid.NewGuid(); + + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "primary@test.com", "primary@test.com", now); + await store.AddAsync(identifier); + + int success = 0; + int conflicts = 0; + + var barrier = new Barrier(20); + + var tasks = Enumerable.Range(0, 20) + .Select(_ => Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id); + + barrier.SignalAndWait(); + + var expected = copy!.Version; + copy.SetPrimary(now); + await store.SaveAsync(copy, expected); + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + })) + .ToArray(); + + await Task.WhenAll(tasks); + + Assert.Equal(1, success); + Assert.Equal(19, conflicts); + + var final = await store.GetByIdAsync(id); + + Assert.True(final!.IsPrimary); + Assert.Equal(1, final.Version); + } + + [Fact] + public async Task Two_identifiers_racing_for_primary_should_allow() + { + var store = new InMemoryUserIdentifierStore(); + var now = DateTimeOffset.UtcNow; + var tenant = TenantKey.Single; + var user = TestUsers.Admin; + + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + var identifier1 = UserIdentifier.Create(id1, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + var identifier2 = UserIdentifier.Create(id2, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "b@test.com", "b@test.com", now); + + await store.AddAsync(identifier1); + await store.AddAsync(identifier2); + + int success = 0; + int conflicts = 0; + + var barrier = new Barrier(2); + + var tasks = new[] + { + Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id1); + + barrier.SignalAndWait(); + + var expected = copy!.Version; + copy.SetPrimary(now); + + await store.SaveAsync(copy, expected); + + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + }), + Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id2); + + barrier.SignalAndWait(); + + var expected = copy!.Version; + copy.SetPrimary(now); + + await store.SaveAsync(copy, expected); + + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + }) + }; + + await Task.WhenAll(tasks); + + Assert.Equal(2, success); + Assert.Equal(0, conflicts); + + var all = await store.GetByUserAsync(tenant, user); + + var primaries = all + .Where(x => x.Type == UserIdentifierType.Email && x.IsPrimary) + .ToList(); + + Assert.Single(primaries); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs new file mode 100644 index 00000000..277d6922 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs @@ -0,0 +1,381 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using static CodeBeam.UltimateAuth.Core.Defaults.UAuthActions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UserIdentifierApplicationServiceTests +{ + [Fact] + public async Task Adding_new_primary_email_should_replace_existing_primary() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "new@example.com", + IsPrimary = true + }); + + var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + + identifiers.Items.Where(x => x.Type == UserIdentifierType.Email).Should().ContainSingle(x => x.IsPrimary); + + identifiers.Items.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary).Value.Should().Be("new@example.com"); + } + + [Fact] + public async Task Primary_phone_should_not_be_login_if_not_allowed() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.LoginIdentifiers.AllowedTypes = new HashSet + { + UserIdentifierType.Email + }; + }); + + var identifierService = runtime.Services.GetRequiredService(); + var loginOrchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var context = TestAccessContext.ForUser( + TestUsers.User, + action: "users.identifiers.add"); + + await identifierService.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Phone, + Value = "+905551111111", + IsPrimary = true + }); + + var result = await loginOrchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "+905551111111", + Secret = "user", + //Device = TestDevice.Default() + }); + + result.IsSuccess.Should().BeFalse(); + } + + [Fact] + public async Task Adding_non_primary_should_not_affect_existing_primary() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + var before = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + var existingPrimaryEmail = before.Items.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "secondary@example.com", + IsPrimary = false + }); + + var after = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + + after.Items.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary) + .Id.Should().Be(existingPrimaryEmail.Id); + } + + [Fact] + public async Task Updating_value_should_reset_verification() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.UpdateSelf); + + var email = (await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery())).Items.Single(x => x.Type == UserIdentifierType.Email); + + await service.UpdateUserIdentifierAsync(context, + new UpdateUserIdentifierRequest + { + Id = email.Id, + NewValue = "updated@example.com" + }); + + var updated = (await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery())).Items + .Single(x => x.Id == email.Id); + + updated.IsVerified.Should().BeFalse(); + } + + [Fact] + public async Task Non_primary_duplicate_should_be_allowed_when_global_uniqueness_disabled() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var user1 = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + var user2 = TestAccessContext.ForUser(TestUsers.Admin, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(user1, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "shared@example.com", + IsPrimary = false + }); + + await service.AddUserIdentifierAsync(user2, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "shared@example.com", + IsPrimary = false + }); + + true.Should().BeTrue(); // no exception + } + + [Fact] + public async Task Primary_duplicate_should_fail_when_global_uniqueness_enabled() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers = true; + }); + + var service = runtime.GetUserApplicationService(); + + var user1 = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + var user2 = TestAccessContext.ForUser(TestUsers.Admin, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(user1, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "unique@example.com", + IsPrimary = true + }); + + Func act = async () => + await service.AddUserIdentifierAsync(user2, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "unique@example.com", + IsPrimary = true + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Unsetting_last_login_identifier_should_fail() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.LoginIdentifiers.AllowedTypes = new HashSet + { + UserIdentifierType.Email + }; + }); + + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.UnsetPrimarySelf); + + var email = (await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery())).Items.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary); + + Func act = async () => + await service.UnsetPrimaryUserIdentifierAsync(context, + new UnsetPrimaryUserIdentifierRequest + { + IdentifierId = email.Id + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Email_should_be_case_insensitive_by_default() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "Test@Example.com" + }); + + Func act = async () => + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "test@example.com" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Username_should_respect_case_policy() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Identifiers.AllowMultipleUsernames = true; + }); + + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Username, + Value = "UserName" + }); + + var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + + identifiers.Items.Should().Contain(x => x.Value == "UserName"); + } + + [Fact] + public async Task Username_should_be_case_insensitive_when_configured() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.LoginIdentifiers.Normalization.UsernameCase = CaseHandling.ToLower; + o.Identifiers.AllowMultipleUsernames = true; + }); + + var service = runtime.GetUserApplicationService(); + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Username, + Value = "UserName" + }); + + Func act = async () => + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Username, + Value = "username" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Phone_should_be_normalized_to_digits() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Phone, + Value = "+90 (555) 123-45-67" + }); + + var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + + identifiers.Items.Should().Contain(x => + x.Type == UserIdentifierType.Phone && + x.Value == "+90 (555) 123-45-67"); + } + + [Fact] + public async Task Updating_to_existing_value_should_fail() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "one@example.com" + }); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "two@example.com" + }); + + var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + var second = identifiers.Items.Single(x => x.Value == "two@example.com"); + + Func act = async () => + await service.UpdateUserIdentifierAsync(context, + new UpdateUserIdentifierRequest + { + Id = second.Id, + NewValue = "one@example.com" + }); + + await act.Should().ThrowAsync(); + } + + //[Fact] + //public async Task Same_identifier_in_different_tenants_should_not_conflict() + //{ + // var runtime = new TestAuthRuntime(); + + // var service = runtime.GetUserApplicationService(); + + // var tenant1User = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf, TenantKey.Single); + // var tenant2User = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf, TenantKey.FromInternal("other")); + + // await service.AddUserIdentifierAsync(tenant1User, + // new AddUserIdentifierRequest + // { + // Type = UserIdentifierType.Email, + // Value = "tenant@example.com", + // IsPrimary = true + // }); + + // await service.AddUserIdentifierAsync(tenant2User, + // new AddUserIdentifierRequest + // { + // Type = UserIdentifierType.Email, + // Value = "tenant@example.com", + // IsPrimary = true + // }); + + // true.Should().BeTrue(); + //} +} +