diff --git a/Oqtane.Client/Modules/Admin/Login/Index.razor b/Oqtane.Client/Modules/Admin/Login/Index.razor index 02d45a3d..ea7f0b04 100644 --- a/Oqtane.Client/Modules/Admin/Login/Index.razor +++ b/Oqtane.Client/Modules/Admin/Login/Index.razor @@ -143,15 +143,15 @@ else if (PageState.QueryString.ContainsKey("key")) { - user = await UserService.LinkUserAsync(user, PageState.QueryString["token"], PageState.Site.Settings["ExternalLogin:ProviderType"], PageState.QueryString["key"], PageState.Site.Settings["ExternalLogin:ProviderName"]); + user = await UserService.AddLoginAsync(user, PageState.QueryString["token"], PageState.Site.Settings["ExternalLogin:ProviderType"], PageState.QueryString["key"], PageState.Site.Settings["ExternalLogin:ProviderName"]); if (user != null) { - await logger.LogInformation(LogFunction.Security, "External Login Linkage Successful For Username {Username}", _username); + await logger.LogInformation(LogFunction.Security, "User Login Linkage Successful For Username {Username}", _username); AddModuleMessage(Localizer["Success.Account.Linked"], MessageType.Info); } else { - await logger.LogError(LogFunction.Security, "External Login Linkage Failed For Username {Username}", _username); + await logger.LogError(LogFunction.Security, "User Login Linkage Failed For Username {Username}", _username); AddModuleMessage(Localizer["Message.Account.NotLinked"], MessageType.Warning); } _username = ""; diff --git a/Oqtane.Client/Modules/Admin/UserProfile/Index.razor b/Oqtane.Client/Modules/Admin/UserProfile/Index.razor index 6e9a9efe..c8113d11 100644 --- a/Oqtane.Client/Modules/Admin/UserProfile/Index.razor +++ b/Oqtane.Client/Modules/Admin/UserProfile/Index.razor @@ -654,7 +654,7 @@ { if (_allowpasskeys) { - _passkeys = await UserService.GetPasskeysAsync(); + _passkeys = await UserService.GetPasskeysAsync(PageState.User.UserId); } } @@ -709,7 +709,7 @@ private async Task DeletePasskey(UserPasskey passkey) { - await UserService.DeletePasskeyAsync(passkey.CredentialId); + await UserService.DeletePasskeyAsync(PageState.User.UserId, passkey.CredentialId); await GetPasskeys(); StateHasChanged(); } @@ -718,7 +718,7 @@ { if (!string.IsNullOrEmpty(_passkeyName)) { - await UserService.UpdatePasskeyAsync(new UserPasskey { CredentialId = _passkeyId, Name = _passkeyName }); + await UserService.UpdatePasskeyAsync(new UserPasskey { CredentialId = _passkeyId, Name = _passkeyName, UserId = PageState.User.UserId }); await GetPasskeys(); _passkeyName = ""; StateHasChanged(); @@ -736,13 +736,13 @@ { if (_allowexternallogin) { - _logins = await UserService.GetLoginsAsync(); + _logins = await UserService.GetLoginsAsync(PageState.User.UserId); } } private async Task DeleteLogin(UserLogin login) { - await UserService.DeleteLoginAsync(login.Provider, login.Key); + await UserService.DeleteLoginAsync(PageState.User.UserId, login.Provider, login.Key); await GetLogins(); StateHasChanged(); } diff --git a/Oqtane.Client/Modules/Admin/Users/Edit.razor b/Oqtane.Client/Modules/Admin/Users/Edit.razor index 98f29c2c..09199202 100644 --- a/Oqtane.Client/Modules/Admin/Users/Edit.razor +++ b/Oqtane.Client/Modules/Admin/Users/Edit.razor @@ -103,6 +103,53 @@ +

+ @if (_allowpasskeys) + { +
+ @if (_passkeys != null && _passkeys.Count > 0) + { + +
+   + @Localizer["Passkey"] +
+ + + @context.Name + +
+ } + else + { +
@Localizer["Message.Passkeys.None"]
+ } +
+
+ } + @if (_allowexternallogin) + { +
+ @if (_logins != null && _logins.Count > 0) + { + +
+   + @Localizer["Login"] +
+ + + @context.Name + +
+ } + else + { +
@Localizer["Message.Logins.None"]
+ } +
+
+ }
@@ -173,24 +220,30 @@ } @code { - private List _timezones; private bool _initialized = false; - private string _passwordrequirements; + private bool _allowpasskeys = false; + private bool _allowexternallogin = false; + private int _userid; 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 string _email = string.Empty; private string _confirmed = string.Empty; private string _displayname = string.Empty; + private List _timezones; private string _timezoneid = string.Empty; private string _isdeleted; private string _lastlogin; private string _lastipaddress; private bool _ishost = false; + private string _passwordrequirements; + private string _password = string.Empty; + private string _passwordtype = "password"; + private string _togglepassword = string.Empty; + private string _confirm = string.Empty; + private List _passkeys; + private List _logins; + private List _profiles; private Dictionary _settings; private string _category = string.Empty; @@ -208,19 +261,8 @@ { try { - _passwordrequirements = await UserService.GetPasswordRequirementsAsync(PageState.Site.SiteId); - _togglepassword = SharedLocalizer["ShowPassword"]; - _profiles = await ProfileService.GetProfilesAsync(PageState.Site.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.QueryString.ContainsKey("id") && int.TryParse(PageState.QueryString["id"], out int UserId)) { @@ -232,13 +274,30 @@ _email = user.Email; _confirmed = user.EmailConfirmed.ToString(); _displayname = user.DisplayName; + _timezones = TimeZoneService.GetTimeZones(); _timezoneid = PageState.User.TimeZoneId; _isdeleted = user.IsDeleted.ToString(); _lastlogin = string.Format("{0:MMM dd yyyy HH:mm:ss}", UtcToLocal(user.LastLoginOn)); _lastipaddress = user.LastIPAddress; _ishost = UserSecurity.ContainsRole(user.Roles, RoleNames.Host); - _settings = user.Settings; + _passwordrequirements = await UserService.GetPasswordRequirementsAsync(PageState.Site.SiteId); + _togglepassword = SharedLocalizer["ShowPassword"]; + await GetPasskeys(); + await GetLogins(); + + _profiles = await ProfileService.GetProfilesAsync(PageState.Site.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}")); + } + } + _settings = user.Settings; + _createdby = user.CreatedBy; _createdon = user.CreatedOn; _modifiedby = user.ModifiedBy; @@ -358,6 +417,35 @@ } } + private async Task GetPasskeys() + { + if (_allowpasskeys) + { + _passkeys = await UserService.GetPasskeysAsync(_userid); + } + } + private async Task DeletePasskey(UserPasskey passkey) + { + await UserService.DeletePasskeyAsync(_userid, passkey.CredentialId); + await GetPasskeys(); + StateHasChanged(); + } + + private async Task GetLogins() + { + if (_allowexternallogin) + { + _logins = await UserService.GetLoginsAsync(_userid); + } + } + + private async Task DeleteLogin(UserLogin login) + { + await UserService.DeleteLoginAsync(_userid, login.Provider, login.Key); + await GetLogins(); + StateHasChanged(); + } + private bool ValidateProfiles() { foreach (Profile profile in _profiles) diff --git a/Oqtane.Client/Resources/Modules/Admin/Users/Edit.resx b/Oqtane.Client/Resources/Modules/Admin/Users/Edit.resx index 0172f599..284dd365 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Users/Edit.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Users/Edit.resx @@ -222,4 +222,34 @@ Security + + Passkeys + + + Logins + + + Passkey + + + Login + + + Delete Passkey + + + Delete Login + + + Are You Sure You Wish To Delete {0}? + + + Are You Sure You Wish To Delete {0}? + + + You Have Not Created Any Passkeys + + + You Do Not Have Any External Logins For This Site + \ No newline at end of file diff --git a/Oqtane.Client/Services/UserService.cs b/Oqtane.Client/Services/UserService.cs index 1086348b..12685add 100644 --- a/Oqtane.Client/Services/UserService.cs +++ b/Oqtane.Client/Services/UserService.cs @@ -147,17 +147,6 @@ namespace Oqtane.Services /// Task GetPersonalAccessTokenAsync(); - /// - /// Link an external login with a local user account - /// - /// The we're verifying - /// A Hash value in the URL which verifies this user got the e-mail (containing this token) - /// External Login provider type - /// External Login provider key - /// External Login provider display name - /// - Task LinkUserAsync(User user, string token, string type, string key, string name); - /// /// Get password requirements for site /// @@ -177,8 +166,9 @@ namespace Oqtane.Services /// /// Get passkeys for a user /// + /// /// - Task> GetPasskeysAsync(); + Task> GetPasskeysAsync(int userId); /// /// Update a user passkey @@ -190,23 +180,37 @@ namespace Oqtane.Services /// /// Delete a user passkey /// + /// /// /// - Task DeletePasskeyAsync(byte[] credentialId); + Task DeletePasskeyAsync(int userId, byte[] credentialId); /// /// Get logins for a user /// + /// /// - Task> GetLoginsAsync(); + Task> GetLoginsAsync(int userId); + + /// + /// Link an external login with a local user account + /// + /// The we're verifying + /// A Hash value in the URL which verifies this user got the e-mail (containing this token) + /// External Login provider type + /// External Login provider key + /// External Login provider display name + /// + Task AddLoginAsync(User user, string token, string type, string key, string name); /// /// Delete a user login /// + /// /// /// /// - Task DeleteLoginAsync(string provider, string key); + Task DeleteLoginAsync(int userId, string provider, string key); } [PrivateApi("Don't show in the documentation, as everything should use the Interface")] @@ -253,7 +257,7 @@ namespace Oqtane.Services public async Task LoginUserAsync(User user, bool setCookie, bool isPersistent) { - return await PostJsonAsync($"{Apiurl}/login?setcookie={setCookie}&persistent={isPersistent}", user); + return await PostJsonAsync($"{Apiurl}/signin?setcookie={setCookie}&persistent={isPersistent}", user); } public async Task LogoutUserAsync(User user) @@ -306,11 +310,6 @@ namespace Oqtane.Services return await GetStringAsync($"{Apiurl}/personalaccesstoken"); } - public async Task LinkUserAsync(User user, string token, string type, string key, string name) - { - return await PostJsonAsync($"{Apiurl}/link?token={token}&type={type}&key={key}&name={name}", user); - } - public async Task GetPasswordRequirementsAsync(int siteId) { var requirements = await GetJsonAsync>($"{Apiurl}/passwordrequirements/{siteId}"); @@ -338,9 +337,9 @@ namespace Oqtane.Services return await PostJsonAsync>($"{Apiurl}/import?siteid={siteId}&fileid={fileId}¬ify={notify}", null); } - public async Task> GetPasskeysAsync() + public async Task> GetPasskeysAsync(int userId) { - return await GetJsonAsync>($"{Apiurl}/passkey"); + return await GetJsonAsync>($"{Apiurl}/passkey?id={userId}"); } public async Task UpdatePasskeyAsync(UserPasskey passkey) @@ -348,19 +347,24 @@ namespace Oqtane.Services return await PutJsonAsync($"{Apiurl}/passkey", passkey); } - public async Task DeletePasskeyAsync(byte[] credentialId) + public async Task DeletePasskeyAsync(int userId, byte[] credentialId) { - await DeleteAsync($"{Apiurl}/passkey?id={Base64Url.EncodeToString(credentialId)}"); + await DeleteAsync($"{Apiurl}/passkey?id={userId}&credential={Base64Url.EncodeToString(credentialId)}"); } - public async Task> GetLoginsAsync() + public async Task> GetLoginsAsync(int userId) { - return await GetJsonAsync>($"{Apiurl}/login"); + return await GetJsonAsync>($"{Apiurl}/login?id={userId}"); } - public async Task DeleteLoginAsync(string provider, string key) + public async Task AddLoginAsync(User user, string token, string type, string key, string name) { - await DeleteAsync($"{Apiurl}/login?provider={provider}&key={key}"); + return await PostJsonAsync($"{Apiurl}/login?token={token}&type={type}&key={key}&name={name}", user); + } + + public async Task DeleteLoginAsync(int userId, string provider, string key) + { + await DeleteAsync($"{Apiurl}/login?id={userId}&provider={provider}&key={key}"); } } } diff --git a/Oqtane.Server/Controllers/UserController.cs b/Oqtane.Server/Controllers/UserController.cs index 875e6e12..2dce72a3 100644 --- a/Oqtane.Server/Controllers/UserController.cs +++ b/Oqtane.Server/Controllers/UserController.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; using System.Security.Claims; +using System.Security.Policy; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; @@ -240,8 +241,8 @@ namespace Oqtane.Controllers } } - // POST api//login - [HttpPost("login")] + // POST api//signin + [HttpPost("signin")] public async Task Login([FromBody] User user, bool setCookie, bool isPersistent) { if (ModelState.IsValid) @@ -330,22 +331,6 @@ namespace Oqtane.Controllers return user; } - // POST api//link - [HttpPost("link")] - public async Task Link([FromBody] User user, string token, string type, string key, string name) - { - if (ModelState.IsValid) - { - user = await _userManager.LinkExternalAccount(user, token, type, key, name); - } - else - { - _logger.Log(LogLevel.Error, this, LogFunction.Security, "External Login Linkage Failed For {Username} And Token {Token}", user.Username, token); - user = null; - } - return user; - } - // GET api//validate/x [HttpGet("validateuser")] public async Task ValidateUser(string username, string email, string password) @@ -466,12 +451,21 @@ namespace Oqtane.Controllers } } - // GET: api//passkey + // GET: api//passkey?id=x [HttpGet("passkey")] [Authorize] - public async Task> GetPasskeys() + public async Task> GetPasskeys(int id) { - return await _userManager.GetPasskeys(_userPermissions.GetUser(User).UserId, _tenantManager.GetAlias().SiteId); + if (_userPermissions.IsAuthorized(User, _tenantManager.GetAlias().SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin) || _userPermissions.GetUser(User).UserId == id) + { + return await _userManager.GetPasskeys(id, _tenantManager.GetAlias().SiteId); + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized User Passkey Get Attempt {UserId} {SiteId}", id, _tenantManager.GetAlias().SiteId); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + return null; + } } // PUT api//passkey @@ -481,10 +475,17 @@ namespace Oqtane.Controllers { if (ModelState.IsValid) { - // passkey name is prefixed with SiteId for multi-tenancy - passkey.Name = $"{_tenantManager.GetAlias().SiteId}:" + passkey.Name; - passkey.UserId = _userPermissions.GetUser(User).UserId; - await _userManager.UpdatePasskey(passkey); + if (_userPermissions.IsAuthorized(User, _tenantManager.GetAlias().SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin) || _userPermissions.GetUser(User).UserId == passkey.UserId) + { + // passkey name is prefixed with SiteId for multi-tenancy + passkey.Name = $"{_tenantManager.GetAlias().SiteId}:" + passkey.Name; + await _userManager.UpdatePasskey(passkey); + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized User Passkey Put Attempt {PassKey}", passkey); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + } } else { @@ -493,28 +494,70 @@ namespace Oqtane.Controllers } } - // DELETE api//passkey?id=x + // DELETE api//passkey?id=x&credential=y [HttpDelete("passkey")] [Authorize] - public async Task DeletePasskey(string id) + public async Task DeletePasskey(int id, string credential) { - await _userManager.DeletePasskey(_userPermissions.GetUser(User).UserId, Base64Url.DecodeFromChars(id)); + if (_userPermissions.IsAuthorized(User, _tenantManager.GetAlias().SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin) || _userPermissions.GetUser(User).UserId == id) + { + await _userManager.DeletePasskey(id, Base64Url.DecodeFromChars(credential)); + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized User Passkey Delete Attempt {UserId} {Credential}", id, credential); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + } } - // GET: api//login + // GET: api//login?id=x [HttpGet("login")] [Authorize] - public async Task> GetLogins() + public async Task> GetLogins(int id) { - return await _userManager.GetLogins(_userPermissions.GetUser(User).UserId, _tenantManager.GetAlias().SiteId); + if (_userPermissions.IsAuthorized(User, _tenantManager.GetAlias().SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin) || _userPermissions.GetUser(User).UserId == id) + { + return await _userManager.GetLogins(id, _tenantManager.GetAlias().SiteId); + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized User Login Get Attempt {UserId} {SiteId}", id, _tenantManager.GetAlias().SiteId); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + return null; + } } - // DELETE api//login?provider=x&key=y + // PUT api//login + [HttpPost("login")] + public async Task AddLogin([FromBody] User user, string token, string type, string key, string name) + { + if (ModelState.IsValid) + { + user = await _userManager.AddLogin(user, token, type, key, name); + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized User Login Post Attempt {Username} {Token}", user.Username, token); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + user = null; + } + return user; + } + + // DELETE api//login?id=x&provider=y&key=z [HttpDelete("login")] [Authorize] - public async Task DeleteLogin(string provider, string key) + public async Task DeleteLogin(int id, string provider, string key) { - await _userManager.DeleteLogin(_userPermissions.GetUser(User).UserId, provider, key); + if (_userPermissions.IsAuthorized(User, _tenantManager.GetAlias().SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin) || _userPermissions.GetUser(User).UserId == id) + { + await _userManager.DeleteLogin(id, provider, key); + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized User Login Delete Attempt {UserId} {Provider} {Key}", id, provider, key); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + } } } } diff --git a/Oqtane.Server/Managers/UserManager.cs b/Oqtane.Server/Managers/UserManager.cs index b56a606d..4e95d5f6 100644 --- a/Oqtane.Server/Managers/UserManager.cs +++ b/Oqtane.Server/Managers/UserManager.cs @@ -33,7 +33,6 @@ namespace Oqtane.Managers Task ForgotPassword(User user); Task ResetPassword(User user, string token); User VerifyTwoFactor(User user, string token); - Task LinkExternalAccount(User user, string token, string type, string key, string name); Task ValidateUser(string username, string email, string password); Task ValidatePassword(string password); Task> ImportUsers(int siteId, string filePath, bool notify); @@ -41,6 +40,7 @@ namespace Oqtane.Managers Task UpdatePasskey(UserPasskey passkey); Task DeletePasskey(int userId, byte[] credentialId); Task> GetLogins(int userId, int siteId); + Task AddLogin(User user, string token, string type, string key, string name); Task DeleteLogin(int userId, string provider, string key); } @@ -588,29 +588,6 @@ namespace Oqtane.Managers } return user; } - - public async Task LinkExternalAccount(User user, string token, string type, string key, string name) - { - IdentityUser identityuser = await _identityUserManager.FindByNameAsync(user.Username); - if (identityuser != null && !string.IsNullOrEmpty(token)) - { - var result = await _identityUserManager.ConfirmEmailAsync(identityuser, token); - if (result.Succeeded) - { - // make LoginProvider multi-tenant aware - type += ":" + user.SiteId.ToString(); - await _identityUserManager.AddLoginAsync(identityuser, new UserLoginInfo(type, key, name)); - _logger.Log(LogLevel.Information, this, LogFunction.Security, "External Login Linkage Successful For {Username} And Provider {Provider}", user.Username, type); - } - else - { - _logger.Log(LogLevel.Error, this, LogFunction.Security, "External Login Linkage Failed For {Username} - Error {Error}", user.Username, string.Join(" ", result.Errors.ToList().Select(e => e.Description))); - user = null; - } - } - return user; - } - public async Task ValidateUser(string username, string email, string password) { var validateResult = new UserValidateResult { Succeeded = true }; @@ -902,6 +879,29 @@ namespace Oqtane.Managers return logins; } + public async Task AddLogin(User user, string token, string type, string key, string name) + { + IdentityUser identityuser = await _identityUserManager.FindByNameAsync(user.Username); + if (identityuser != null && !string.IsNullOrEmpty(token)) + { + var result = await _identityUserManager.ConfirmEmailAsync(identityuser, token); + if (result.Succeeded) + { + // make LoginProvider multi-tenant aware + type += ":" + user.SiteId.ToString(); + await _identityUserManager.AddLoginAsync(identityuser, new UserLoginInfo(type, key, name)); + _logger.Log(LogLevel.Information, this, LogFunction.Security, "External Login Linkage Successful For {Username} And Provider {Provider}", user.Username, type); + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "External Login Linkage Failed For {Username} - Error {Error}", user.Username, string.Join(" ", result.Errors.ToList().Select(e => e.Description))); + user = null; + } + } + return user; + } + + public async Task DeleteLogin(int userId, string provider, string key) { var user = _users.GetUser(userId);