diff --git a/Oqtane.Client/Modules/Admin/Login/Index.razor b/Oqtane.Client/Modules/Admin/Login/Index.razor index 36d9eaf1..955f823a 100644 --- a/Oqtane.Client/Modules/Admin/Login/Index.razor +++ b/Oqtane.Client/Modules/Admin/Login/Index.razor @@ -20,39 +20,50 @@ else
@if (_allowexternallogin) { - -

+ +
} @if (_allowsitelogin) { -
+
-
+
-
- @if (!_alwaysremember) - { -
+ @if (!_alwaysremember) + { +
+
- } +
+ } + +
+ +
- - -

- - @if (PageState.Site.AllowRegistration) + + + @if (_allowpasskeys) { -

- @Localizer["Register"] +
+ + } + + @if (PageState.Site.AllowRegistration) + { +
+
+ @Localizer["Register"] +
} }
@@ -77,6 +88,7 @@ else @code { private bool _allowsitelogin = true; private bool _allowexternallogin = false; + private bool _allowpasskeys = false; private ElementReference login; private bool validated = false; private bool twofactor = false; @@ -88,6 +100,7 @@ else private bool _remember = false; private bool _alwaysremember = false; private string _code = string.Empty; + private string _registerurl = string.Empty; public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Anonymous; public override bool? Prerender => true; @@ -103,8 +116,18 @@ else { _allowexternallogin = (SettingService.GetSetting(PageState.Site.Settings, "ExternalLogin:ProviderType", "") != "") ? true : false; _allowsitelogin = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:AllowSiteLogin", "true")); + _allowpasskeys = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:AllowPasskeys", "false")); _alwaysremember = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:AlwaysRemember", "false")); + if (!string.IsNullOrEmpty(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:RegisterUrl", ""))) + { + _registerurl = SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:RegisterUrl", ""); + } + else + { + _registerurl = NavigateUrl("register"); + } + _togglepassword = SharedLocalizer["ShowPassword"]; if (PageState.QueryString.ContainsKey("name")) @@ -163,23 +186,6 @@ else } } - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender && PageState.User == null && _allowsitelogin) - { - if (!string.IsNullOrEmpty(username.Id)) // ensure username is visible in UI - { - await username.FocusAsync(); - } - } - - // redirect logged in user to specified page - if (PageState.User != null && !UserSecurity.IsAuthorized(PageState.User, RoleNames.Admin)) - { - NavigationManager.NavigateTo(PageState.ReturnUrl); - } - } - private async Task Login() { try @@ -331,4 +337,58 @@ else NavigationManager.NavigateTo(Utilities.TenantUrl(PageState.Alias, "/pages/external?returnurl=" + WebUtility.UrlEncode(PageState.ReturnUrl)), true); } + private async Task Passkey() + { + // post back to the Passkey page so that the cookies are set correctly + var interop = new Interop(JSRuntime); + var fields = new { __RequestVerificationToken = SiteState.AntiForgeryToken, operation = "request", returnurl = NavigateUrl() }; + string url = Utilities.TenantUrl(PageState.Alias, "/pages/passkey/"); + await interop.SubmitForm(url, fields); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && PageState.QueryString.ContainsKey("options")) + { + // user has initiated a passkey login + try + { + var interop = new Interop(JSRuntime); + var credential = await interop.RequestCredential(WebUtility.UrlDecode(PageState.QueryString["options"])); + if (!string.IsNullOrEmpty(credential)) + { + // post back to the Passkey page so that the cookies are set correctly + var returnurl = (!string.IsNullOrEmpty(PageState.ReturnUrl)) ? PageState.ReturnUrl : PageState.Alias.Path + "/"; + var fields = new { __RequestVerificationToken = SiteState.AntiForgeryToken, operation = "login", credential = credential, returnurl = returnurl }; + string url = Utilities.TenantUrl(PageState.Alias, "/pages/passkey/"); + await interop.SubmitForm(url, fields); + } + else + { + await logger.LogError("Error Logging In With Passkey"); + AddModuleMessage(Localizer["Error.Passkey"], MessageType.Error); + } + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Logging In With Passkey"); + AddModuleMessage(Localizer["Error.Passkey"], MessageType.Error); + } + return; + } + + if (firstRender && PageState.User == null && _allowsitelogin) + { + if (!string.IsNullOrEmpty(username.Id)) // ensure username is visible in UI + { + await username.FocusAsync(); + } + } + + // redirect logged in user to specified page + if (PageState.User != null && !UserSecurity.IsAuthorized(PageState.User, RoleNames.Admin)) + { + NavigationManager.NavigateTo(PageState.ReturnUrl); + } + } } diff --git a/Oqtane.Client/Modules/Admin/UserProfile/Index.razor b/Oqtane.Client/Modules/Admin/UserProfile/Index.razor index 1f829f67..3ab9cb1a 100644 --- a/Oqtane.Client/Modules/Admin/UserProfile/Index.razor +++ b/Oqtane.Client/Modules/Admin/UserProfile/Index.razor @@ -26,7 +26,7 @@
} - +
@@ -69,7 +69,7 @@ - +
@@ -110,40 +110,70 @@
+
} -
-
-
- - -
-   -   - @Localizer["Passkey"] -
- - @if (context.CredentialId != _passkeyId) - { - - - @context.Name - } - else - { - - - - } - -
-

-
-
- -
+ @if (_allowpasskeys) + { +
+ @if (PageState.Route.Scheme == "https") + { + + } + else + { + + } + @if (_passkeys != null && _passkeys.Count > 0) + { + +
+   +   + @Localizer["Passkey"] +
+ + @if (context.CredentialId != _passkeyId) + { + + + @context.Name + } + else + { + + + + } + +
+ } +
+
+ } + @if (_allowexternallogin) + { +
+ @if (_logins != null && _logins.Count > 0) + { + +
+   + @Localizer["Login"] +
+ + + @context.Name + +
+ } +
+
+ } +
+
- +
@foreach (Profile profile in _profiles) @@ -272,11 +302,11 @@ - +

- @@ -295,7 +325,7 @@ - + @if (context.IsRead) { @@ -358,7 +388,7 @@ - + @if (context.IsRead) { @@ -415,15 +445,14 @@ } @code { + public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.View; + private bool _initialized = false; - private string _passwordrequirements; - private string _username = string.Empty; - private string _password = string.Empty; - private string _passwordtype = "password"; - private string _togglepassword = string.Empty; - private string _confirm = string.Empty; private bool _allowtwofactor = false; - private string _twofactor = "False"; + private bool _allowpasskeys = false; + private bool _allowexternallogin = false; + + private string _username = string.Empty; private string _email = string.Empty; private string _displayname = string.Empty; private FileManager _filemanager; @@ -434,9 +463,16 @@ private File _photo = null; private string _imagefiles = string.Empty; - private List _passkeys; + private string _passwordrequirements; + private string _password = string.Empty; + private string _passwordtype = "password"; + private string _togglepassword = string.Empty; + private string _confirm = string.Empty; + private string _twofactor = "False"; + private List _passkeys; private byte[] _passkeyId; private string _passkeyName = string.Empty; + private List _logins; private List _profiles; private Dictionary _userSettings; @@ -446,45 +482,29 @@ private List _notifications; private string _notificationSummary = string.Empty; - public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.View; - protected override async Task OnInitializedAsync() { try { - _passwordrequirements = await UserService.GetPasswordRequirementsAsync(PageState.Site.SiteId); - _togglepassword = SharedLocalizer["ShowPassword"]; _allowtwofactor = (SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:TwoFactor", "false") == "true"); - _profiles = await ProfileService.GetProfilesAsync(ModuleState.SiteId); - foreach (var profile in _profiles) - { - if (profile.Options.ToLower().StartsWith("entityname:")) - { - var options = await SettingService.GetSettingsAsync(profile.Options.Substring(11), -1); - options.Add("", $"<{SharedLocalizer["Not Specified"]}>"); - profile.Options = string.Join(",", options.OrderBy(item => item.Value).Select(kvp => $"{kvp.Key}:{kvp.Value}")); - } - } - _timezones = TimeZoneService.GetTimeZones(); + _allowpasskeys = (SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:Passkeys", "false") == "true"); + _allowexternallogin = (SettingService.GetSetting(PageState.Site.Settings, "ExternalLogin:ProviderType", "") != "") ? true : false; if (PageState.User != null) { + // identity section _username = PageState.User.Username; - _twofactor = PageState.User.TwoFactorRequired.ToString(); _email = PageState.User.Email; _displayname = PageState.User.DisplayName; - _timezoneid = PageState.User.TimeZoneId; - - // get user folder + _timezones = TimeZoneService.GetTimeZones(); + _timezoneid = PageState.User.TimeZoneId; var folder = await FolderService.GetFolderAsync(ModuleState.SiteId, PageState.User.FolderPath); if (folder != null) { _folderid = folder.FolderId; } - _imagefiles = SettingService.GetSetting(PageState.Site.Settings, "ImageFiles", Constants.ImageFiles); _imagefiles = (string.IsNullOrEmpty(_imagefiles)) ? Constants.ImageFiles : _imagefiles; - if (PageState.User.PhotoFileId != null) { _photofileid = PageState.User.PhotoFileId.Value; @@ -496,8 +516,27 @@ _photo = null; } + // security section + _passwordrequirements = await UserService.GetPasswordRequirementsAsync(PageState.Site.SiteId); + _togglepassword = SharedLocalizer["ShowPassword"]; + _twofactor = PageState.User.TwoFactorRequired.ToString(); + await GetPasskeys(); + await GetLogins(); + + // profile section + _profiles = await ProfileService.GetProfilesAsync(ModuleState.SiteId); + foreach (var profile in _profiles) + { + if (profile.Options.ToLower().StartsWith("entityname:")) + { + var options = await SettingService.GetSettingsAsync(profile.Options.Substring(11), -1); + options.Add("", $"<{SharedLocalizer["Not Specified"]}>"); + profile.Options = string.Join(",", options.OrderBy(item => item.Value).Select(kvp => $"{kvp.Key}:{kvp.Value}")); + } + } _userSettings = PageState.User.Settings; + // notification section await LoadNotificationsAsync(); _initialized = true; @@ -514,22 +553,7 @@ } } - private async Task LoadNotificationsAsync() - { - _notifications = await NotificationService.GetNotificationsAsync(PageState.Site.SiteId, _filter, PageState.User.UserId); - _notifications = _notifications.Where(item => item.DeletedBy != PageState.User.Username).ToList(); - } - - private string GetProfileValue(string SettingName, string DefaultValue) - { - string value = SettingService.GetSetting(_userSettings, SettingName, DefaultValue); - if (value.Contains("]")) - { - value = value.Substring(value.IndexOf("]") + 1); - } - return value; - } - + // identity methods private async Task Save() { try @@ -604,6 +628,124 @@ } } + private void Cancel() + { + NavigationManager.NavigateTo(PageState.ReturnUrl); + } + + // security methods + + private void TogglePassword() + { + if (_passwordtype == "password") + { + _passwordtype = "text"; + _togglepassword = SharedLocalizer["HidePassword"]; + } + else + { + _passwordtype = "password"; + _togglepassword = SharedLocalizer["ShowPassword"]; + } + } + + private async Task GetPasskeys() + { + if (_allowpasskeys) + { + _passkeys = await UserService.GetPasskeysAsync(); + } + } + + private async Task AddPasskey() + { + // post back to the Passkey page so that the cookies are set correctly + var interop = new Interop(JSRuntime); + var fields = new { __RequestVerificationToken = SiteState.AntiForgeryToken, operation = "create", returnurl = NavigateUrl() }; + string url = Utilities.TenantUrl(PageState.Alias, "/pages/passkey/"); + await interop.SubmitForm(url, fields); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + // user has initiated a passkey addition + if (PageState.QueryString.ContainsKey("options")) + { + try + { + var interop = new Interop(JSRuntime); + var credential = await interop.CreateCredential(WebUtility.UrlDecode(PageState.QueryString["options"])); + if (!string.IsNullOrEmpty(credential)) + { + // post back to the Passkey page so that the cookies are set correctly + var fields = new { __RequestVerificationToken = SiteState.AntiForgeryToken, operation = "validate", credential = credential, returnurl = NavigateUrl(PageState.Page.Path, "tab=Security") }; + string url = Utilities.TenantUrl(PageState.Alias, "/pages/passkey/"); + await interop.SubmitForm(url, fields); + } + else + { + await logger.LogError("Error Adding Passkey"); + AddModuleMessage(Localizer["Error.Passkey"], MessageType.Error); + } + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Adding Passkey"); + AddModuleMessage(Localizer["Error.Passkey"], MessageType.Error); + } + } + } + } + + private void EditPasskey(UserPasskey passkey) + { + _passkeyId = passkey.CredentialId; + _passkeyName = passkey.Name; + StateHasChanged(); + } + + private async Task DeletePasskey(UserPasskey passkey) + { + await UserService.DeletePasskeyAsync(passkey.CredentialId); + await GetPasskeys(); + StateHasChanged(); + } + + private async Task SavePasskey() + { + if (!string.IsNullOrEmpty(_passkeyName)) + { + await UserService.UpdatePasskeyAsync(new UserPasskey { CredentialId = _passkeyId, Name = _passkeyName }); + await GetPasskeys(); + _passkeyName = ""; + StateHasChanged(); + } + } + + private async Task CancelPasskey() + { + await GetPasskeys(); + _passkeyName = ""; + StateHasChanged(); + } + + private async Task GetLogins() + { + if (_allowexternallogin) + { + _logins = await UserService.GetLoginsAsync(); + } + } + + private async Task DeleteLogin(UserLogin login) + { + await UserService.DeleteLoginAsync(login.Provider, login.Key); + await GetLogins(); + StateHasChanged(); + } + private async Task Logout() { await logger.LogInformation("User Logout Everywhere For Username {Username}", PageState.User?.Username); @@ -630,51 +772,24 @@ } } - private async Task GetPasskeys() - { - _passkeys = await UserService.GetPasskeysAsync(); - } - - private async Task AddPasskey() - { - _passkeyName = $"{PageState.User.DisplayName}{_passkeys.Count + 1}"; // set default name - await UserService.AddPasskeyAsync(new Passkey { Name = _passkeyName, CredentialJson = "" }); - await GetPasskeys(); - StateHasChanged(); - } + // profile methods - private void EditPasskey(Passkey passkey) + private string GetProfileValue(string SettingName, string DefaultValue) { - _passkeyId = passkey.CredentialId; - _passkeyName = passkey.Name; - StateHasChanged(); - } - - private async Task DeletePasskey(Passkey passkey) - { - await UserService.DeletePasskeyAsync(passkey.CredentialId); - await GetPasskeys(); - StateHasChanged(); - } - - private async Task SavePasskey() - { - if (!string.IsNullOrEmpty(_passkeyName)) + string value = SettingService.GetSetting(_userSettings, SettingName, DefaultValue); + if (value.Contains("]")) { - await UserService.UpdatePasskeyAsync(new Passkey { CredentialId = _passkeyId, Name = _passkeyName }); - await GetPasskeys(); - _passkeyName = ""; - StateHasChanged(); + value = value.Substring(value.IndexOf("]") + 1); } + return value; } - private async Task CancelPasskey() + private void ProfileChanged(ChangeEventArgs e, string SettingName) { - await GetPasskeys(); - _passkeyName = ""; - StateHasChanged(); + var value = (string)e.Value; + _userSettings = SettingService.SetSetting(_userSettings, SettingName, value); } - + private bool ValidateProfiles() { foreach (Profile profile in _profiles) @@ -706,18 +821,22 @@ return true; } - private void Cancel() + // notification methods + + private async Task LoadNotificationsAsync() { - NavigationManager.NavigateTo(PageState.ReturnUrl); + _notifications = await NotificationService.GetNotificationsAsync(PageState.Site.SiteId, _filter, PageState.User.UserId); + _notifications = _notifications.Where(item => item.DeletedBy != PageState.User.Username).ToList(); } - private void ProfileChanged(ChangeEventArgs e, string SettingName) + private async void FilterNotifications(ChangeEventArgs e) { - var value = (string)e.Value; - _userSettings = SettingService.SetSetting(_userSettings, SettingName, value); + _filter = (string)e.Value; + await LoadNotificationsAsync(); + StateHasChanged(); } - private async Task Delete(Notification Notification) + private async Task DeleteNotification(Notification Notification) { try { @@ -742,13 +861,6 @@ } } - private async void FilterChanged(ChangeEventArgs e) - { - _filter = (string)e.Value; - await LoadNotificationsAsync(); - StateHasChanged(); - } - private async Task DeleteAllNotifications() { try @@ -780,18 +892,4 @@ HideProgressIndicator(); } } - - private void TogglePassword() - { - if (_passwordtype == "password") - { - _passwordtype = "text"; - _togglepassword = SharedLocalizer["HidePassword"]; - } - else - { - _passwordtype = "password"; - _togglepassword = SharedLocalizer["ShowPassword"]; - } - } } diff --git a/Oqtane.Client/Modules/Admin/Users/Index.razor b/Oqtane.Client/Modules/Admin/Users/Index.razor index 5e568a4a..edb519c2 100644 --- a/Oqtane.Client/Modules/Admin/Users/Index.razor +++ b/Oqtane.Client/Modules/Admin/Users/Index.razor @@ -72,32 +72,41 @@ else
- @if (_allowregistration == "true") - { -
- -
- -
-
- } -
- -
- -
-
-
- -
- -
-
@if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) { + @if (_allowregistration == "true") + { +
+ +
+ +
+
+ } +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
@@ -538,6 +547,7 @@ else private string _registerurl; private string _profileurl; private string _requireconfirmedemail; + private string _passkeys; private string _twofactor; private string _cookiename; private string _cookiedomain; @@ -609,12 +619,13 @@ else var settings = await SettingService.GetSiteSettingsAsync(PageState.Site.SiteId); _allowregistration = PageState.Site.AllowRegistration.ToString().ToLower(); - _registerurl = SettingService.GetSetting(settings, "LoginOptions:RegisterUrl", ""); - _profileurl = SettingService.GetSetting(settings, "LoginOptions:ProfileUrl", ""); - _requireconfirmedemail = SettingService.GetSetting(settings, "LoginOptions:RequireConfirmedEmail", "true"); if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) { + _registerurl = SettingService.GetSetting(settings, "LoginOptions:RegisterUrl", ""); + _profileurl = SettingService.GetSetting(settings, "LoginOptions:ProfileUrl", ""); + _requireconfirmedemail = SettingService.GetSetting(settings, "LoginOptions:RequireConfirmedEmail", "true"); + _passkeys = SettingService.GetSetting(settings, "LoginOptions:Passkeys", "false"); _twofactor = SettingService.GetSetting(settings, "LoginOptions:TwoFactor", "false"); _cookiename = SettingService.GetSetting(settings, "LoginOptions:CookieName", ".AspNetCore.Identity.Application"); _cookiedomain = SettingService.GetSetting(settings, "LoginOptions:CookieDomain", ""); @@ -753,6 +764,7 @@ else settings = SettingService.SetSetting(settings, "LoginOptions:RegisterUrl", _registerurl, false); settings = SettingService.SetSetting(settings, "LoginOptions:ProfileUrl", _profileurl, false); settings = SettingService.SetSetting(settings, "LoginOptions:RequireConfirmedEmail", _requireconfirmedemail, false); + settings = SettingService.SetSetting(settings, "LoginOptions:Passkeys", _passkeys, false); settings = SettingService.SetSetting(settings, "LoginOptions:TwoFactor", _twofactor, false); settings = SettingService.SetSetting(settings, "LoginOptions:CookieName", _cookiename, true); settings = SettingService.SetSetting(settings, "LoginOptions:CookieDomain", _cookiedomain, true); diff --git a/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx index a60a2044..84a53b4a 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Forgot Password + Forgot Password? User Account Email Address Verified Successfully. You Can Now Login With Your Username And Password. @@ -231,4 +231,7 @@ Register as new user? + + Use Passkey + \ No newline at end of file diff --git a/Oqtane.Client/Resources/Modules/Admin/UserProfile/Index.resx b/Oqtane.Client/Resources/Modules/Admin/UserProfile/Index.resx index 2a6862bb..e2a1552f 100644 --- a/Oqtane.Client/Resources/Modules/Admin/UserProfile/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/UserProfile/Index.resx @@ -168,9 +168,6 @@ Are You Sure You Wish To Delete This Notification? - - Identity - If you are changing your password you must enter it again to confirm it matches the value entered above @@ -211,7 +208,7 @@ Indicates if you are using two factor authentication. Two factor authentication requires you to enter a verification code sent via email after you sign in. - Two Factor? + Use Two Factor? Clear Notifications @@ -249,4 +246,40 @@ Your time zone + + Identity + + + Security + + + Multi-Factor Authenticationxxx + + + Passkeys + + + Logins + + + Passkey + + + Delete Passkey + + + Are You Sure You Wish To Delete {0}? + + + Login + + + Delete Login + + + Are You Sure You Wish To Delete {0}? + + + Passkeys Can Only Be Created Using a Secure Browser Connection + \ No newline at end of file diff --git a/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx index 310e6dc3..6e7fb3e0 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx @@ -561,4 +561,10 @@ Specify if users should be logged out of both the application and provider (the default is false indicating they will only be logged out of the application) + + Allow Passkeys? + + + Do you want to allow users to login using passkeys (ie. passwordless authentication using WebAuthn/FIDO2)? + \ No newline at end of file diff --git a/Oqtane.Client/Services/ServiceBase.cs b/Oqtane.Client/Services/ServiceBase.cs index f7302cb7..ec5b4422 100644 --- a/Oqtane.Client/Services/ServiceBase.cs +++ b/Oqtane.Client/Services/ServiceBase.cs @@ -3,12 +3,14 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Json; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Oqtane.Enums; using Oqtane.Models; using Oqtane.Shared; +using static System.Runtime.InteropServices.JavaScript.JSType; namespace Oqtane.Services { @@ -206,6 +208,17 @@ namespace Oqtane.Services await CheckResponse(response, uri); } + protected async Task PostStringAsync(string uri) + { + var response = await GetHttpClient().PostAsync(uri, null); + if (await CheckResponse(response, uri) && ValidateJsonContent(response.Content)) + { + var result = await response.Content.ReadAsStringAsync(); + return result; + } + return default; + } + protected async Task PostJsonAsync(string uri, T value) { return await PostJsonAsync(uri, value); diff --git a/Oqtane.Client/Services/UserService.cs b/Oqtane.Client/Services/UserService.cs index 6d249b5a..1086348b 100644 --- a/Oqtane.Client/Services/UserService.cs +++ b/Oqtane.Client/Services/UserService.cs @@ -1,11 +1,12 @@ -using Oqtane.Shared; -using Oqtane.Models; +using System.Buffers.Text; +using System.Collections.Generic; +using System.Net; using System.Net.Http; using System.Threading.Tasks; -using Oqtane.Documentation; -using System.Net; -using System.Collections.Generic; using Microsoft.Extensions.Localization; +using Oqtane.Documentation; +using Oqtane.Models; +using Oqtane.Shared; namespace Oqtane.Services { @@ -177,21 +178,14 @@ namespace Oqtane.Services /// Get passkeys for a user /// /// - Task> GetPasskeysAsync(); - - /// - /// Add a user passkey - /// - /// - /// - Task AddPasskeyAsync(Passkey passkey); + Task> GetPasskeysAsync(); /// /// Update a user passkey /// /// /// - Task UpdatePasskeyAsync(Passkey passkey); + Task UpdatePasskeyAsync(UserPasskey passkey); /// /// Delete a user passkey @@ -199,6 +193,20 @@ namespace Oqtane.Services /// /// Task DeletePasskeyAsync(byte[] credentialId); + + /// + /// Get logins for a user + /// + /// + Task> GetLoginsAsync(); + + /// + /// Delete a user login + /// + /// + /// + /// + Task DeleteLoginAsync(string provider, string key); } [PrivateApi("Don't show in the documentation, as everything should use the Interface")] @@ -330,24 +338,29 @@ namespace Oqtane.Services return await PostJsonAsync>($"{Apiurl}/import?siteid={siteId}&fileid={fileId}¬ify={notify}", null); } - public async Task> GetPasskeysAsync() + public async Task> GetPasskeysAsync() { - return await GetJsonAsync>($"{Apiurl}/passkey"); + return await GetJsonAsync>($"{Apiurl}/passkey"); } - public async Task AddPasskeyAsync(Passkey passkey) + public async Task UpdatePasskeyAsync(UserPasskey passkey) { - return await PostJsonAsync($"{Apiurl}/passkey", passkey); - } - - public async Task UpdatePasskeyAsync(Passkey passkey) - { - return await PutJsonAsync($"{Apiurl}/passkey", passkey); + return await PutJsonAsync($"{Apiurl}/passkey", passkey); } public async Task DeletePasskeyAsync(byte[] credentialId) { - await DeleteAsync($"{Apiurl}/passkey?id={credentialId}"); + await DeleteAsync($"{Apiurl}/passkey?id={Base64Url.EncodeToString(credentialId)}"); + } + + public async Task> GetLoginsAsync() + { + return await GetJsonAsync>($"{Apiurl}/login"); + } + + public async Task DeleteLoginAsync(string provider, string key) + { + await DeleteAsync($"{Apiurl}/login?provider={provider}&key={key}"); } } } diff --git a/Oqtane.Client/UI/Interop.cs b/Oqtane.Client/UI/Interop.cs index 3a56783f..c8d09e33 100644 --- a/Oqtane.Client/UI/Interop.cs +++ b/Oqtane.Client/UI/Interop.cs @@ -417,5 +417,30 @@ namespace Oqtane.UI return Task.CompletedTask; } } + + public ValueTask CreateCredential(string optionsResponse) + { + try + { + return _jsRuntime.InvokeAsync("Oqtane.Interop.createCredential", optionsResponse); + } + catch + { + return new ValueTask(Task.FromResult(string.Empty)); + } + } + + public ValueTask RequestCredential(string optionsResponse) + { + try + { + return _jsRuntime.InvokeAsync("Oqtane.Interop.requestCredential", optionsResponse); + } + catch + { + return new ValueTask(Task.FromResult(string.Empty)); + } + } + } } diff --git a/Oqtane.Server/Controllers/UserController.cs b/Oqtane.Server/Controllers/UserController.cs index 2141acc6..373a354a 100644 --- a/Oqtane.Server/Controllers/UserController.cs +++ b/Oqtane.Server/Controllers/UserController.cs @@ -1,19 +1,21 @@ -using Microsoft.AspNetCore.Mvc; +using System.Buffers.Text; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Security.Claims; +using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; -using Oqtane.Models; -using System.Threading.Tasks; -using System.Linq; -using System.Security.Claims; -using Oqtane.Shared; -using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Oqtane.Enums; +using Oqtane.Extensions; using Oqtane.Infrastructure; +using Oqtane.Managers; +using Oqtane.Models; using Oqtane.Repository; using Oqtane.Security; -using Oqtane.Extensions; -using Oqtane.Managers; -using System.Collections.Generic; +using Oqtane.Shared; namespace Oqtane.Controllers { @@ -467,32 +469,15 @@ namespace Oqtane.Controllers // GET: api//passkey [HttpGet("passkey")] [Authorize] - public async Task> GetPasskeys() + public async Task> GetPasskeys() { return await _userManager.GetPasskeys(_userPermissions.GetUser(User).UserId); } - // POST api//passkey - [HttpPost("passkey")] - [Authorize] - public async Task AddPasskey([FromBody] Passkey passkey) - { - if (ModelState.IsValid) - { - passkey.UserId = _userPermissions.GetUser(User).UserId; - await _userManager.AddPasskey(passkey); - } - else - { - _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized User Passkey Post Attempt {PassKey}", passkey); - HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; - } - } - // PUT api//passkey [HttpPut("passkey")] [Authorize] - public async Task UpdatePasskey([FromBody] Passkey passkey) + public async Task UpdatePasskey([FromBody] UserPasskey passkey) { if (ModelState.IsValid) { @@ -509,9 +494,25 @@ namespace Oqtane.Controllers // DELETE api//passkey?id=x [HttpDelete("passkey")] [Authorize] - public async Task DeletePasskey(byte[] id) + public async Task DeletePasskey(string id) { - await _userManager.DeletePasskey(_userPermissions.GetUser(User).UserId, id); + await _userManager.DeletePasskey(_userPermissions.GetUser(User).UserId, Base64Url.DecodeFromChars(id)); + } + + // GET: api//login + [HttpGet("login")] + [Authorize] + public async Task> GetLogins() + { + return await _userManager.GetLogins(_userPermissions.GetUser(User).UserId); + } + + // DELETE api//login?provider=x&key=y + [HttpDelete("login")] + [Authorize] + public async Task DeleteLogin(string provider, string key) + { + await _userManager.DeleteLogin(_userPermissions.GetUser(User).UserId, provider, key); } } } diff --git a/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs b/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs index 115abee0..e72ddc4e 100644 --- a/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs +++ b/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs @@ -102,7 +102,8 @@ namespace Microsoft.Extensions.DependencyInjection }) .AddCookie(Constants.AuthenticationScheme) .AddOpenIdConnect(AuthenticationProviderTypes.OpenIDConnect, options => { }) - .AddOAuth(AuthenticationProviderTypes.OAuth2, options => { }); + .AddOAuth(AuthenticationProviderTypes.OAuth2, options => { }) + .AddTwoFactorUserIdCookie(); services.ConfigureOqtaneCookieOptions(); services.ConfigureOqtaneAuthenticationOptions(configuration); diff --git a/Oqtane.Server/Managers/UserManager.cs b/Oqtane.Server/Managers/UserManager.cs index 8c4bf457..3c28081d 100644 --- a/Oqtane.Server/Managers/UserManager.cs +++ b/Oqtane.Server/Managers/UserManager.cs @@ -4,9 +4,9 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net; -using System.Security.Policy; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Localization; using Oqtane.Enums; @@ -36,10 +36,11 @@ namespace Oqtane.Managers Task ValidateUser(string username, string email, string password); Task ValidatePassword(string password); Task> ImportUsers(int siteId, string filePath, bool notify); - Task> GetPasskeys(int userId); - Task AddPasskey(Passkey passkey); - Task UpdatePasskey(Passkey passkey); + Task> GetPasskeys(int userId); + Task UpdatePasskey(UserPasskey passkey); Task DeletePasskey(int userId, byte[] credentialId); + Task> GetLogins(int userId); + Task DeleteLogin(int userId, string provider, string key); } public class UserManager : IUserManager @@ -824,9 +825,9 @@ namespace Oqtane.Managers return result; } - public async Task> GetPasskeys(int userId) + public async Task> GetPasskeys(int userId) { - var passkeys = new List(); + var passkeys = new List(); var user = _users.GetUser(userId); if (user != null) { @@ -836,31 +837,14 @@ namespace Oqtane.Managers var userpasskeys = await _identityUserManager.GetPasskeysAsync(identityuser); foreach (var userpasskey in userpasskeys) { - passkeys.Add(new Passkey { CredentialId = userpasskey.CredentialId, Name = userpasskey.Name, UserId = userId }); + passkeys.Add(new UserPasskey { CredentialId = userpasskey.CredentialId, Name = userpasskey.Name, UserId = userId }); } } } return passkeys; } - public async Task AddPasskey(Passkey passkey) - { - var user = _users.GetUser(passkey.UserId); - if (user != null) - { - var identityuser = await _identityUserManager.FindByNameAsync(user.Username); - if (identityuser != null) - { - var attestationResult = await _identitySignInManager.PerformPasskeyAttestationAsync(passkey.CredentialJson); - if (attestationResult.Succeeded) - { - var addPasskeyResult = await _identityUserManager.AddOrUpdatePasskeyAsync(identityuser, attestationResult.Passkey); - } - } - } - } - - public async Task UpdatePasskey(Passkey passkey) + public async Task UpdatePasskey(UserPasskey passkey) { var user = _users.GetUser(passkey.UserId); if (user != null) @@ -890,5 +874,37 @@ namespace Oqtane.Managers } } } + + public async Task> GetLogins(int userId) + { + var logins = new List(); + var user = _users.GetUser(userId); + if (user != null) + { + var identityuser = await _identityUserManager.FindByNameAsync(user.Username); + if (identityuser != null) + { + var userlogins = await _identityUserManager.GetLoginsAsync(identityuser); + foreach (var userlogin in userlogins) + { + logins.Add(new UserLogin { Provider = userlogin.LoginProvider, Key = userlogin.ProviderKey, Name = userlogin.ProviderDisplayName }); + } + } + } + return logins; + } + + public async Task DeleteLogin(int userId, string provider, string key) + { + var user = _users.GetUser(userId); + if (user != null) + { + var identityuser = await _identityUserManager.FindByNameAsync(user.Username); + if (identityuser != null) + { + await _identityUserManager.RemoveLoginAsync(identityuser, provider, key); + } + } + } } } diff --git a/Oqtane.Server/Pages/Passkey.cshtml b/Oqtane.Server/Pages/Passkey.cshtml new file mode 100644 index 00000000..43bac572 --- /dev/null +++ b/Oqtane.Server/Pages/Passkey.cshtml @@ -0,0 +1,3 @@ +@page "/pages/passkey" +@namespace Oqtane.Pages +@model Oqtane.Pages.PasskeyModel diff --git a/Oqtane.Server/Pages/Passkey.cshtml.cs b/Oqtane.Server/Pages/Passkey.cshtml.cs new file mode 100644 index 00000000..ad6a35d3 --- /dev/null +++ b/Oqtane.Server/Pages/Passkey.cshtml.cs @@ -0,0 +1,144 @@ +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Oqtane.Enums; +using Oqtane.Extensions; +using Oqtane.Infrastructure; +using Oqtane.Managers; +using Oqtane.Security; +using Oqtane.Shared; + +namespace Oqtane.Pages +{ + [AllowAnonymous] + public class PasskeyModel : PageModel + { + private readonly UserManager _identityUserManager; + private readonly SignInManager _identitySignInManager; + private readonly IUserManager _userManager; + private readonly ILogManager _logger; + + public PasskeyModel(UserManager identityUserManager, SignInManager identitySignInManager, IUserManager userManager, ILogManager logger) + { + _identityUserManager = identityUserManager; + _identitySignInManager = identitySignInManager; + _userManager = userManager; + _logger = logger; + } + + public async Task OnPostAsync(string operation, string credential, string returnurl) + { + if (HttpContext.GetSiteSettings().GetValue("LoginOptions:Passkeys", "false") == "true") + { + IdentityUser identityuser; + + switch (operation.ToLower()) + { + case "create": + if (User.Identity.IsAuthenticated) + { + identityuser = await _identityUserManager.FindByNameAsync(User.Identity.Name); + if (identityuser != null) + { + var creationOptionsJson = await _identitySignInManager.MakePasskeyCreationOptionsAsync(new() + { + Id = identityuser.Id, + Name = identityuser.UserName, + DisplayName = identityuser.UserName + }); + returnurl += $"?options={WebUtility.UrlEncode(creationOptionsJson)}"; + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Passkey Create Attempt - User {User} Does Not Exist", User.Identity.Name); + } + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Passkey Create Attempt - User Not Authenticated"); + } + break; + case "validate": + if (User.Identity.IsAuthenticated && !string.IsNullOrEmpty(credential)) + { + identityuser = await _identityUserManager.FindByNameAsync(User.Identity.Name); + if (identityuser != null) + { + var attestationResult = await _identitySignInManager.PerformPasskeyAttestationAsync(credential); + if (attestationResult.Succeeded) + { + attestationResult.Passkey.Name = identityuser.UserName + "'s Passkey"; + var addPasskeyResult = await _identityUserManager.AddOrUpdatePasskeyAsync(identityuser, attestationResult.Passkey); + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Passkey Validation Failed For User {Username} - {Message}", User.Identity.Name, attestationResult.Failure.Message); + } + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Passkey Validation Attempt - User {User} Does Not Exist", User.Identity.Name); + } + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Passkey Validation Attempt - User Not Authenticated Or Credential Not Provided"); + } + break; + case "request": + if (!User.Identity.IsAuthenticated) + { + identityuser = null; + var requestOptionsJson = await _identitySignInManager.MakePasskeyRequestOptionsAsync(identityuser); + returnurl += $"?options={WebUtility.UrlEncode(requestOptionsJson)}"; + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Passkey Request Attempt - User Is Already Authenticated"); + } + break; + case "login": + if (!User.Identity.IsAuthenticated && !string.IsNullOrEmpty(credential)) + { + var result = await _identitySignInManager.PasskeySignInAsync(credential); + if (result.Succeeded) + { + var user = _userManager.GetUser(User.Identity.Name, HttpContext.GetAlias().SiteId); + if (user != null && !user.IsDeleted && UserSecurity.ContainsRole(user.Roles, RoleNames.Registered)) + { + _logger.Log(LogLevel.Information, this, LogFunction.Security, "Passkey Login Successful For User {Username}", User.Identity.Name); + } + else + { + _logger.Log(LogLevel.Information, this, LogFunction.Security, "Passkey Login Failed For User {Username}", User.Identity.Name); + } + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Passkey Login Failed - Invalid Credential"); + } + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Passkey Login Attempt"); + } + break; + } + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Passkey Request - Passkeys Are Not Enabled For Site"); + } + + if (!returnurl.StartsWith("/")) + { + returnurl = "/" + returnurl; + } + + return LocalRedirect(Url.Content("~" + returnurl)); + } + } +} diff --git a/Oqtane.Server/wwwroot/Modules/Oqtane.Modules.Admin.Login/Module.css b/Oqtane.Server/wwwroot/Modules/Oqtane.Modules.Admin.Login/Module.css index 086b246b..38c1a2c9 100644 --- a/Oqtane.Server/wwwroot/Modules/Oqtane.Modules.Admin.Login/Module.css +++ b/Oqtane.Server/wwwroot/Modules/Oqtane.Modules.Admin.Login/Module.css @@ -1,5 +1,5 @@ /* Login Module Custom Styles */ .Oqtane-Modules-Admin-Login { - width: 200px; + width: 220px; } diff --git a/Oqtane.Server/wwwroot/js/interop.js b/Oqtane.Server/wwwroot/js/interop.js index fecc4c99..ab29231b 100644 --- a/Oqtane.Server/wwwroot/js/interop.js +++ b/Oqtane.Server/wwwroot/js/interop.js @@ -516,5 +516,17 @@ Oqtane.Interop = { } } } + }, + createCredential: async function (optionsResponse) { + const optionsJson = JSON.parse(optionsResponse); + const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson); + const credential = await navigator.credentials.create({ publicKey: options }); + return JSON.stringify(credential); + }, + requestCredential: async function (optionsResponse) { + const optionsJson = JSON.parse(optionsResponse); + const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson); + const credential = await navigator.credentials.get({ publicKey: options, undefined }); + return JSON.stringify(credential); } }; diff --git a/Oqtane.Shared/Models/UserLogin.cs b/Oqtane.Shared/Models/UserLogin.cs new file mode 100644 index 00000000..d22311df --- /dev/null +++ b/Oqtane.Shared/Models/UserLogin.cs @@ -0,0 +1,23 @@ +namespace Oqtane.Models +{ + /// + /// Passkey properties + /// + public class UserLogin + { + /// + /// the login provider for this login + /// + public string Provider { get; set; } + + /// + /// The key for this login + /// + public string Key { get; set; } + + /// + /// The friendly name for the login provider + /// + public string Name { get; set; } + } +} diff --git a/Oqtane.Shared/Models/Passkey.cs b/Oqtane.Shared/Models/UserPasskey.cs similarity index 96% rename from Oqtane.Shared/Models/Passkey.cs rename to Oqtane.Shared/Models/UserPasskey.cs index 31671890..9ee69221 100644 --- a/Oqtane.Shared/Models/Passkey.cs +++ b/Oqtane.Shared/Models/UserPasskey.cs @@ -3,7 +3,7 @@ namespace Oqtane.Models /// /// Passkey properties /// - public class Passkey + public class UserPasskey { /// /// the credential ID for this passkey