Merge remote-tracking branch 'origin/netcore/netcore' into netcore/feature/linux-paths
# Conflicts: # src/Umbraco.Web.BackOffice/Security/AutoLinkSignInResult.cs
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
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.Identity;
|
||||
@@ -22,7 +21,6 @@ using Umbraco.Core.Services;
|
||||
using Umbraco.Extensions;
|
||||
using Umbraco.Net;
|
||||
using Umbraco.Web.BackOffice.Filters;
|
||||
using Umbraco.Web.BackOffice.Security;
|
||||
using Umbraco.Web.Common.ActionsResults;
|
||||
using Umbraco.Web.Common.Attributes;
|
||||
using Umbraco.Web.Common.Controllers;
|
||||
@@ -46,7 +44,7 @@ namespace Umbraco.Web.BackOffice.Controllers
|
||||
[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] // TODO: Maybe this could be applied with our Application Model conventions
|
||||
//[ValidationFilter] // TODO: I don't actually think this is required with our custom Application Model conventions applied
|
||||
[AngularJsonOnlyConfiguration] // TODO: This could be applied with our Application Model conventions
|
||||
[IsBackOffice]
|
||||
[IsBackOffice] // TODO: This could be applied with our Application Model conventions
|
||||
public class AuthenticationController : UmbracoApiControllerBase
|
||||
{
|
||||
private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor;
|
||||
@@ -165,6 +163,7 @@ namespace Umbraco.Web.BackOffice.Controllers
|
||||
var user = await _userManager.FindByIdAsync(User.Identity.GetUserId());
|
||||
if (user == null) throw new InvalidOperationException("Could not find user");
|
||||
|
||||
ExternalSignInAutoLinkOptions autoLinkOptions = null;
|
||||
var authType = (await _signInManager.GetExternalAuthenticationSchemesAsync())
|
||||
.FirstOrDefault(x => x.Name == unlinkLoginModel.LoginProvider);
|
||||
|
||||
@@ -174,18 +173,11 @@ namespace Umbraco.Web.BackOffice.Controllers
|
||||
}
|
||||
else
|
||||
{
|
||||
var opt = _externalAuthenticationOptions.Get(authType.Name);
|
||||
if (opt == null)
|
||||
autoLinkOptions = _externalAuthenticationOptions.Get(authType.Name);
|
||||
if (!autoLinkOptions.AllowManualLinking)
|
||||
{
|
||||
return BadRequest($"Could not find external authentication options registered for provider {unlinkLoginModel.LoginProvider}");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!opt.Options.AutoLinkOptions.AllowManualLinking)
|
||||
{
|
||||
// If AllowManualLinking is disabled for this provider we cannot unlink
|
||||
return BadRequest();
|
||||
}
|
||||
// If AllowManualLinking is disabled for this provider we cannot unlink
|
||||
return BadRequest();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,26 +199,18 @@ namespace Umbraco.Web.BackOffice.Controllers
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<double> GetRemainingTimeoutSeconds()
|
||||
public double GetRemainingTimeoutSeconds()
|
||||
{
|
||||
// force authentication to occur since this is not an authorized endpoint
|
||||
var result = await HttpContext.AuthenticateAsync(Constants.Security.BackOfficeAuthenticationType);
|
||||
if (!result.Succeeded)
|
||||
var backOfficeIdentity = HttpContext.User.GetUmbracoIdentity();
|
||||
var remainingSeconds = HttpContext.User.GetRemainingAuthSeconds();
|
||||
if (remainingSeconds <= 30 && backOfficeIdentity != null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var remainingSeconds = result.Principal.GetRemainingAuthSeconds();
|
||||
if (remainingSeconds <= 30)
|
||||
{
|
||||
var username = result.Principal.FindFirst(ClaimTypes.Name)?.Value;
|
||||
|
||||
//NOTE: We are using 30 seconds because that is what is coded into angular to force logout to give some headway in
|
||||
// the timeout process.
|
||||
|
||||
_logger.LogInformation(
|
||||
"User logged will be logged out due to timeout: {Username}, IP Address: {IPAddress}",
|
||||
username ?? "unknown",
|
||||
backOfficeIdentity.Name,
|
||||
_ipResolver.GetCurrentRequestIpAddress());
|
||||
}
|
||||
|
||||
@@ -238,11 +222,14 @@ namespace Umbraco.Web.BackOffice.Controllers
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet]
|
||||
public async Task<bool> IsAuthenticated()
|
||||
public bool IsAuthenticated()
|
||||
{
|
||||
// force authentication to occur since this is not an authorized endpoint
|
||||
var result = await HttpContext.AuthenticateAsync(Constants.Security.BackOfficeAuthenticationType);
|
||||
return result.Succeeded;
|
||||
var attempt = _backofficeSecurityAccessor.BackOfficeSecurity.AuthorizeRequest();
|
||||
if (attempt == ValidateRequestAttempt.Success)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -256,7 +243,7 @@ namespace Umbraco.Web.BackOffice.Controllers
|
||||
/// </remarks>
|
||||
[UmbracoBackOfficeAuthorize]
|
||||
[SetAngularAntiForgeryTokens]
|
||||
[CheckIfUserTicketDataIsStale]
|
||||
//[CheckIfUserTicketDataIsStale] // TODO: Migrate this, though it will need to be done differently at the cookie auth level
|
||||
public UserDetail GetCurrentUser()
|
||||
{
|
||||
var user = _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser;
|
||||
@@ -572,17 +559,13 @@ namespace Umbraco.Web.BackOffice.Controllers
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[ValidateAngularAntiForgeryToken]
|
||||
public async Task<IActionResult> PostLogout()
|
||||
public IActionResult PostLogout()
|
||||
{
|
||||
// force authentication to occur since this is not an authorized endpoint
|
||||
var result = await HttpContext.AuthenticateAsync(Constants.Security.BackOfficeAuthenticationType);
|
||||
if (!result.Succeeded) return Ok();
|
||||
|
||||
await _signInManager.SignOutAsync();
|
||||
HttpContext.SignOutAsync(Constants.Security.BackOfficeAuthenticationType);
|
||||
|
||||
_logger.LogInformation("User {UserName} from IP address {RemoteIpAddress} has logged out", User.Identity == null ? "UNKNOWN" : User.Identity.Name, HttpContext.Connection.RemoteIpAddress);
|
||||
|
||||
var userId = int.Parse(result.Principal.Identity.GetUserId());
|
||||
var userId = int.Parse(User.Identity.GetUserId());
|
||||
var args = _userManager.RaiseLogoutSuccessEvent(User, userId);
|
||||
if (!args.SignOutRedirectUrl.IsNullOrWhiteSpace())
|
||||
{
|
||||
@@ -596,6 +579,7 @@ namespace Umbraco.Web.BackOffice.Controllers
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Return the <see cref="UserDetail"/> for the given <see cref="IUser"/>
|
||||
/// </summary>
|
||||
|
||||
@@ -34,14 +34,12 @@ using Microsoft.AspNetCore.Identity;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Umbraco.Web.Security;
|
||||
using Umbraco.Web.BackOffice.Security;
|
||||
|
||||
namespace Umbraco.Web.BackOffice.Controllers
|
||||
{
|
||||
[DisableBrowserCache]
|
||||
[DisableBrowserCache] //TODO Reintroduce
|
||||
//[UmbracoRequireHttps] //TODO Reintroduce
|
||||
[PluginController(Constants.Web.Mvc.BackOfficeArea)]
|
||||
[IsBackOffice]
|
||||
public class BackOfficeController : UmbracoController
|
||||
{
|
||||
private readonly IBackOfficeUserManager _userManager;
|
||||
@@ -404,57 +402,186 @@ namespace Umbraco.Web.BackOffice.Controllers
|
||||
{
|
||||
if (loginInfo == null) throw new ArgumentNullException(nameof(loginInfo));
|
||||
if (response == null) throw new ArgumentNullException(nameof(response));
|
||||
ExternalSignInAutoLinkOptions autoLinkOptions = null;
|
||||
|
||||
// Sign in the user with this external login provider (which auto links, etc...)
|
||||
var result = await _signInManager.ExternalLoginSignInAsync(loginInfo, isPersistent: false);
|
||||
var authType = (await _signInManager.GetExternalAuthenticationSchemesAsync())
|
||||
.FirstOrDefault(x => x.Name == loginInfo.LoginProvider);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
if (result == Microsoft.AspNetCore.Identity.SignInResult.Success)
|
||||
if (authType == null)
|
||||
{
|
||||
|
||||
_logger.LogWarning("Could not find external authentication provider registered: {LoginProvider}", loginInfo.LoginProvider);
|
||||
}
|
||||
else if (result == Microsoft.AspNetCore.Identity.SignInResult.LockedOut)
|
||||
else
|
||||
{
|
||||
// TODO: We've never actually dealt with this before
|
||||
}
|
||||
else if (result == Microsoft.AspNetCore.Identity.SignInResult.TwoFactorRequired)
|
||||
{
|
||||
// TODO: We've never actually dealt with this before
|
||||
}
|
||||
else if (result == Microsoft.AspNetCore.Identity.SignInResult.NotAllowed)
|
||||
{
|
||||
// TODO: We've never actually dealt with this before
|
||||
}
|
||||
else if (result == Microsoft.AspNetCore.Identity.SignInResult.Failed)
|
||||
{
|
||||
// Failed only occurs when the user does not exist
|
||||
errors.Add("The requested provider (" + loginInfo.LoginProvider + ") has not been linked to an account, the provider must be linked from the back office.");
|
||||
}
|
||||
else if (result == AutoLinkSignInResult.FailedNotLinked)
|
||||
{
|
||||
errors.Add("The requested provider (" + loginInfo.LoginProvider + ") has not been linked to an account, the provider must be linked from the back office.");
|
||||
}
|
||||
else if (result == AutoLinkSignInResult.FailedNoEmail)
|
||||
{
|
||||
errors.Add($"The requested provider ({loginInfo.LoginProvider}) has not provided the email claim {ClaimTypes.Email}, the account cannot be linked.");
|
||||
}
|
||||
else if (result is AutoLinkSignInResult autoLinkSignInResult && autoLinkSignInResult.Errors.Count > 0)
|
||||
{
|
||||
errors.AddRange(autoLinkSignInResult.Errors);
|
||||
autoLinkOptions = _externalLogins.Get(authType.Name);
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
// Sign in the user with this external login provider if the user already has a login
|
||||
|
||||
var user = await _userManager.FindByLoginAsync(loginInfo.LoginProvider, loginInfo.ProviderKey);
|
||||
if (user != null)
|
||||
{
|
||||
ViewData.SetExternalSignInProviderErrors(
|
||||
new BackOfficeExternalLoginProviderErrors(
|
||||
loginInfo.LoginProvider,
|
||||
errors));
|
||||
var shouldSignIn = true;
|
||||
if (autoLinkOptions != null && autoLinkOptions.OnExternalLogin != null)
|
||||
{
|
||||
shouldSignIn = autoLinkOptions.OnExternalLogin(user, loginInfo);
|
||||
if (shouldSignIn == false)
|
||||
{
|
||||
_logger.LogWarning("The AutoLinkOptions of the external authentication provider '{LoginProvider}' have refused the login based on the OnExternalLogin method. Affected user id: '{UserId}'", loginInfo.LoginProvider, user.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSignIn)
|
||||
{
|
||||
//sign in
|
||||
await _signInManager.SignInAsync(user, false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (await AutoLinkAndSignInExternalAccount(loginInfo, autoLinkOptions) == false)
|
||||
{
|
||||
ViewData.SetExternalSignInProviderErrors(
|
||||
new BackOfficeExternalLoginProviderErrors(
|
||||
loginInfo.LoginProvider,
|
||||
new[] { "The requested provider (" + loginInfo.LoginProvider + ") has not been linked to an account" }));
|
||||
}
|
||||
|
||||
//Remove the cookie otherwise this message will keep appearing
|
||||
Response.Cookies.Delete(Constants.Security.BackOfficeExternalCookieName);
|
||||
}
|
||||
|
||||
return response();
|
||||
}
|
||||
|
||||
private async Task<bool> AutoLinkAndSignInExternalAccount(ExternalLoginInfo loginInfo, ExternalSignInAutoLinkOptions autoLinkOptions)
|
||||
{
|
||||
if (autoLinkOptions == null)
|
||||
return false;
|
||||
|
||||
if (autoLinkOptions.AutoLinkExternalAccount == false)
|
||||
return true; // TODO: This seems weird to return true, but it was like that before so must be a reason?
|
||||
|
||||
var email = loginInfo.Principal.FindFirstValue(ClaimTypes.Email);
|
||||
|
||||
//we are allowing auto-linking/creating of local accounts
|
||||
if (email.IsNullOrWhiteSpace())
|
||||
{
|
||||
ViewData.SetExternalSignInProviderErrors(
|
||||
new BackOfficeExternalLoginProviderErrors(
|
||||
loginInfo.LoginProvider,
|
||||
new[] { $"The requested provider ({loginInfo.LoginProvider}) has not provided the email claim {ClaimTypes.Email}, the account cannot be linked." }));
|
||||
}
|
||||
else
|
||||
{
|
||||
//Now we need to perform the auto-link, so first we need to lookup/create a user with the email address
|
||||
var autoLinkUser = await _userManager.FindByEmailAsync(email);
|
||||
if (autoLinkUser != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
//call the callback if one is assigned
|
||||
autoLinkOptions.OnAutoLinking?.Invoke(autoLinkUser, loginInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Could not link login provider {LoginProvider}.", loginInfo.LoginProvider);
|
||||
ViewData.SetExternalSignInProviderErrors(
|
||||
new BackOfficeExternalLoginProviderErrors(
|
||||
loginInfo.LoginProvider,
|
||||
new[] { "Could not link login provider " + loginInfo.LoginProvider + ". " + ex.Message }));
|
||||
return true;
|
||||
}
|
||||
|
||||
await LinkUser(autoLinkUser, loginInfo);
|
||||
}
|
||||
else
|
||||
{
|
||||
var name = loginInfo.Principal?.Identity?.Name;
|
||||
if (name.IsNullOrWhiteSpace()) throw new InvalidOperationException("The Name value cannot be null");
|
||||
|
||||
autoLinkUser = BackOfficeIdentityUser.CreateNew(_globalSettings, email, email, autoLinkOptions.GetUserAutoLinkCulture(_globalSettings), name);
|
||||
|
||||
foreach (var userGroup in autoLinkOptions.DefaultUserGroups)
|
||||
{
|
||||
autoLinkUser.AddRole(userGroup);
|
||||
}
|
||||
|
||||
//call the callback if one is assigned
|
||||
try
|
||||
{
|
||||
autoLinkOptions.OnAutoLinking?.Invoke(autoLinkUser, loginInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Could not link login provider {LoginProvider}.", loginInfo.LoginProvider);
|
||||
ViewData.SetExternalSignInProviderErrors(
|
||||
new BackOfficeExternalLoginProviderErrors(
|
||||
loginInfo.LoginProvider,
|
||||
new[] { "Could not link login provider " + loginInfo.LoginProvider + ". " + ex.Message }));
|
||||
return true;
|
||||
}
|
||||
|
||||
var userCreationResult = await _userManager.CreateAsync(autoLinkUser);
|
||||
|
||||
if (userCreationResult.Succeeded == false)
|
||||
{
|
||||
ViewData.SetExternalSignInProviderErrors(
|
||||
new BackOfficeExternalLoginProviderErrors(
|
||||
loginInfo.LoginProvider,
|
||||
userCreationResult.Errors.Select(x => x.Description).ToList()));
|
||||
}
|
||||
else
|
||||
{
|
||||
await LinkUser(autoLinkUser, loginInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task LinkUser(BackOfficeIdentityUser autoLinkUser, ExternalLoginInfo loginInfo)
|
||||
{
|
||||
var existingLogins = await _userManager.GetLoginsAsync(autoLinkUser);
|
||||
var exists = existingLogins.FirstOrDefault(x => x.LoginProvider == loginInfo.LoginProvider && x.ProviderKey == loginInfo.ProviderKey);
|
||||
|
||||
// if it already exists (perhaps it was added in the AutoLink callbak) then we just continue
|
||||
if (exists != null)
|
||||
{
|
||||
//sign in
|
||||
await _signInManager.SignInAsync(autoLinkUser, isPersistent: false);
|
||||
return;
|
||||
}
|
||||
|
||||
var linkResult = await _userManager.AddLoginAsync(autoLinkUser, loginInfo);
|
||||
if (linkResult.Succeeded)
|
||||
{
|
||||
//we're good! sign in
|
||||
await _signInManager.SignInAsync(autoLinkUser, isPersistent: false);
|
||||
return;
|
||||
}
|
||||
|
||||
ViewData.SetExternalSignInProviderErrors(
|
||||
new BackOfficeExternalLoginProviderErrors(
|
||||
loginInfo.LoginProvider,
|
||||
linkResult.Errors.Select(x => x.Description).ToList()));
|
||||
|
||||
//If this fails, we should really delete the user since it will be in an inconsistent state!
|
||||
var deleteResult = await _userManager.DeleteAsync(autoLinkUser);
|
||||
if (!deleteResult.Succeeded)
|
||||
{
|
||||
//DOH! ... this isn't good, combine all errors to be shown
|
||||
ViewData.SetExternalSignInProviderErrors(
|
||||
new BackOfficeExternalLoginProviderErrors(
|
||||
loginInfo.LoginProvider,
|
||||
linkResult.Errors.Concat(deleteResult.Errors).Select(x => x.Description).ToList()));
|
||||
}
|
||||
}
|
||||
|
||||
// Used for XSRF protection when adding external logins
|
||||
// TODO: This is duplicated in BackOfficeSignInManager
|
||||
private const string XsrfKey = "XsrfId";
|
||||
|
||||
private IActionResult RedirectToLocal(string returnUrl)
|
||||
{
|
||||
if (Url.IsLocalUrl(returnUrl))
|
||||
|
||||
@@ -17,9 +17,9 @@ using Umbraco.Web.BackOffice.HealthCheck;
|
||||
using Umbraco.Web.BackOffice.Profiling;
|
||||
using Umbraco.Web.BackOffice.PropertyEditors;
|
||||
using Umbraco.Web.BackOffice.Routing;
|
||||
using Umbraco.Web.BackOffice.Security;
|
||||
using Umbraco.Web.BackOffice.Trees;
|
||||
using Umbraco.Web.Common.Attributes;
|
||||
using Umbraco.Web.Common.Security;
|
||||
using Umbraco.Web.Features;
|
||||
using Umbraco.Web.Models.ContentEditing;
|
||||
using Umbraco.Web.Trees;
|
||||
@@ -135,7 +135,7 @@ namespace Umbraco.Web.BackOffice.Controllers
|
||||
/// Returns the server variables for authenticated users
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
internal Task<Dictionary<string, object>> GetServerVariablesAsync()
|
||||
internal async Task<Dictionary<string, object>> GetServerVariablesAsync()
|
||||
{
|
||||
var globalSettings = _globalSettings;
|
||||
var backOfficeControllerName = ControllerExtensions.GetControllerName<BackOfficeController>();
|
||||
@@ -149,8 +149,8 @@ namespace Umbraco.Web.BackOffice.Controllers
|
||||
// having each url defined here explicitly - we can do that in v8! for now
|
||||
// for umbraco services we'll stick to explicitly defining the endpoints.
|
||||
|
||||
{"externalLoginsUrl", _linkGenerator.GetPathByAction(nameof(BackOfficeController.ExternalLogin), backOfficeControllerName, new { area = Constants.Web.Mvc.BackOfficeArea })},
|
||||
{"externalLinkLoginsUrl", _linkGenerator.GetPathByAction(nameof(BackOfficeController.LinkLogin), backOfficeControllerName, new { area = Constants.Web.Mvc.BackOfficeArea })},
|
||||
// {"externalLoginsUrl", _linkGenerator.GetPathByAction(nameof(BackOfficeController.ExternalLogin), backOfficeControllerName, new { area = Constants.Web.Mvc.BackOfficeArea })},
|
||||
// {"externalLinkLoginsUrl", _linkGenerator.GetPathByAction(nameof(BackOfficeController.LinkLogin), backOfficeControllerName, new { area = Constants.Web.Mvc.BackOfficeArea })},
|
||||
{"gridConfig", _linkGenerator.GetPathByAction(nameof(BackOfficeController.GetGridConfig), backOfficeControllerName, new { area = Constants.Web.Mvc.BackOfficeArea })},
|
||||
// TODO: This is ultra confusing! this same key is used for different things, when returning the full app when authenticated it is this URL but when not auth'd it's actually the ServerVariables address
|
||||
{"serverVarsJs", _linkGenerator.GetPathByAction(nameof(BackOfficeController.Application), backOfficeControllerName, new { area = Constants.Web.Mvc.BackOfficeArea })},
|
||||
@@ -418,14 +418,11 @@ namespace Umbraco.Web.BackOffice.Controllers
|
||||
"externalLogins", new Dictionary<string, object>
|
||||
{
|
||||
{
|
||||
// TODO: It would be nicer to not have to manually translate these properties
|
||||
// but then needs to be changed in quite a few places in angular
|
||||
"providers", _externalLogins.GetBackOfficeProviders()
|
||||
.Select(p => new
|
||||
{
|
||||
authType = p.AuthenticationType,
|
||||
caption = p.Name,
|
||||
properties = p.Options
|
||||
authType = p.AuthenticationType, caption = p.Name,
|
||||
properties = p.Properties
|
||||
})
|
||||
.ToArray()
|
||||
}
|
||||
@@ -444,7 +441,7 @@ namespace Umbraco.Web.BackOffice.Controllers
|
||||
}
|
||||
}
|
||||
};
|
||||
return Task.FromResult(defaultVals);
|
||||
return defaultVals;
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
|
||||
@@ -8,7 +8,8 @@ using Umbraco.Web.PublishedCache;
|
||||
|
||||
namespace Umbraco.Web.BackOffice.Controllers
|
||||
{
|
||||
[PluginController(Constants.Web.Mvc.BackOfficeApiArea)]
|
||||
[PluginController(Constants.Web.Mvc.BackOfficeApiArea)]
|
||||
[IsBackOffice]
|
||||
public class PublishedSnapshotCacheStatusController : UmbracoAuthorizedApiController
|
||||
{
|
||||
private readonly IPublishedSnapshotService _publishedSnapshotService;
|
||||
|
||||
@@ -41,6 +41,7 @@ using IUser = Umbraco.Core.Models.Membership.IUser;
|
||||
using Task = System.Threading.Tasks.Task;
|
||||
using Umbraco.Net;
|
||||
using Umbraco.Web.Common.ActionsResults;
|
||||
using Umbraco.Web.Common.Security;
|
||||
|
||||
namespace Umbraco.Web.BackOffice.Controllers
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user