ASP.NET Core authentication principle and Implementation

Generally, in an application, security is divided into two steps: authentication and authorization. Authentication is responsible for checking the identity of the current requester, while authorization determines whether the current requester can access the desired resources according to the identity obtained in the previous step.

Since security begins with verification, we will introduce security from verification.

Core concepts of validation

Let's start with a relatively simple scenario. For example, in Web API development, we need to verify whether the requester provides a security token and whether the security token is valid. If it is invalid, the API side should refuse to provide the service. In the namespace Microsoft AspNetCore. Under authentication, define the core interface for authentication. The corresponding assembly is Microsoft AspNetCore. Authentication. Abstractions. dll.

Authentication interface IAuthenticationHandler

In ASP Net, validation includes three basic operations:

Authenticate authentication

The authentication operation is responsible for constructing the user identity based on the context of the current request and using the information from the request, such as request header, Cookie, etc. The result of the build is an AuthenticateResult object, which indicates whether the authentication is successful. If successful, the user ID will be found in the authentication ticket.

Common verifications include:

  • Cookie based authentication authenticates the user from the requested cookie

  • Based on the verification of JWT Bearer, the JWT token is extracted from the request header for verification

Challenge challenge

In the authorization management phase, if the user is not authenticated, but the expected resource requirements must be authenticated, the authorization service will issue a query. For example, when anonymous users access restricted resources, or when users click the login link. The authorization service will query the corresponding users.

for example

  • Cookie based authentication redirects the user to the login page

  • JWT based authentication will return a 401 response with a WWW authenticate: bearer response header to remind the client of the need to provide access credentials

The challenge operation should let the user know what authentication mechanism should be used to access the requested resource.

Forbid refused

In the authorization management phase, if the user has passed the authentication, but has not been licensed for the resources he accesses, the deny operation will be used.

For example:

  • In Cookie authentication mode, users who have logged in but do not have access rights are redirected to a page indicating that they do not have access rights

  • In JWT authentication mode, 403 is returned

  • In custom authentication mode, users without permission are redirected to the page requesting resources

Access denied processing should let the user know:

  • It has passed the verification

  • However, you do not have access to the requested resource

In this scenario, you can see that the basic functions to be provided by verification include verification and denial of service after verification failure. In ASP Net core, authentication is called Authenticate, and rejection is called forbidden. On the website for consumers to visit, if we want to navigate the user to the login page instead of returning an error page directly like the API after authentication failure, we also need to add an operation. The essence of this operation is to want the user to provide security credentials again. In ASP Net core, this operation is called Challenge. The combination of these three operations is the most basic requirement for verification, which is expressed in the form of interface, namely IAuthenticationHandler interface, as shown below:

public interface IAuthenticationHandler
{
    Task InitializeAsync(AuthenticationScheme scheme, HttpContext context);
    Task<AuthenticateResult> AuthenticateAsync();
    Task ChallengeAsync(AuthenticationProperties? properties);
    Task ForbidAsync(AuthenticationProperties? properties);
}

The result of the validation is an AuthenticateResult object. It is worth noting that it also provides a static method NoResult() to return no result. The static method Fail() generates a result indicating verification exception, and Success() needs to provide verification tickets.

After verification, a verification result containing the requester's ticket will be returned.

namespace Microsoft.AspNetCore.Authentication
{
    public class AuthenticateResult
    {
        // ......
        public static AuthenticateResult NoResult()
        {
            return new AuthenticateResult() { None = true };
        }
        public static AuthenticateResult Fail(Exception failure)
        {
            return new AuthenticateResult() { Failure = failure };
        }
        public static AuthenticateResult Success(AuthenticationTicket ticket)
        {
            if (ticket == null)
            {
                throw new ArgumentNullException(nameof(ticket));
            }
            return new AuthenticateResult() { Ticket = ticket, Properties = ticket.Properties };
        }
        public static AuthenticateResult Success(AuthenticationTicket ticket)
        {
            if (ticket == null)
            {
                throw new ArgumentNullException(nameof(ticket));
            }
            return new AuthenticateResult() { Ticket = ticket, Properties = ticket.Properties };
        }
        // ......
    }
}

View the authenticeresult source code in GitHub

So where does the verified information come from? In addition to the three operations described above, an initialized operation Initialize is required to provide the context information of the current request through this method.

View the IAuthenticationHandler definition in GitHub

Authentication interface supporting login and logout operations

Sometimes, we also want to provide logout operations. The interface for adding logout operations is called IAuthenticationSignOutHandler.

public interface IAuthenticationSignOutHandler : IAuthenticationHandler
{
    Task SignOutAsync(AuthenticationProperties? properties);
}

View the IAuthenticationSignOutHandler source code in GitHub

On the basis of logout, if you want to provide login operation, it is the IAuthenticationSignInHandler interface.

public interface IAuthenticationSignInHandler : IAuthenticationSignOutHandler
{
    Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties);
}

View the IAuthenticationSignInHandler source code in GitHub

Implements the abstract base class AuthenticationHandler supported by authentication

It is troublesome to implement the interface directly. In the namespace Microsoft AspNetCore. Under authentication, Microsoft provides an abstract base class AuthenticationHandler to facilitate the development of authentication controller. Other controllers can derive from this controller to obtain the services it provides.

namespace Microsoft.AspNetCore.Authentication
{
    public abstract class AuthenticationHandler<TOptions> : IAuthenticationHandler where TOptions : AuthenticationSchemeOptions, new()
    {
         protected AuthenticationHandler(IOptionsMonitor<TOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
        {
            Logger = logger.CreateLogger(this.GetType().FullName);
            UrlEncoder = encoder;
            Clock = clock;
            OptionsMonitor = options;
        }
    }
    // ......
}

As you can see from the definition of the class, it uses generics. Each controller should have a configuration option corresponding to the controller. Specify the configuration type used by the verification processor through generics. In the constructor, you can see that it is used to obtain the corresponding configuration option object.

View the source code of AuthenticationHandler in GitHub

Through InitializeAsync(), verify that the processor can obtain the context object HttpContext of the current request.

public async Task InitializeAsync(AuthenticationScheme scheme, HttpContext context)

Finally, as the of the abstract class, you want the derived class to complete the verification task. The abstract method HandleAuthenticateAsync() provides an extension point.

/// <summary>
/// Allows derived types to handle authentication.
/// </summary>
/// <returns>The <see cref="AuthenticateResult"/>.</returns>
protected abstract Task<AuthenticateResult> HandleAuthenticateAsync();

The result of validation is an AuthenticateResult.

Denial of service is much simpler, providing a default implementation directly in this abstract base class. HTTP 403 is returned directly.

protected virtual Task HandleForbiddenAsync(AuthenticationProperties properties)
{
    Response.StatusCode = 403;
    return Task.CompletedTask;
}

The remaining one, too, provides a default implementation. Directly return the HTTP 401 response.

protected virtual Task HandleChallengeAsync(AuthenticationProperties properties)
{
    Response.StatusCode = 401;
    return Task.CompletedTask;
}

How is the Jwt validation processor implemented?

For JWT, login and logout are not involved, so it needs to be derived from the abstract base class AuthenticationHandler that implements the IAuthenticationHandler interface. JwtBearerHandler derived from AuthenticationHandler implements JwtBearerOptions based on its own configuration options. Therefore, the class definition becomes as follows, and the constructor obviously meets the requirements of the abstract base class.

namespace Microsoft.AspNetCore.Authentication.JwtBearer
{
    public class JwtBearerHandler : AuthenticationHandler<JwtBearerOptions>
    {
        public JwtBearerHandler(
            IOptionsMonitor<JwtBearerOptions> options, 
            ILoggerFactory logger, 
            UrlEncoder encoder, 
            ISystemClock clock)
            : base(options, logger, encoder, clock)
        { }
        // ......
    }
}

View the source code of JwtBearerHandler in GitHub

The real authentication is implemented in HandleAuthenticateAsync(). Are you familiar with the following code? Get the attached JWT access token from the request header, and then verify the validity of the token. The core code is as follows.

string authorization = Request.Headers[HeaderNames.Authorization];

// If no authorization header found, nothing to process further
if (string.IsNullOrEmpty(authorization))
{
    return AuthenticateResult.NoResult();
}

if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
    token = authorization.Substring("Bearer ".Length).Trim();
}

// If no token found, no further work possible
if (string.IsNullOrEmpty(token))
{
    return AuthenticateResult.NoResult();
}

// ......
principal = validator.ValidateToken(token, validationParameters, out validatedToken);

View the source code of JwtBearerHandler in GitHub

Register Jwt authentication processor

In ASP Net core, you can use various authentication processors, not just one. The authentication controller needs a name, which is regarded as the name of the authentication Schema. The default name of the Jwt validation pattern is "Bearer", which is passed through the string constant jwtbearerdefaults Authenticationscheme definition.

namespace Microsoft.AspNetCore.Authentication.JwtBearer
{
    /// <summary>
    /// Default values used by bearer authentication.
    /// </summary>
    public static class JwtBearerDefaults
    {
        /// <summary>
        /// Default value for AuthenticationScheme property in the JwtBearerAuthenticationOptions
        /// </summary>
        public const string AuthenticationScheme = "Bearer";
    }
}

View the source code of JwtBearerDefaults in GitHub

Finally, the Jwt authentication controller is registered in the dependency injection container through the extension method AddJwtBearer() of AuthenticationBuilder.

public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder)
            => builder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, _ => { });

public static AuthenticationBuilder AddJwtBearer(
    this AuthenticationBuilder builder, 
    string authenticationScheme, 
    string displayName, 
    Action<JwtBearerOptions> configureOptions)
{
            builder.Services.TryAddEnumerable(
                ServiceDescriptor.Singleton<IPostConfigureOptions<JwtBearerOptions>, 
                JwtBearerPostConfigureOptions>());
            return builder.AddScheme<JwtBearerOptions, JwtBearerHandler>(
                authenticationScheme, displayName, configureOptions);
}

View the source code of JwtBearerExtensions extension method in GitHub

Validate Schema

A verification processor, together with the corresponding verification configuration options, we give it a name and combine it to form a verification architecture Schema. In ASP Net core, you can register multiple authentication schemas. For example, an authorization policy can use the name of the Schema to specify the authentication Schema used to use a specific authentication method. When configuring authentication, the default authentication Schema is usually set. When no validation Schema is specified, the default Schema will be used for processing.

just so so

  • Different authentication architectures are used for authenticate, challenge, and forbid operations

  • Use policies to combine multiple authentication schemas

The registered authentication mode eventually becomes authentication scheme and is registered in the dependency injection service.

public class AuthenticationScheme
{
    public string Name { get; }
    public string? DisplayName { get; }
    
    [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
    public Type HandlerType { get; }
}

View the source code of AuthenticationScheme in GitHub

Use authentication processor

IAuthenticationSchemeProvider

Various authentication schemas are saved to an IAuthenticationSchemeProvider.

public interface IAuthenticationSchemeProvider
{
    Task<IEnumerable<AuthenticationScheme>> GetAllSchemesAsync();
    Task<AuthenticationScheme?> GetSchemeAsync(string name);
    void AddScheme(AuthenticationScheme scheme);
    void RemoveScheme(string name);
}

View the IAuthenticationSchemeProvider source code in GitHub

IAuthenticationHandlerProvider

The final use is realized through IAuthenticationHandlerProvider. The corresponding authentication controller can be obtained through the string name of an authentication mode.

public interface IAuthenticationHandlerProvider
{
    Task<IAuthenticationHandler?> GetHandlerAsync(HttpContext context, string authenticationScheme);
}

View the IAuthenticationHandlerProvider source code in GitHub

Its default implementation is AuthenticationHandlerProvider, and the source code is not complex.

public class AuthenticationHandlerProvider : IAuthenticationHandlerProvider
{
    public IAuthenticationSchemeProvider Schemes { get; }
    private readonly Dictionary<string, IAuthenticationHandler> _handlerMap 
        = new Dictionary<string, IAuthenticationHandler>(StringComparer.Ordinal);
    
    public AuthenticationHandlerProvider(IAuthenticationSchemeProvider schemes)
    {
        Schemes = schemes;
    }
    
    public async Task<IAuthenticationHandler?> GetHandlerAsync(HttpContext context, string authenticationScheme)
    {
        if (_handlerMap.TryGetValue(authenticationScheme, out var value))
        {
            return value;
        }

        var scheme = await Schemes.GetSchemeAsync(authenticationScheme);
        if (scheme == null)
        {
            return null;
        }
        var handler = (context.RequestServices.GetService(scheme.HandlerType) ??
           ActivatorUtilities.CreateInstance(context.RequestServices, scheme.HandlerType))
            as IAuthenticationHandler;
        if (handler != null)
        {
            await handler.InitializeAsync(scheme, context);
            _handlerMap[authenticationScheme] = handler;
        }
        return handler;
    }
}

View the source code of AuthenticationHandlerProvider in GitHub

Authentication Middleware

The processing of verification middleware is not so complex.

Find the default authentication mode and use the name of the default authentication mode to obtain the corresponding authentication processor. If the authentication is successful, put the principal of the current requesting User on the User of the current request context.

There is also a special section of code to find out which authentication processors implement IAuthenticationHandlerProvider, and call them in turn to see if it is necessary to extract the termination request processing process.

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Authentication
{
    public class AuthenticationMiddleware
    {
        private readonly RequestDelegate _next;

        public AuthenticationMiddleware(RequestDelegate next, IAuthenticationSchemeProvider schemes)
        {
            if (next == null)
            {
                throw new ArgumentNullException(nameof(next));
            }
            if (schemes == null)
            {
                throw new ArgumentNullException(nameof(schemes));
            }

            _next = next;
            Schemes = schemes;
        }

        public IAuthenticationSchemeProvider Schemes { get; set; }

        public async Task Invoke(HttpContext context)
        {
            context.Features.Set<IAuthenticationFeature>(new AuthenticationFeature
            {
                OriginalPath = context.Request.Path,
                OriginalPathBase = context.Request.PathBase
            });

            // Give any IAuthenticationRequestHandler schemes a chance to handle the request
            var handlers = context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
            foreach (var scheme in await Schemes.GetRequestHandlerSchemesAsync())
            {
                var handler = await handlers.GetHandlerAsync(context, scheme.Name) as IAuthenticationRequestHandler;
                if (handler != null && await handler.HandleRequestAsync())
                {
                    return;
                }
            }

            var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync();
            if (defaultAuthenticate != null)
            {
                var result = await context.AuthenticateAsync(defaultAuthenticate.Name);
                if (result?.Principal != null)
                {
                    context.User = result.Principal;
                }
            }

            await _next(context);
        }
    }
}

View the source code of AuthenticationMiddle in GitHub

reference material

  • https://docs.microsoft.com/en-us/aspnet/core/security/authentication/?view=aspnetcore-5.0

Keywords: ASP.NET C# .NET microsoft

Added by Scifa on Fri, 07 Jan 2022 04:25:46 +0200