In this chapter, we'll implement authentication and authorization in our Q&A app. We will use a popular service called Auth0, which implements OpenID Connect (OIDC), to help us to do this. We will start by understanding what OIDC is and why it is a good choice, before getting our app to interact with Auth0.
At the moment, our web API is accessible by unauthenticated users, which is a security vulnerability. We will resolve this vulnerability by protecting the necessary endpoints with simple authorization. This will mean that only authenticated users can access protected resources.
Authenticated users shouldn't have access to everything, though. We will learn how to ensure authenticated users only get access to what they are allowed to by using custom authorization policies.
We'll also learn how to get details about authenticated users so that we can include them when questions and answers are saved to the database.
We will end the chapter by enabling cross-origin requests in preparation for allowing our frontend to access the REST API.
In this chapter, we'll cover the following topics:
We'll use the following tools and services in this chapter:
All of the code snippets in this chapter can be found online at https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition. To restore code from a chapter, the source code repository can be downloaded and the relevant folder opened in the relevant editor. If the code is frontend code, then npm install can be entered in the terminal to restore the dependencies.
Check out the following video to see the Code in Action: http://bit.ly/2EPQ8DY
Before we cover OIDC, let's make sure we understand authentication and authorization. Authentication verifies that the user is who they say they are. In our app, the user will enter their email and password to prove who they are. Authorization decides whether a user has permission to access a resource. In our app, some of the REST API endpoints, such as posting a question, will eventually be protected by authorization checks.
OIDC is an industry-standard way of handling both authentication and authorization as well as other user-related operations. This works well for a wide variety of architectures, including single-page applications (SPAs) such as ours where there is a JavaScript client and a server-side REST API that need to be secured.
The following diagram shows the high-level flow of a user of our app being authenticated and then gaining access to protected resources in the REST API:
Here are some more details of the steps that take place:
Notice that our app never handles user credentials. When user authentication is required, the user will be redirected to the identity provider to carry out this process. Our app only ever deals with a secure token, which is referred to as an access token, which is a long-encoded string. This token is in JSON Web Token (JWT) format, which again is industry-standard.
The content of a JWT can be inspected using the https://jwt.io/ website. We can paste a JWT into the Encoded box and then the site puts the decoded JWT in the Decoded box, as shown in the following screenshot:
There are three parts to a JWT, separated by dots, and they appear as different colors in jwt.io:
The header usually contains the type of the token in a typ field and the signing algorithm being used in an alg field. So, the preceding token is a JWT that uses an RSA signature with the SHA-256 asymmetric algorithm. There is also a kid field in the header, which is an opaque identifier that can be used to identify the key that was used to sign the JWT.
The payload of JWTs vary but the following fields are often included:
OIDC deals with securely storing passwords, authenticating users, generating access tokens, and much more. Being able to leverage an industry-standard technology such as OIDC not only saves us lots of time but also gives us the peace of mind that the implementation is very secure and will receive updates as attackers get smarter.
What we have just learned is implemented by Auth0. We'll start to use Auth0 in the next section.
We are going to use a ready-made identity service called Auth0 in our app. Auth0 implements OIDC and is also free for a low number of users. Using Auth0 will allow us to focus on integrating with an identity service rather than spending time building our own.
In this section, we are going to set up Auth0 and integrate it into our ASP.NET backend.
Let's carry out the following steps to set up Auth0 as our identity provider:
The Default Audience option is in the API Authorization Settings section. Change this to https://qanda:
This tells Auth0 to add https://qanda to the aud payload field in the JWT it generates. This setting triggers Auth0 to generate access tokens in JWT format. Our ASP.NET backend will also check that access tokens contain this data before granting access to protected resources.
The name can be anything we choose, but the Identifier setting must match the default audience we set on the tenant. Make sure Signing Algorithm is set to RS256 and then click the CREATE button.
That completes the setup of Auth0.
Next, we will integrate our ASP.NET backend with Auth0.
We can now change our ASP.NET backend to authenticate with Auth0. Let's open the backend project in Visual Studio and carry out the following steps:
Microsoft.AspNetCore.Authentication.JwtBearer
Important Note
Make sure the version of the package you select is supported by the version of .NET you are using. So, for example, if you are targeting .NET 5.0, then select package version 5.0.*.
using Microsoft.AspNetCore.Authentication.JwtBearer;
Add the following lines to the ConfigureServices method in the Startup class:
public void ConfigureServices(IServiceCollection services)
{
...
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme =
JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme =
JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
options.Authority =
Configuration["Auth0:Authority"];
options.Audience =
Configuration["Auth0:Audience"];
});
}
This adds JWT-based authentication specifying the authority and expected audience as the appsettings.json settings.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
...
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
...
}
This will validate the access token in each request if one exists. If the check succeeds, the user on the request context will be set.
{
...,
"Auth0": {
"Authority": "https://your-tentant-id.auth0.com/",
"Audience": "https://qanda"
}
}
We will need to substitute our Auth0 tenant ID into the Authority field. The tenant ID can be found in Auth0 to the left of the user avatar:
So, Authority for the preceding tenant is https://your-tenant-id.auth0.com/. The Audience field needs to match the audience we specified in Auth0.
Our web API is now validating access tokens in the requests.
Let's quickly recap what we have done in this section. We told our identity provider the path to our frontend and the paths for signing in and out. Identity providers often provide an administration page for us to supply this information. We also told ASP.NET to validate the bearer token in a request using the UseAuthentication method in the Configure method in the Startup class. The validation is configured using the AddAuthentication method in ConfigureServices.
We are going to start protecting some endpoints in the next section.
We are going to start this section by protecting the questions endpoint for adding, updating, and deleting questions as well as posting answers so that only authenticated users can do these operations. We will then move on to implement and use a custom authorization policy so that only the author of the question can update or delete it.
Let's protect the questions endpoint for the POST, PUT, and DELETE HTTP methods by carrying out these steps:
using Microsoft.AspNetCore.Authorization;
[Authorize]
[HttpPost]
public async ... PostQuestion(QuestionPostRequest questionPostRequest)
...
[Authorize]
[HttpPut("{questionId}")]
public async ... PutQuestion(int questionId, QuestionPutRequest questionPutRequest)
...
[Authorize]
[HttpDelete("{questionId}")]
public async ... DeleteQuestion(int questionId)
...
[Authorize]
[HttpPost("answer")]
public async ... PostAnswer(AnswerPostRequest answerPostRequest)
...
We receive a response with status code 401 Unauthorized. This shows that this action method is now protected.
So, once the authentication middleware is in place, the Authorize attribute protects action methods. If a whole controller needs to be protected, the Authorize attribute can decorate the controller class:
[Authorize]
[Route("api/[controller]")]
[ApiController]
public class QuestionsController : ControllerBase
All of the action methods in the controller will then be protected without having to specify the Authorize attribute. We can also unprotect action methods in a protected controller by using the AllowAnonymous attribute:
[AllowAnonymous]
[HttpGet]
public IEnumerable<QuestionGetManyResponse> GetQuestions(string search, bool includeAnswers, int page = 1, int pageSize = 20)
So, in our example, we could have protected the whole controller using the Authorize attribute and unprotected the GetQuestions, GetUnansweredQuestions, and GetQuestion action methods with the AllowAnonymous attribute to achieve the behavior we want.
Next, we are going to learn how to implement a policy check with endpoint authorization.
At the moment, any authenticated user can update or delete questions. We are going to implement and use a custom authorization policy and use it to enforce that only the author of the question can do these operations. Let's carry out the following steps:
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Authorization;
using QandA.Authorization;
Note that the reference to the QandA.Authorization namespace doesn't exist yet. We'll implement this in a later step.
public void ConfigureServices(IServiceCollection services)
{
...
services.AddHttpClient();
}
The authorization policy has its requirements defined in a class called MustBeQuestionAuthorRequirement, which we'll implement in a later step.
public void ConfigureServices(IServiceCollection services)
{
...
services.AddHttpClient();
services.AddAuthorization(options =>
options.AddPolicy("MustBeQuestionAuthor", policy
=>
policy.Requirements
.Add(new MustBeQuestionAuthorRequirement())));
}
The authorization policy has its requirements defined in a class called MustBeQuestionAuthorRequirement, which we'll implement in a later step.
public void ConfigureServices(IServiceCollection services)
{
...
services.AddHttpClient();
services.AddAuthorization(...);
services.AddScoped<
IAuthorizationHandler,
MustBeQuestionAuthorHandler>();
}
So, the handler for MustBeQuestionAuthorRequirement will be implemented in a class called MustBeQuestionAuthorHandler.
public void ConfigureServices(IServiceCollection services)
{
...
services.AddHttpClient();
services.AddAuthorization(...);
services.AddScoped<
IAuthorizationHandler,
MustBeQuestionAuthorHandler>();
services.AddHttpContextAccessor();
}
Note that AddHttpContextAccessor is a convenience method for AddSingleton<IHttpContextAccessor,HttpContextAccessor>.
using Microsoft.AspNetCore.Authorization;
namespace QandA.Authorization
{
public class MustBeQuestionAuthorRequirement:
IAuthorizationRequirement
{
public MustBeQuestionAuthorRequirement()
{
}
}
}
using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using QandA.Data;
namespace QandA.Authorization
{
public class MustBeQuestionAuthorHandler:
AuthorizationHandler<MustBeQuestionAuthorRequirement>
{
private readonly IDataRepository _dataRepository;
private readonly IHttpContextAccessor
_httpContextAccessor;
public MustBeQuestionAuthorHandler(
IDataRepository dataRepository,
IHttpContextAccessor httpContextAccessor)
{
_dataRepository = dataRepository;
_httpContextAccessor = httpContextAccessor;
}
protected async override Task
HandleRequirementAsync(
AuthorizationHandlerContext context,
MustBeQuestionAuthorRequirement requirement)
{
// TODO - check that the user is authenticated
// TODO - get the question id from the request
// TODO - get the user id from the name
// identifier claim
// TODO - get the question from the data
// repository
// TODO - if the question can't be found go to
// the next piece of middleware
// TODO - return failure if the user id in the
// question from the data repository is
// different to the user id in the request
// TODO - return success if we manage to get
// here
}
}
}
This inherits from the AuthorizationHandler class, which takes in the requirement it is handling as a generic parameter. We have injected the data repository and the HTTP context into the class.
protected async override Task
HandleRequirementAsync(
AuthorizationHandlerContext context,
MustBeQuestionAuthorRequirement requirement)
{
if (!context.User.Identity.IsAuthenticated)
{
context.Fail();
return;
}
// TODO - get the question id from the request
// TODO - get the user id from the name identifier
// claim
// TODO - get the question from the data repository
// TODO - if the question can't be found go to the
// next piece of middleware
// TODO - return failure if the user id in the
// question from the data repository is different
// to the user id in the request
// TODO - return success if we manage to get here
}
The context parameter in the method contains information about the user's identity in an Identity property. We use the IsAuthenticated property within the Identity object to determine whether the user is authenticated or not. We call the Fail method on the context argument to tell it that the requirement failed.
protected async override Task
HandleRequirementAsync(
AuthorizationHandlerContext context,
MustBeQuestionAuthorRequirement requirement)
{
if (!context.User.Identity.IsAuthenticated)
{
context.Fail();
return;
}
var questionId =
_httpContextAccessor.HttpContext.Request
.RouteValues["questionId"];
int questionIdAsInt = Convert.ToInt32(questionId);
// TODO - get the user id from the name identifier
// claim
// TODO - get the question from the data repository
// TODO - if the question can't be found go to the
//next piece of middleware
// TODO - return failure if the user id in the
//question from the data repository is different
// to the user id in the request
// TODO - return success if we manage to get here
}
We use the RouteValues dictionary within the HTTP context request to get access to get the question ID. The RoutesValues dictionary contains the controller name, the action method name, as well as the parameters for the action method.
protected async override Task
HandleRequirementAsync(
AuthorizationHandlerContext context,
MustBeQuestionAuthorRequirement requirement)
{
...
var questionId =
_httpContextAccessor.HttpContext.Request
.RouteValues["questionId"];
int questionIdAsInt = Convert.ToInt32(questionId);
var userId =
context.User.FindFirst(ClaimTypes.NameIdentifier).
Value;
// TODO - get the question from the data repository
// TODO - if the question can't be found go to the
// next piece of middleware
// TODO - return failure if the user id in the
//question from the data repository is different
// to the user id in the request
// TODO - return success if we manage to get here
}
userId is stored in the name identifier claim.
Important Note
A claim is information about a user from a trusted source. A claim represents what the subject is, not what the subject can do. The ASP.NET authentication middleware automatically puts userId in a name identifier claim for us.
We have used the FindFirst method on the User object from the context parameter to get the value of the name identifier claim. The User object is populated with the claims by the authentication middleware earlier in the request pipeline after it has read the access token.
protected async override Task
HandleRequirementAsync(
AuthorizationHandlerContext context,
MustBeQuestionAuthorRequirement requirement)
{
...
var userId =
context.User.FindFirst(ClaimTypes.NameIdentifier).Value;
var question =
await _dataRepository.GetQuestion(questionIdAsInt);
if (question == null)
{
// let it through so the controller can return a 404
context.Succeed(requirement);
return;
}
// TODO - return failure if the user id in the
//question from the data repository is different
// to the user id in the request
// TODO - return success if we manage to get here
}
protected async override Task
HandleRequirementAsync(
AuthorizationHandlerContext context,
MustBeQuestionAuthorRequirement requirement)
{
...
var question =
await _dataRepository.GetQuestion(questionIdAsInt);
if (question == null)
{
// let it through so the controller can return
// a 404
context.Succeed(requirement);
return;
}
if (question.UserId != userId)
{
context.Fail();
return;
}
context.Succeed(requirement);
}
[Authorize(Policy = "MustBeQuestionAuthor")]
[HttpPut("{questionId}")]
public ... PutQuestion(int questionId, QuestionPutRequest questionPutRequest)
...
[Authorize(Policy = "MustBeQuestionAuthor")]
[HttpDelete("{questionId}")]
public ... DeleteQuestion(int questionId)
...
We have now applied our authorization policy to updating and deleting a question.
Unfortunately, we can't use the test access token that Auth0 gives us to try this out but we will circle back to this and confirm that it works in Chapter 12, Interacting with RESTful APIs.
Custom authorization policies give us lots of flexibility and power to implement complex authorization rules. As we have just experienced in our example, a single policy can be implemented centrally and used on different action methods.
Let's quickly recap what we have learned in this section:
In the next section, we will learn how to reference information about the authenticated user in an API controller.
Now that our REST API knows about the user interacting with it, we can use this to post the correct user against questions and answers. Let's carry out the following steps:
using System.Security.Claims;
using Microsoft.Extensions.Configuration;
using System.Net.Http;
using System.Text.Json;
public async ...
PostQuestion(QuestionPostRequest
questionPostRequest)
{
var savedQuestion =
await _dataRepository.PostQuestion(new
QuestionPostFullRequest
{
Title = questionPostRequest.Title,
Content = questionPostRequest.Content,
UserId =
User.FindFirst(ClaimTypes.NameIdentifier).Value,
UserName = "[email protected]",
Created = DateTime.UtcNow
});
...
}
ControllerBase contains a User property that gives us information about the authenticated user, including the claims. So, we use the FindFirst method to get the value of the name identifier claim.
namespace QandA.Data.Models
{
public class User
{
public string Name { get; set; }
}
}
Note that there is more user information that we can get from Auth0 but we are only interested in the username in our app.
...
private readonly IHttpClientFactory _clientFactory;
private readonly string _auth0UserInfo;
public QuestionsController(
...,
IHttpClientFactory clientFactory,
IConfiguration configuration)
{
...
_clientFactory = clientFactory;
_auth0UserInfo =
$"{configuration["Auth0:Authority"]}userinfo";
}
private async Task<string> GetUserName()
{
var request = new HttpRequestMessage(
HttpMethod.Get,
_auth0UserInfo);
request.Headers.Add(
"Authorization",
Request.Headers["Authorization"].First());
var client = _clientFactory.CreateClient();
var response = await client.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jsonContent =
await response.Content.ReadAsStringAsync();
var user =
JsonSerializer.Deserialize<User>(
jsonContent,
new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return user.Name;
}
else
{
return "";
}
}
We make a GET HTTP request to the Auth0 user information endpoint with the Authorization HTTP header from the current request to the ASP.NET backend. This HTTP header will contain the access token that will give us access to the Auth0 endpoint.
If the request is successful, we parse the response body into our User model. Notice that we use the new JSON serializer in .NET. Notice also that we specify case-insensitive property mapping so that the camel case fields in the response map correctly to the title case properties in the class.
public async ... PostQuestion(QuestionPostRequest questionPostRequest)
{
var savedQuestion = await
_dataRepository.PostQuestion(new
QuestionPostFullRequest
{
Title = questionPostRequest.Title,
Content = questionPostRequest.Content,
UserId =
User.FindFirst(ClaimTypes.NameIdentifier).Value,
UserName = await GetUserName(),
Created = DateTime.UtcNow
});
...
}
[Authorize]
[HttpPost("answer")]
public ActionResult<AnswerGetResponse> PostAnswer(AnswerPostRequest answerPostRequest)
{
...
var savedAnswer = _dataRepository.PostAnswer(new
AnswerPostFullRequest
{
QuestionId = answerPostRequest.QuestionId.Value,
Content = answerPostRequest.Content,
UserId =
User.FindFirst(ClaimTypes.NameIdentifier).Value,
UserName = await GetUserName(),
Created = DateTime.UtcNow
});
...
}
Unfortunately, we can't use the test access token that Auth0 gives us to try this out because it doesn't have a user associated with it. However, we will circle back to this and confirm that it works in Chapter 12, Interacting with RESTful APIs.
Our question controller is interacting with the authenticated user nicely now.
To recap, information about the authenticated user is available in a User property within an API controller. The information in the User property is limited to the information contained in the JWT. Additional information can be obtained by requesting it from the relevant endpoint in the identity service provider.
CORS stands for Cross-Origin Resource Sharing and is a mechanism that uses HTTP headers to tell a browser to let a web application run at certain origins (domains) so that it has permission to access certain resources on a server at a different origin.
In this section, we will start by trying to access our REST API from a browser application and discover that it isn't accessible. We will then add and configure CORS in the REST API and verify that it is accessible from a browser application.
Let's carry out the following steps:
public void ConfigureServices(IServiceCollection services)
{
...
services.AddCors(options =>
options.AddPolicy("CorsPolicy", builder =>
builder
.AllowAnyMethod()
.AllowAnyHeader()
.WithOrigins(Configuration["Frontend"])));
}
This has defined a CORS policy that allows origins specified in appsettings.json to access the REST API. It also allows requests with any HTTP method and any HTTP header.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
...
app.Routing();
app.UseCors("CorsPolicy");
app.UseAuthentication();
...
}
{
...,
"Frontend": "https://resttesttest.com"
}
{
...,
"Frontend": "http://localhost:3000"
}
CORS is straightforward to add in ASP.NET. First, we create a policy and use this in the request pipeline. It is important that the UseCors method is placed between the UseRouting and UseEndpoint methods in the Configure method for it to function correctly.
Auth0 is an OIDC identity provider that we can leverage to authenticate and authorize clients. An access token in JWT format is available from an identity provider when a successful sign-in has been made. An access token can be used in requests to access protected resources.
ASP.NET can validate JWTs by first using the AddAuthentication method in the ConfigureServices method in the Startup class and then UseAuthentication in the Configure method.
Once authentication has been added to the request pipeline, REST API resources can be protected by decorating the controller and action methods using the Authorize attribute. Protected action methods can then be unprotected by using the AllowAnonymous attribute. We can access information about a user, such as their claims, via a controller's User property.
Custom policies are a powerful way to allow a certain set of users to get access to protected resources. Requirement and handler classes must be implemented that define the policy logic. The policy can be applied to an endpoint using the Authorize attribute by passing in the policy name as a parameter.
ASP.NET disallows cross-origin requests out of the box. We are required to add and enable a CORS policy for the web clients that require access to the REST API.
Our backend is close to completion now. In the next chapter, we'll turn our attention back to the frontend and start to interact with the backend we have built.
Let's answer the following questions to practice what we have learned in this chapter:
public void Configure(...)
{
...
app.UseEndpoints(...);
app.UseAuthentication();
}
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme =
JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme =
JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
...
options.Audience = "https://myapp";
});
When we try to access protected resources in our ASP.NET backend, we receive an HTTP 401 status code. What is the problem here?
{
"nbf": 1609671475,
"auth_time": 1609671475,
"exp": 1609757875,
...
}
Tip: You can decode the Unix dates using this website: https://www.unixtimestamp.com/index.php.
Authorisation: bearer some-access-token
We receive an HTTP 401 status code from the request, though. What is the problem?
private readonly IHttpContextAccessor _httpContextAccessor;
public MyClass(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public SomeMethod()
{
var request = _httpContextAccessor.HttpContext.Request;
}
The HttpContextAccessor service must be added to the ConfigureServices method in the Startup class, as follows:
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
User.FindFirst(ClaimTypes.NameIdentifier).Value
Here are some useful links to learn more about the topics covered in this chapter:
18.189.2.122