Module: Members

9 of 11 Pages

Set up restricted pages

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, and that role-based rules can further restrict access when needed.

Luckily, Xperience and .NET Identity both include functionality to facilitate this process.

This guide explains the main principles of securing content for members and member roles. It uses the ArticleList widget from the Training guides as an example, but it does not provide a full walkthrough of widget UI implementation changes.

For a complete implementation, review the ArticleList widget in the finished branch and adapt it to your project.

If you used the XperienceCommunity.MemberRoles package and you’re ready to switch to the native implementation of member roles, see the Convert member roles from the XperienceCommunity.MemberRoles package (optional) section.

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 access section and tick the box to require authentication, before clicking to publish the change.

Optionally, select the member roles you want to restrict access to.

For this example, choose one of the article pages under the News and articles page of the Training guides pages channel, and restrict access to the Basic role (the default user role in Training guides).

Depending on whether you’ve completed the advanced content guides, some articles might not work. Make sure you choose one that renders properly.

Screenshot of the membership properties of a page

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 Membership access heading at the bottom of the form.

Optionally, select the member roles you want to restrict access to, for example, the Basic role.

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. For this example, DO NOT secure the page that references this reusable item, only secure the reusable item itself.

Screenshot of the security properties of a reusable content item

Handle authentication redirects

The 401 Unauthorized and 403 Forbidden error codes indicate different access failures.

Both Xperience and .NET Identity use these responses in ways that matter for secured content:

  • Xperience’s Content tree-based router returns 401 Unauthorized when a secured page is requested by an unauthenticated visitor.
  • Custom controller logic can still return 403 Forbidden, for example when you call Forbid() for secured reusable content.
  • .NET Identity offers configuration to redirect both responses to configured paths.

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 both response types to a handler that can dynamically figure out which language version visitors should see.

Start by creating four constants. Three are used to configure Identity redirects and cookies, and one (the sign-in page tree path) is reused later when resolving the localized sign-in URL.

C#
~/Features/Shared/Helpers/ApplicationConstants.cs

...
// Expected content-tree path of the sign-in page
public const string EXPECTED_SIGN_IN_PATH = "/Membership/Sign_in";
// Path of the access-denied handler action
public const string ACCESS_DENIED_ACTION_PATH = "/Authentication/AccessDenied";
// Query parameter name used for the return URL
public const string RETURN_URL_PARAMETER = "returnUrl";
...
C#
~/Features/DataProtection/Shared.CookieNames.cs

...
// The authentication cookie name
public const string COOKIE_AUTHENTICATION = "trainingguides.authentication";
...

Then, use these constants to set the corresponding CookieAuthenticationOptions properties in the ConfigureApplicationCookie call during startup.

In this setup, unauthenticated requests first go to the expected sign-in path, while forbidden requests go to the access denied handler.

C#
~/Program.cs

...
builder.Services.ConfigureApplicationCookie(options =>
{
    options.LoginPath = new PathString(ApplicationConstants.EXPECTED_SIGN_IN_PATH);
    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 resolves the language from the provided return URL when it starts with a known language prefix, and falls back to the preferred or default language otherwise.

Then, if the request language does not match, redirect to the localized access denied route while preserving the return URL.

If the request language already matches, render an access denied view model.

To keep the action focused, the sample moves supporting logic into helper methods: ResolveLanguageFromReturnUrlOrPreferred resolves the language from the return URL (with fallback to preferred or default language), and GetAccessDeniedViewModel builds the localized access denied view model.

C#
AuthenticationController.cs


...
using Kentico.Content.Web.Mvc.Routing;
...
namespace TrainingGuides.Web.Features.Membership.Controllers;

public class AuthenticationController(
    IMembershipService membershipService,
    IStringLocalizer<SharedResources> stringLocalizer,
    // NEW DEPENDENCIES
    IPreferredLanguageRetriever preferredLanguageRetriever,
    IInfoProvider<ContentLanguageInfo> contentLanguageInfoProvider,
    // END NEW DEPENDENCIES
    IHttpRequestService httpRequestService) : Controller
{

    private const string SIGN_IN_FAILED = "Your sign-in attempt was not successful. Please try again.";

    private IActionResult RenderError(SignInWidgetViewModel model)
    {
        ModelState.AddModelError(string.Empty, stringLocalizer[SIGN_IN_FAILED]);
        return PartialView("~/Features/Membership/Widgets/SignIn/SignInForm.cshtml", model);
    }

    private IActionResult RenderSuccess(string redirectUrl)
    {
        var model = new SignInWidgetViewModel
        {
            DisplayForm = true,
            AuthenticationSuccessful = true,
            RedirectUrl = redirectUrl

        };
        return PartialView("~/Features/Membership/Widgets/SignIn/SignInForm.cshtml", model);
    }

    [HttpPost($"{{{ApplicationConstants.LANGUAGE_KEY}}}{ApplicationConstants.AUTHENTICATE_ACTION_PATH}")]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Authenticate(SignInWidgetViewModel model, [FromQuery(Name = ApplicationConstants.RETURN_URL_PARAMETER)] string returnUrl)
    {
        if (!ModelState.IsValid)
        {
            return RenderError(model);
        }

        var signInResult = await membershipService.SignIn(model.UserNameOrEmail, model.Password, model.StaySignedIn);

        string returnPath = string.IsNullOrWhiteSpace(returnUrl)
            ? (model.DefaultRedirectPageGuid == Guid.Empty
                ? "/"
                : (await httpRequestService.GetPageRelativeUrl(model.DefaultRedirectPageGuid, preferredLanguageRetriever.Get())).TrimStart('~'))
            : EnsureRelativeReturnUrl(returnUrl);

        string absoluteReturnUrl = httpRequestService.GetAbsoluteUrlForPath(returnPath, false);

        return signInResult.Succeeded
            ? RenderSuccess(absoluteReturnUrl)
            : RenderError(model);
    }

    [Authorize]
    [HttpPost("/Authentication/SignOut")]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> SignOut(SignOutFormModel model)
    {
        await membershipService.SignOut();

        string redirectPath = EnsureRelativeReturnUrl(model.RedirectUrl);

        string redirectUrl = httpRequestService.GetAbsoluteUrlForPath(redirectPath, false);

        return Redirect(redirectUrl);
    }

    [HttpGet(ApplicationConstants.EXPECTED_SIGN_IN_PATH)]
    public async Task<IActionResult> SignIn([FromQuery(Name = ApplicationConstants.RETURN_URL_PARAMETER)] string returnUrl = "")
    {
        string language = ResolveLanguageFromReturnUrlOrPreferred(returnUrl);

        string signInUrl = await membershipService.GetSignInUrl(language, false);

        string query = string.IsNullOrWhiteSpace(returnUrl)
            ? string.Empty
            : QueryString.Create(ApplicationConstants.RETURN_URL_PARAMETER, returnUrl).ToString();

        var redirectUrl = new UriBuilder(httpRequestService.GetBaseUrl())
        {
            Path = signInUrl.TrimStart('~'),
            Query = query
        };

        return Redirect(redirectUrl.ToString());
    }

    [HttpGet(ApplicationConstants.ACCESS_DENIED_ACTION_PATH)]
    [HttpGet($"{{{ApplicationConstants.LANGUAGE_KEY}}}{ApplicationConstants.ACCESS_DENIED_ACTION_PATH}")]
    public IActionResult AccessDenied([FromQuery(Name = ApplicationConstants.RETURN_URL_PARAMETER)] string returnUrl)
    {
        string language = ResolveLanguageFromReturnUrlOrPreferred(returnUrl);
        string requestLanguage = RouteData.Values[ApplicationConstants.LANGUAGE_KEY]?.ToString() ?? string.Empty;

        if (!string.Equals(requestLanguage, language, StringComparison.OrdinalIgnoreCase))
        {
            string localizedPath = $"/{language}{ApplicationConstants.ACCESS_DENIED_ACTION_PATH}";
            string queryString = QueryString.Create(ApplicationConstants.RETURN_URL_PARAMETER, returnUrl).ToString();

            return Redirect($"{localizedPath}{queryString}");
        }

        return View("~/Features/Membership/ViewComponents/Authentication/AccessDenied.cshtml", GetAccessDeniedViewModel());
    }

    private ViewComponents.Authentication.AccessDeniedViewModel GetAccessDeniedViewModel() => new()
    {
        Title = stringLocalizer["Access Denied"],
        Heading = $"🔒 {stringLocalizer["Access Denied"]}",
        Message = stringLocalizer["You do not have permission to access this content. If you believe you should have access, please contact support."]
    };

    private string ResolveLanguageFromReturnUrlOrPreferred(string returnUrl)
    {
        if (string.IsNullOrWhiteSpace(returnUrl))
        {
            return preferredLanguageRetriever.Get();
        }

        // Cache this in real-world scenarios.
        // Resolve from returnUrl first to preserve the target page locale during sign-in/access-denied redirects;
        // otherwise fall back to preferred language from request/channel context.
        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 example 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.

C#
IMembershipService.cs

/// <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);
C#
MembershipService.cs

/// <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;
}
...

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

To test, make sure your project includes the ArticleList widget files from the finished branch.

The widget also exists in the main branch but does not include the full membership and member-role implementation used in this guide’s results.

Add the widget to a page and set the Secured items display mode property to Prompt for login – see the News page in the Training guides repo.

Now, if you set the About conifers page to require authentication earlier, you can test out the functionality. Make sure you didn’t change your member’s default Basic role.

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.