Require authentication for certain content
Members series
This guide is a part of the Members series.
When a business decides to include members in their project, they typically have some kind of exclusive content that only members should be able to see.
As a result, developers must ensure that only signed-in members can see certain content.
Luckily, Xperience and .NET Identity both include functionality to facilitate this process.
Before you start
This guide requires the following:
- Familiarity with C#, .NET Core, .NET Identity, Dependency injection, and the MVC pattern.
- A running instance of Xperience by Kentico, preferably 29.6.1 or higher.Some features covered in the Training guides may not work in older versions.
Code samples
The code samples in this guide rely on code from previous guides in the Members series.
You can find a project with completed, working versions of code samples from this guide and others in the finished branch of the Training guides repository.
The main branch of the repository provides a starting point to code along with the guides.
The code samples in this guide are for .NET 8 only.
They come from a project that uses implicit using directives. You may need to add additional using
directives to your code if your project does not use this feature.
Mark content to require authentication in the Xperience administration
In the admin UI of Xperience by Kentico, you can find configurable properties to specify whether a given content item is secured.
Restrict a website page
In the Xperience admin UI, open a website channel where you want to secure a page.
Then, select the page you want to secure in the content tree, and switch to the Properties tab.
Expand the Membership section and tick the box to require authentication, before clicking to publish the change.
For this example, choose one of the article pages under the News and articles page of the Training guides pages channel.
Depending on whether you’ve completed the advanced content guides, some articles might not work. Make sure you choose one that renders properly.
Restrict a reusable item in the Content hub
In the Content hub, edit the item you want to secure.
Select the Properties tab and tick the box to require authentication under the Security heading.
For this example, choose one of the reusable articles referenced by a page under the News and articles page of the Training guides pages channel.
Make sure it’s from a page that renders properly, and is NOT associated with the page you chose in the previous section. DO NOT secure the page that references this reusable item, only secure the reusable item itself.
Handle the 403 HTTP status code
The 403 Forbidden error code is used in web development to indicate that someone does not have access to a resource.
Both Xperience and .NET Identity follow this convention in ways that are relevant to our scenario:
- Xperience’s Content tree-based router returns this status code when a secured page is requested by an unauthenticated visitor.
- .NET Identity offers configuration to redirect requests that return this status code to a certain path, such as a sign-in page.
Configure Identity
If you examine the Identity configuration options mentioned above, you’ll notice that the path must be set on startup, and therefore does not have the context of the request for which access was denied. There is no way to determine, for example, which language variant of the sign-in page the application should redirect to.
Therefore, let’s redirect visitors to a handler that can dynamically figure out which language version they should see.
Start by creating three constants:
- One should hold the path to a controller action that will serve as this handler.
- Another should determine the name of the query string parameter to which Identity assigns the return URL (the path that initially returned the 403 Forbidden status).
- The final constant should hold a name for the authentication cookie.
...
public const string ACCESS_DENIED_ACTION_PATH = "/Authentication/AccessDenied";
public const string RETURN_URL_PARAMETER = "returnUrl";
...
...
// Essential cookies
public const string COOKIE_AUTHENTICATION = "trainingguides.authentication";
...
Then, use these constants to set the corresponding CookieAuthenticationOptions
properties in the ConfigureApplicationCookie
call during startup.
...
builder.Services.ConfigureApplicationCookie(options =>
{
options.AccessDeniedPath = new PathString(ApplicationConstants.ACCESS_DENIED_ACTION_PATH);
options.ReturnUrlParameter = ApplicationConstants.RETURN_URL_PARAMETER;
options.Cookie.IsEssential = true;
options.Cookie.Name = CookieNames.COOKIE_AUTHENTICATION;
});
...
Make sure that you call ConfigureApplicationCookie
after AddIdentity
.
Define the AccessDenied controller action
With identity configured, let’s create the handler to which it will redirect.
Add a new action called AccessDenied
to the authentication controller, utilizing the path constant from earlier.
Include logic that extracts a language codename from the provided return URL if it exists, and returns the default language code otherwise.
Then, use it to get a language-specific sign-in URL with a new IMembershipService
method.
Finally, redirect to the sign-in URL, passing along the query string parameter so that we can redirect to the secured content after successful authentication.
...
using Kentico.Content.Web.Mvc.Routing;
...
namespace TrainingGuides.Web.Features.Membership.Controllers;
public class AuthenticationController : Controller
{
// Use dependency injection to populate these
private readonly IMembershipService membershipService;
private readonly IStringLocalizer<SharedResources> stringLocalizer;
private readonly IHttpRequestService httpRequestService;
// NEW DEPENDENCIES
private readonly IPreferredLanguageRetriever preferredLanguageRetriever;
private readonly IInfoProvider<ContentLanguageInfo> contentLanguageInfoProvider;
// END NEW DEPENDENCIES
private const string SIGN_IN_FAILED = "Your sign-in attempt was not successful. Please try again.";
...
[HttpGet(ApplicationConstants.ACCESS_DENIED_ACTION_PATH)]
public async Task<IActionResult> AccessDenied([FromQuery(Name = ApplicationConstants.RETURN_URL_PARAMETER)] string returnUrl)
{
string language = GetLanguageFromReturnUrl(returnUrl);
string signInUrl = await membershipService.GetSignInUrl(language);
var query = QueryString.Create(ApplicationConstants.RETURN_URL_PARAMETER, returnUrl);
var redirectUrl = new UriBuilder(signInUrl)
{
Query = query.ToString()
};
return Redirect(redirectUrl.ToString());
}
private string GetLanguageFromReturnUrl(string returnUrl)
{
// Cache this in real-world scenarios
var languages = contentLanguageInfoProvider.Get()
.Column(nameof(ContentLanguageInfo.ContentLanguageName))
.GetListResult<string>();
foreach (string language in languages)
{
if (returnUrl.StartsWith($"/{language}/", StringComparison.OrdinalIgnoreCase) || returnUrl.StartsWith($"~/{language}/", StringComparison.OrdinalIgnoreCase))
{
return language;
}
}
// Since this controller action has no language in its path, this will return the channel default.
return preferredLanguageRetriever.Get();
}
}
Here is the referenced addition to the membership service:
The code samples in this guide rely on a decorated version of IWebPageUrlRetriever
that includes exception handling.
If you do not plan to use a similar customization, make sure to handle errors that the Retrieve
method may throw.
/// <summary>
/// Gets the URL of the expected sign in page in the provided language.
/// </summary>
/// <param name="language">The required language to retrieve.</param>
/// <param name="absoluteURL">Whether to return an absolute URL.</param>
/// <returns>The relative path of the sign in page.</returns>
Task<string> GetSignInUrl(string language, bool absoluteURL = false);
/// <inheritdoc />
public async Task<string> GetSignInUrl(string language, bool absoluteURL = false)
=> await GetPageUrl(ApplicationConstants.EXPECTED_SIGN_IN_PATH, language, absoluteURL);
...
private async Task<string> GetPageUrl(string expectedPagePath, string language, bool absoluteURL = false)
{
var signInUrl = await webPageUrlRetriever.Retrieve(
webPageTreePath: expectedPagePath,
websiteChannelName: websiteChannelContext.WebsiteChannelName,
languageName: language
);
return absoluteURL ?
httpRequestService.GetAbsoluteUrlForPath(signInUrl.RelativePath.TrimStart('~'), false)
: signInUrl.RelativePath;
}
...
And here is the constant it utilizes for the tree path of the sign-in page:
...
public const string EXPECTED_SIGN_IN_PATH = "/Membership/Sign_in";
...
Referencing specific items in the database from code
This code depends on a sign-in page existing at a specific path in the content tree. (It doesn’t need to be the only one, but it must exist at the specified tree path.)
When you need to store a reference to some kind of identifier for data in the database, we recommend using something that editors have control over, such as a tree path or codename, rather than something like a numerical ID or GUID.
That way, if an editor accidentally moves or deletes an object, it is possible for them to fix it with no code changes necessary.
Check your progress
Now, if you set the About conifers page to require authentication earlier, you can test out the functionality.
You’ll notice that the secured page correctly redirects to the Sign in page, but once the member authenticates, the form DOES NOT send them to the return URL.
We’ll have to make some changes for that to work.
Add redirect logic to the Sign-in widget
Let’s expand the Sign in widget view component to redirect a member back to the provided return URL after successful authentication.
It needs to find the returnUrl
value in the query string of the request, so we should start with a new method in the HttpRequestService
.
/// <summary>
/// Retrieves the value of the specified query string parameter
/// </summary>
/// <param name="parameter">The name of the query string parameter to retrieve</param>
/// <returns>The value of the specified query string parameter</returns>
string GetQueryStringValue(string parameter);
/// <inheritdoc/>
public string GetQueryStringValue(string parameter) => httpContextAccessor.HttpContext?.Request.Query[parameter].ToString() ?? string.Empty;
Then, expand the BuildWidgetViewModel
method of the sign-in widget view component to check the query string for a returnUrl value, falling back to the previous logic if it finds none.
using TrainingGuides.Web.Features.Shared.Helpers;
...
public class SignInWidgetViewComponent : ViewComponent
{
...
public async Task<SignInWidgetViewModel> BuildWidgetViewModel(SignInWidgetProperties properties) => new SignInWidgetViewModel
{
ActionUrl = GetActionUrl(),
DefaultRedirectPageGuid = properties.DefaultRedirectPage.FirstOrDefault()?.WebPageGuid ?? Guid.Empty,
DisplayForm = !await membershipService.IsMemberAuthenticated(),
FormTitle = properties.FormTitle,
SubmitButtonText = properties.SubmitButtonText,
UserNameOrEmailLabel = properties.UserNameLabel,
PasswordLabel = properties.PasswordLabel,
StaySignedInLabel = properties.StaySignedInLabel
};
private string GetActionUrl()
{
// New code: retrieve and use the return URL if it exists
string? returnUrl = GetReturnUrlFromQueryString();
QueryString? queryString = string.IsNullOrWhiteSpace(returnUrl) ? null : QueryString.Create(ApplicationConstants.RETURN_URL_PARAMETER, returnUrl);
return httpRequestService.GetAbsoluteUrlForPath(ApplicationConstants.AUTHENTICATE_ACTION_PATH, true, queryString);
}
// New code: use the new HttpRequestService method to retrieve the query string
private string? GetReturnUrlFromQueryString()
{
string returnUrl = httpRequestService.GetQueryStringValue(ApplicationConstants.RETURN_URL_PARAMETER);
// If there is no return URL or it is not a relative URL, return null
if (string.IsNullOrWhiteSpace(returnUrl) || !returnUrl.StartsWith("/"))
{
return null;
}
return returnUrl;
}
}
See this section of the language selector guide or the training guides repository for details about how to implement the GetBaseUrlWithLanguage
method.
If you ensure the return URL only works with relative paths, you can prevent attackers from exploiting the query string to redirect members to phishing sites after successful authentication on your site.
See the redirect in action
Now the sign in form should correctly redirect you to the return URL after you authenticate.
However, you may notice that even if you followed along earlier and set a reusable item to require authentication, it is still visible to signed out users on the page.
This happens because the web page that references this reusable article is not secured. The content tree-based router only knows to check security of the pages it is retrieving, and not any reusable content they might reference.
Handle secured reusable content
Ideally, content editors should mark all pages that reference secured items to require authentication, but we shouldn’t assume that they will never make mistakes.
When pages display reusable content, you need one of the following approaches to for security:
- Set the query that retrieves the content to filter out secured items.
- Check the
ContentItemIsSecured
value of the linked items you want to secure, and react accordingly. For example, you can redirect or display an error message.
In this case, let’s try the latter.
Go to the ~/Features/Articles/Services folder and add a new method to the article page service. It should check if the primary reusable content item referenced by an article page is secured.
...
/// <summary>
/// Determines whether the reusable article item referenced by the article page is secured.
/// </summary>
/// <param name="articlePage">The article page.</param>
/// <returns>True if the reusable item that the page references is secured.</returns>
bool IsReusableArticleSecured(ArticlePage articlePage);
...
...
/// <inheritdoc/>
public bool IsReusableArticleSecured(ArticlePage articlePage)
{
var oldArticle = articlePage.ArticlePageContent.FirstOrDefault();
var newArticle = (IContentItemFieldsSource?)articlePage.ArticlePageArticleContent.FirstOrDefault();
return (oldArticle?.SystemFields.ContentItemIsSecured ?? false)
|| (newArticle?.SystemFields.ContentItemIsSecured ?? false);
}
...
Depending on whether you’ve followed along with the Advanced content series, the ArticlePageArticleContent
field may or may not exist in the ArticlePage
type.
Then, use your new method in the article page controller, returning the 403 Forbidden status code when the current visitor is unauthenticated and the item is secured.
using TrainingGuides.Web.Features.Membership.Services;
...
public class ArticlePageController : Controller
{
// Use dependency injection to populate these
private readonly IWebPageDataContextRetriever webPageDataContextRetriever;
private readonly IContentItemRetrieverService<ArticlePage> articlePageRetrieverService;
private readonly IArticlePageService articlePageService;
// NEW DEPENDENCY
private readonly IMembershipService membershipService;
...
public async Task<IActionResult> Index()
{
var context = webPageDataContextRetriever.Retrieve();
var articlePage = await articlePageRetrieverService.RetrieveWebPageById(
context.WebPage.WebPageItemID,
ArticlePage.CONTENT_TYPE_NAME,
2);
// NEW CODE
if (articlePage is not null
&& articlePageService.IsReusableArticleSecured(articlePage)
&& !await membershipService.IsMemberAuthenticated())
{
return Forbid();
}
// END NEW CODE
var model = await articlePageService.GetArticlePageViewModel(articlePage);
return new TemplateResult(model);
}
...
See the results
Now, Identity should handle the 403 status the same way it does for pages if you try to access a secured article.
What’s next?
The finished branch of the Training guides repository contains a demonstration of two approaches to handle secured content in listings:
- Display a message explaining that the item is locked and linking to the sign-in page.
- Hide secured items completely from the listing by filtering them out of the query.
If you’re looking to expand upon the lesson from this guide, we recommend trying to implement this functionality in your own project.
Specifically, check out:
The branch also contains other useful features for your reference, like password reset functionality and a dynamic widget that shows a sign-out button or a link depending on whether or not the current visitor is authenticated.