@@ -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);