Bonus 6 - Mastering ASP - NET Core Security
Bonus 6 - Mastering ASP - NET Core Security
5.1 Configuring the IdentityServer to Use the Authorization Code Flow .......... 27
5.2 Securing the Client Application with the Authorization Code Flow ............. 28
Now, we may wonder what the security solution for this landscape is – when
we have a public API and multiple third-party clients? This leads us to token-
based security. A token is related to consent – the user grants consent to a
1
client application to access the API on behalf of that user. Then, the token is
sent with the HTTP requests and not the username and password.
The first one is to no longer use client credentials at the application level
because this should be handled on a centralized server. The second thing is
to ensure that tokens are safe enough for the authentication and
authorization actions for different types of applications.
That said, all of these questions lead us to the centralized Identity Provider
that should be responsible for the token handling and proving the user’s
identity.
We should point out that using Identity Server4, OAuth2 and OIDC will help
us with the authentication and authorization actions, but for the user
management actions, we have to integrate ASP.NET Core Identity at the
centralized level as well.
So, a lot of ground is ahead of us, but if you follow along with this book and
its examples, we are sure you will master security actions in ASP.NET Core
applications.
We’ll help you do that by using both IdentityServer4 and Duende. Since
the implementation is almost the same for both, we will point out the main
differences that you need to pay attention to.
2
Before we start learning about OAuth and OpenID Connect, we have to
understand what a token is. If we want to access a protected resource, the
first thing we have to do is to retrieve a token. When we talk about token-
based security, most of the time we refer to the JSON web token (JWT).
We’ve learned about JWT in our Ultimate ASP.NET Core Web API book, but
let’s just recall a couple of things.
For secure data transmission, we use JSON objects. A JWT consist of three
basic parts: the header, the payload, and the signature. The header contains
information like the type of token and the name of the algorithm. The payload
contains some attributes about the logged-in user. For example, it can
contain the user id, subject and information whether a user is an
administrator. Finally, we have the signature part. Usually, the server uses
this signature part to verify whether the token contains valid information –
the information that the server is issuing.
3
After the token validation, the API provides data to the client
application and that client application returns data to the user.
As we can see, we are not using our credentials with the third-party
application. Instead, we use a token, which is certainly a more secure way.
OAuth2 and OpenID Connect are protocols that allow us to build more
secure applications. OAuth stands for Open standard for Authorization. It is
the industry-standard protocol for authorization. It delegates user
authentication to the service that hosts the user’s account and authorizes
third-party applications to access that account. It provides different flows for
our applications, whether they are web, desktop, or mobile applications.
Additionally, it defines how a client application can securely get a token from
the token provider and use it for authorization actions.
4
• The client application creates a request which redirects the user to the
IDP, where the user proves their identity by providing their username
and password. As we can see here, the user’s credentials are not sent
via request, but rather the user provides the username and password
at the IDP level.
• After the verification process, IDP creates the id token, signs it, and
sends it back to the client. This token contains user verification data.
• Finally, the client application gets the id token and validates it.
There is one more thing to mention here. The client application uses the
id token to create claims identity and store these claims in a cookie.
As we can see, the user can authenticate using the user credentials. But, the
client can do the same with the client credentials (client_id and
client_secret). If a client is capable of maintaining the confidentiality of its
credentials and can safely authenticate – that client is considered a
confidential client. It is a confidential client because it stores its credentials
on the server inaccessible to the user. For example, an ASP.NET Core MVC
application is a confidential client.
5
It is quite important to keep in mind that there is great documentation
regarding OAuth 2.0 – RFC 6749 that makes it a lot easier to understand
OAuth-related topics. One of those topics is related to OAuth endpoints. So,
let’s inspect these endpoints:
• Implicit Flow
• Authorization Code
• Resource Owner Password Credentials
• Client Credentials
• Hybrid (mix of Authorization Code and Implicit Flow)
The flow determines how the token is returned to the client and each flow
has its specifics.
You can read more about these flows in the documentation mentioned above.
6
In this book, you will learn how to use the Authorization Code Flow with
Proof Key for Code Exchange (PKCE, pronounced pixie) to provide a high
level of security for our web application and our API. It is also a
recommended flow for web applications.
7
Before we start, we have to install the IdentityServer4 templates that help
us speed up the creation process. We are going to use only the basic
templates for the empty IDP application and basic UI files, nothing more
than that because we want to explain every single step of the security
implementation. After you learn all the steps in detail, you can use other
templates to create projects with additional features out of the box.
The result:
8
We can see multiple templates installed with the Short Name values that we
can use to create our projects.
After creation completes, let’s open the project and modify the
launchsettings.json file:
{
"profiles": {
"SelfHost": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:5005"
}
}
}
Since the created project from a template targets .NET Core 3.1, we are
going to update that and the preinstalled libraries.
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="IdentityServer4" Version="4.1.2" />
9
Here, we are targeting .NET 6.0 and using the latest versions of preinstalled
libraries.
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Duende.IdentityServer" Version="6.0.0-preview.2" />
At the moment of writing this book, Duende packages are still in the preview
version. But feel free to update them to the full version once they are
updated. Also, we have information from the Duende developers that they
are planning on releasing .NET 6 templates, so you probably won’t have to
do these modifications at all.
Right after the modification, we can inspect all the files this template
provides us with.
10
We can see this project references ASP.NET Core in the Frameworks section
and it has installed the IdentityServer4 (initially it was 4.1.2) library and
Serilog.ASPNetCore library for logging. It is a similar situation with the
Duende project.
Additionally, we are going to open the Config class and modify it a bit:
As we can see, this is a static class with a couple of properties. For the Ids,
we have added one more resource – IdentityResources.Profile. Identity
resources map to scopes that enable access to identity-related information.
With the OpenId method, support is provided for a subject id or sub value.
With the Profile method, support for information like given_name or
family_name is provided.
11
Now, let’s inspect the Startup.cs class. First, let’s take a look at the
ConfigureServices method:
// not recommended for production - you need to store your key material somewhere secure
builder.AddDeveloperSigningCredential();
For now, we are going to skip the code that is commented out. We’ll get
back to that later. The crucial part is the AddIdentityServer method that
we use to register IdentityServer in our application. With additional
methods, we register identity resources, APIs, and clients inside the in-
memory configuration from the Config class.
In the Configure method, the only thing relevant to us right now is the
app.UseIdentityServer(); expression. This will add the IdentityServer to
the application’s request pipeline.
12
If we want, we can create in-memory user storage to use until we integrate
the ASP.NET Core Identity library for user management actions. To create
such storage, we have to modify the TestUsers class in the QuickStart
folder, by removing current users and adding new ones:
using System.Security.Claims;
using IdentityServer4.Test;
13
{
options.EmitStaticAudienceClaim = true;
})
.AddInMemoryIdentityResources(Config.Ids)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryApiResources(Config.Apis)
.AddInMemoryClients(Config.Clients)
.AddTestUsers(TestUsers.Users);
Great. Let’s start the application and inspect the console logs:
14
As the URI states, this is the OpenID Configuration. Here, we can see who
the issuer is, different endpoints, supported claims and scopes, etc.
So, what this does is create additional folders with controllers, views, and
static files:
15
Now, we have to uncomment the code inside the ConfigureServices method:
// not recommended for production - you need to store your key material somewhere secure
builder.AddDeveloperSigningCredential();
}
app.UseStaticFiles();
app.UseRouting();
app.UseIdentityServer();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapDefaultControllerRoute();
16
});
}
For Duende, we are going to see a bit different screen with the same links:
17
Well, this is a lot better now. In addition to this page, you can click these
links to see the configuration page and the login page as well.
We’ve already seen the configuration, so let’s click the first “here” link:
We can see the Login form. Once we enter the credentials from the
TestUsers class, we will see the claims and properties:
18
In the first part of this book, we talked about different client types –
confidential and public. Of course, we need a client application to consume
our API. Therefore, we are going to create a new confidential client
application.
"profiles": {
"CompanyEmployees.Client": {
"commandName": "Project",
19
"launchBrowser": true,
"applicationUrl": "https://localhost:5010",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
Here, we register our HttpClient service and configure default values for the
base address (our API address) and headers. For the HeaderNames class,
we have to use the Microsoft.Net.Http.Headers namespace. Also, since
this is a new .NET 6 template, we don’t have the Startup class.
We need the ViewModel class, so, let’s create it in the Models folder:
20
Right after that, we are going to create a new action in the same controller
class:
response.EnsureSuccessStatusCode();
return View(companies);
}
We use the _httpClientFactory object to create our API client and use that
client with the GetAsync method to send a request to the API endpoint.
Then, we read the content, convert it to a list and return a view.
@model IEnumerable<CompanyEmployees.Client.Models.CompanyViewModel>
@{
ViewData["Title"] = "Companies";
}
<h1>Companies</h1>
<p>
<a asp-action="Create">Create New</a>
</p>
<table class="table">
<thead>
<tr>
<th>
@Html.DisplayNameFor(model => model.Name)
</th>
<th>
@Html.DisplayNameFor(model => model.FullAddress)
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model) {
<tr>
21
<td>
@Html.DisplayFor(modelItem => item.Name)
</td>
<td>
@Html.DisplayFor(modelItem => item.FullAddress)
</td>
<td>
@Html.ActionLink("Edit", "Edit", new { /* id=item.PrimaryKey */ }) |
@Html.ActionLink("Details", "Details", new { /* id=item.PrimaryKey */ }) |
@Html.ActionLink("Delete", "Delete", new { /* id=item.PrimaryKey */ })
</td>
</tr>
}
</tbody>
</table>
Excellent.
To finish the client creation, let’s modify the _Layout.cshtml file, to include
this view in the menu:
As you can see, we just modify the Home link to the Companies link, nothing
else.
For the API, we are using the project from our main book’s source code. You
can find it in this book’s source code folder called 02-
ClientApplication/CompanyEmployees.API. If you have read our main
book Ultimate ASP.NET Core Web API, you probably have the database
created. If you don’t, all you have to do is modify the connection string in
the appsettings.json file (or leave it as is):
22
"ConnectionStrings": {
"sqlConnection": "server=.; database=CompanyEmployee; Integrated Security=true"
},
After that, you will have your database created and populated with initial
data.
After all of these changes, we can start our API and Client application. As
soon as our client starts, we are going to see the Home screen:
Now, if we click the Companies link, we should be able to see our companies
data:
23
That’s it for now, regarding the client application. We have the API prepared,
the IDP project ready, and the client application consuming our API. Now it’s
time to add the security logic for our applications.
24
In the process of securing our application, we have to create a URI that will
direct us to the Identity Provider project. That URI consists of multiple
elements. Each of them contains important information for the security
process. We have extracted and simplified (for readability) parts of such a
URI:
1. The first part is the /authorization endpoint URI at the level of the
IDP.
2. Next, we have a client_id that is an identifier of our client MVC
application.
3. Then, there is the redirection URI that points to our client application.
This URI is required for our IDP project so that we know where to
deliver the code.
4. The response type states which flow we are using for the security
actions. We are going to use the Authorization Code Flow and the
code response type suggests just that.
5. We have the scopes as well. The application requires access to the
OpenId and Profile scopes. If you remember, we have enabled them at
the IDP level.
25
6. With the help of the response_mode part, we decide in what way we
want to deliver the information to the browser (URI or Form POST)
7. The nonce part is here for validation purposes. IdentityServer will
include this nonce in the identity token while sending it to the client.
Now, we can inspect the diagram, to see how the authorization code flow
works:
As we can see:
Now it’s time to configure the IDP project to support the use of the
authorization code flow. To do that, we have to modify the Config class:
builder.Services.AddAuthentication(opt =>
{
opt.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
opt.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
}).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
28
it will be stored in a cookie, which then can be used for each request to the
web application.
builder.Services.AddAuthentication(opt =>
{
opt.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
opt.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
}).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, opt =>
{
opt.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
opt.Authority = "https://localhost:5005";
opt.ClientId = "companyemployeeclient";
opt.ResponseType = OpenIdConnectResponseType.Code;
opt.SaveTokens = true;
opt.ClientSecret = "CompanyEmployeeClientSecret";
opt.UsePkce = false;
});
29
lives in the Microsoft.IdentityModel.Protocols.OpenIdConnect
namespace.
app.UseAuthentication();
app.UseAuthorization();
[Authorize]
public async Task<IActionResult> Companies()
{
...
}
using Microsoft.AspNetCore.Authorization;
Right now, we can start the API, IDP, and Client application. Once the Home
screen is shown, we can click the Companies link:
30
We get redirected to the Login screen on the IDP level. If we inspect the
URI:
You can also inspect the console logs, to find additional information.
31
We can see that the Client application requests our permission for the data
we specified in the allowed scopes in the configuration (OpenId and Profile).
When we click the Yes button, we are going to see our requested data.
32
We can see the validation process of the authorization code. So, the token
was sent via the back channel to the /token endpoint and the validation
was successful.
To inspect the claims from the token, we are going to modify the Privacy
view file in the client application:
@using Microsoft.AspNetCore.Authentication
<h2>Claims</h2>
<dl>
@foreach (var claim in User.Claims)
{
<dt>@claim.Type</dt>
<dd>@claim.Value</dd>
}
</dl>
<h2>Properties</h2>
<dl>
@foreach (var prop in (await Context.AuthenticateAsync()).Properties.Items)
{
<dt>@prop.Key</dt>
<dd>@prop.Value</dd>
}
</dl>
Now, we can start the client application and navigate to the Companies
page. We have to log in. After that, let’s click the Privacy link:
33
Here, we can see our claims. Bellow them, we can find the Properties as
well.
Right now, there is a problem with this Authorization Code Flow. The code is
vulnerable to a code injection attack. The attacker can access that code and
use it to switch sessions with the victim. This means the attacker will have
all the privileges as the victim.
The recommended way of solving this problem is by using PKCE. With PKCE
configured, with each request to the /authorization endpoint, the secret is
created by the client. Then, when calling the /token endpoint, this secret is
verified and IDP will return a token only if the secret matches.
So, let’s see how the diagram looks now, with the PKCE protection:
34
So, when a client sends a request to the /authorization endpoint, it adds the
hashed code_challenge. This code is stored at the IDP level. Later on, the
client sends the code_verifier, next to the client's credentials and code. IDP
hashes the code_verifier and compares it to the stored code_challenge. If it
matches, IDP replies with the id token and access token.
Now, when we understand the flow with PKCE, we can continue with the
implementation.
new Client
{
ClientName = "CompanyEmployeeClient",
35
ClientId = "companyemployeeclient",
AllowedGrantTypes = GrantTypes.Code,
RedirectUris = new List<string>{ "https://localhost:5010/signin-oidc" },
AllowedScopes = { IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile },
ClientSecrets = { new Secret("CompanyEmployeeClientSecret".Sha512()) },
RequirePkce = true,
RequireConsent = true
}
Let’s start our applications and test this by clicking the Companies link:
We can see the check for PKCE parameters and, in the request, we can find
the code_challenge.
36
The PKCE validation is here, we can see the code_verifier and that the
validation process succeeded.
Right now, we can navigate to the Login view only if we try to access a
protected page. But, we want to be able to click the Login link and navigate
to the Login page as well. To do that, let’s create a new Auth controller in
the client application and add a Login action:
37
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-
action="Companies">Companies</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-
action="Privacy">Privacy</a>
</li>
</ul>
@if (!User.Identity.IsAuthenticated)
{
<section>
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-
action="Login">Login</a>
</section>
}
else
{
<section>
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-
action="Logout">Logout</a>
</section>
}
</div>
If we start the client application and we are not logged in, we are going to
see the Login link. Once we click it, we are going to be redirected to the
login page. After a successful login, the Logout link will be available. But, we
don’t have the Logout action, so let’s add it to the Auth controller:
First, let’s add one more property in the Config class for the Client
configuration:
RequirePkce = true,
38
PostLogoutRedirectUris = new List<string> { "https://localhost:5010/signout-callback-oidc" }
With these settings in place, after successful logout action, our application
will redirect the user to the Home page. You can try it for yourself.
But now, we still have one more problem. On the Login screen, if we click
the Cancel button, we will get an error page.
There are multiple ways to solve this, but the easiest one is to modify the
Login action in the Account controller at the IDP level:
if (context.IsNativeClient())
{
return this.LoadingPage("Redirect", model.ReturnUrl);
}
return Redirect(context.Client.ClientUri);
}
...
new Client
{
...
RequireConsent = true,
ClientUri = "https://localhost:5010"
}
39
As soon as we click the Cancel button, we are going to be redirected to the
Home page.
If we look again at the Consent screen picture, we are going to see that we
allowed MVC Client to use the id and the user profile information. But, if we
inspect the content on the Privacy page, we are going to see we are missing
the given_name and the family_name claims – from the Profile scope.
We can include these claims in the id token but, with too much information
in the id token, it can become quite large and cause issues due to URI length
restrictions. So, we are going to get these claims another way, by modifying
the OpenID Connect configuration:
At this point, we can log in again and inspect the Privacy page:
40
Excellent. We can see our additional claims.
41
We can use claims to show identity-related information in our application,
but we can use them for the authorization process as well. In this section,
we are going to learn how to modify our claims and add new ones. We are
also going to learn about the Authorization process and how to use Roles to
protect our endpoints.
If we inspect our decoded id_token with the claims on the Privacy page, we
are going to find some naming differences:
What we want is to ensure that our claims stay the same as we define them,
instead of being mapped to different claims. For example, the nameidentifier
claim is mapped to the sub claim, and we want it to stay the sub claim. To
do that, we have to slightly modify the Program class in the client project:
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
42
The JwtSecurityTokenHandler lives inside the
System.IdentityModel.Tokens.Jwt namespace.
Now, we can start our application, log out from the client, log in again and
check the Privacy page:
We can see our claims are the same as we defined them at the IDP (Identity
Provider) level.
If there are some claims we don’t want to have in the token, we can remove
them. To do that, we have to use the ClaimActions property in the OIDC
(OpenIdConnect) configuration:
43
The DeleteClaim method exists in the
Microsoft.AspNetCore.Authentication namespace.
If you don’t want to use the DeleteClaim method for each claim you want
to remove, you can always use the DeleteClaims method:
Then, we have to add the Address scope to the AllowedScopes property for
the Client configuration:
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Address
},
After this, we have to open the TestUsers class and add a new claim for both
users:
44
new TestUser
{
...
Claims = new List<Claim>
{
new Claim("given_name", "John"),
new Claim("family_name", "Doe"),
new Claim("address", "John Doe's Boulevard 323")
}
},
new TestUser
{
...
Claims = new List<Claim>
{
new Claim("given_name", "Jane"),
new Claim("family_name", "Doe"),
new Claim("address", "Jane Doe's Avenue 214")
}
}
};
Now, let’s move on to the client project and modify the OpenIdConnect
configuration:
45
We can see a new address scope. But, if we inspect the Privacy page, we
won’t be able to find the address claim there. That’s because we didn’t map
it to our claims. But, what we can do is inspect the console logs to make
sure the IdentityServer returned our new claim:
opt.ClaimActions.MapUniqueJsonKey("address", "address");
46
After we log in again, we can find the address claim on the Privacy page.
The important thing to mention here is: if you need a claim just for a part of
the application (not for the entire application), the best practice is not to
map it. You can always get it with the IdentityModel package, by sending
the request to the /userinfo endpoint. By doing that, you ensure your
cookies are small in size and that you always get up-to-date information
from the userinfo endpoint.
Now, let’s see how we can extract the address claim from
the /userinfo endpoint.
47
{
var idpClient = _httpClientFactory.CreateClient("IDPClient");
var metaDataResponse = await idpClient.GetDiscoveryDocumentAsync();
if (response.IsError)
{
throw new Exception("Problem while fetching data from the UserInfo endpoint",
response.Exception);
}
return View();
}
So, we create a new client object and fetch the response from the
IdentityServer with the GetDiscoveryDocumentAsync method. This
response contains our required /userinfo endpoint’s address. After that, we
use the UserInfo address and extracted the access token to fetch the
required user information. If the response is successful, we extract the
address claim from the claims list and just add it to the User.Claims list
(this is the list of Claims we iterate through in the Privacy view).
using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using System.Security.Claims;
Now, if we log in again and navigate to the Privacy page, the address claim
will still be there. But this time, we extracted it manually. So basically, we
can use this code only when we need it in our application.
48
Up until now, we have been working with Authentication by providing proof
of who we are and the id token helped us in the process. So it makes sense
to continue with the Authorization actions and take a look at Role-Based
Authorization actions.
new TestUser
{
...
Claims = new List<Claim>
{
...
new Claim("address", "John Doe's Boulevard 323"),
new Claim("role", "Administrator")
}
},
new TestUser
{
...
Claims = new List<Claim>
{
...
new Claim("address", "Jane Doe's Avenue 214"),
new Claim("role", "Visitor")
}
}
We’ve finished working with users. Now, let’s move on to the Config class
and create a new identity scope in the Ids property:
49
and finally a list of claims that must be returned when the application asks
for this “roles” scope.
Additionally, we have to add a new allowed scope for our client application:
new Client
{
...
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Address ,
"roles"
},
...
}
With all of these in place, we are done with the IDP level modifications. Now,
we can move on to the client application by updating the OIDC configuration
to support roles scope:
It isn’t enough just to add the roles scope to the configuration, we need to
map it as well to have it in a claims list.
So, what we want to do with this role claim is to allow only the user with the
Administrator role to use Create, Update, Details, and Delete actions. To do
that, let’s modify the Companies view:
@if (User.IsInRole("Administrator"))
{
<p>
<a asp-action="Create">Create New</a>
</p>
}
<table class="table">
...
50
<tbody>
@foreach (var item in Model)
{
<tr>
...
@if (User.IsInRole("Administrator"))
{
<td>
@Html.ActionLink("Edit", "Edit", new {}) |
@Html.ActionLink("Details", "Details", new {}) |
@Html.ActionLink("Delete", "Delete", new {})
</td>
}
</tr>
}
</tbody>
</table>
Here, we wrap the actions we want to allow only to the administrator with
the check if the user is in the Administrator role. We do that by using the
User object and IsInRole method where we pass the value of the role.
Finally, we have to state where our framework can find the user’s role:
Now, we can start our applications and log in with Jane’s account:
51
We can see an additional scope in the Consent screen.
As soon as we allow this, the Home screen will appear. Let’s just inspect the
console logs:
52
We can’t find additional actions on this page, but if we log out and log in
with John’s account, we will be able to find the missing actions.
Excellent.
But can we protect our endpoints with roles as well? Of course. Let’s see
how it’s done.
Le’s say, for example, only the Administrator users can access the Privacy
page. Well, with the same action from the previous part, we can show the
Privacy link in the _Layout view:
@if (User.IsInRole("Administrator"))
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-
action="Privacy">Privacy</a>
</li>
}
53
Even though we can’t see the Privacy link, we still have access to the Privacy
page by entering a valid URI address:
So, what we have to do is to protect our Privacy endpoint with the user’s
role:
[Authorize(Roles = "Administrator")]
public async Task<IActionResult> Privacy()
Now, if we log out, log in again as Jane and try to use the URI address to
access the privacy page, we won’t be able to do that:
54
The first thing we are going to do is create the AccessDenied action in the
Auth controller:
@{
ViewData["Title"] = "AccessDenied";
}
<h1>AccessDenied</h1>
<p>
You can always <a asp-controller="Auth" asp-action="Logout">log in as someone
else</a>.
</p>
55
And, of course, if we click the “log in as someone else” link, we are going to
be logged out and navigated to the Home page.
56
For this section of the book, we are going to pay closer attention to the last
three steps of our Authorization Code Flow diagram:
As we can see in step eight, IDP provides the access token and the id token.
Then, the client application stores the access token and sends it as a Bearer
token with each request to the API. At the API level, this token is validated
and access is granted to the protected resources. In this section, we are
going to cover these three steps.
The first thing we want to do is to add a new API scope in the Config class at
the IDP level:
57
public static IEnumerable<ApiScope> ApiScopes =>
new ApiScope[]
{
new ApiScope("companyemployeeapi.scope", "CompanyEmployee API Scope")
};
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Address ,
"roles",
"companyemployeeapi.scope"
},
If we check the code in the Startup class, we are going to see that IDP is
already configured to use API resources:
And that’s all we have to modify at the level of IDP. We can move on to the
client application.
58
In the Program class, we are going to add a new scope to the OIDC
configuration:
builder.Services.ConfigureAuthenticationHandler();
59
builder.Services.AddControllers(config =>
app.UseAuthentication();
app.UseAuthorization();
[HttpGet]
[Authorize]
public async Task<IActionResult> GetCompanies()
Excellent.
On the consent screen, we can see the new API scope. As soon as we allow
this and navigate to the Companies page, we get a 401 Unauthorized
response:
60
The API returns this response because we didn’t send the access token in
the request.
Since we have to pass the access token with each call to the API, the best
practice is to implement some reusable logic for applying that token to the
request. That’s exactly what we are going to do here.
In the client application, we are going to create a new Handlers folder and
inside it a new BearerTokenHandler class:
if (!string.IsNullOrWhiteSpace(accessToken))
request.SetBearerToken(accessToken);
using IdentityModel.Client;
using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
61
is exactly what we need since we don’t want to override the default way
HTTP sends messages and receives them, but we want to add something to
it.
builder.Services.AddHttpContextAccessor();
builder.Services.AddTransient<BearerTokenHandler>();
Now, let’s start all the applications, log in as John and click on the
Companies link in the menu:
62
This time, we can see our data, which means the client sent the access
token to the API where it was validated, and the access to the protected
resource was granted.
Now, if we navigate to the Privacy page, copy the access token and decode
it, we will find the companyemployeeapi scope included in the list of scopes,
and it is the value for the audience as well:
If we log out from the client, log in again, but this time uncheck the
CompanyEmployee API option and navigate to the Companies page, we are
going to get the 401 response. But, since we already have the AccessDenied
page, we can use it to create a better user experience.
[Authorize]
63
public async Task<IActionResult> Companies()
{
var httpClient = _httpClientFactory.CreateClient("APIClient");
if(response.IsSuccessStatusCode)
{
var companiesString = await response.Content.ReadAsStringAsync();
var companies = JsonSerializer.Deserialize<List<CompanyViewModel>>(companiesString,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return View(companies);
}
else if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode ==
HttpStatusCode.Forbidden)
{
return RedirectToAction("AccessDenied", "Auth");
}
Let’s get started and learn how to use Policies in our client application.
64
We have to modify the configuration class at the IDP level first. So, let’s add
a new identity resource:
We want our client to be able to request this scope, so let’s modify the
allowed scopes for the MVC client:
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Address,
"roles",
"companyemployeeapi",
"country"
},
The last thing we have to do at the IDP level is to modify our test users by
adding a new claim in the claims list to both John and Jane:
Now, at the client level, we have to modify the OIDC configuration with
familiar actions:
opt.Scope.Add("country");
opt.ClaimActions.MapUniqueJsonKey("country", "country");
builder.Services.AddAuthorization(authOpt =>
{
authOpt.AddPolicy("CanCreateAndModifyData", policyBuilder =>
65
{
policyBuilder.RequireAuthenticatedUser();
policyBuilder.RequireRole("role", "Administrator");
policyBuilder.RequireClaim("country", "USA");
});
});
services.AddControllersWithViews();
So, once we log in, we are going to see an additional scope in our consent
screen. If we navigate to the Privacy page, we are going to see the country
claim:
But, we are not using the policy we created for authorization. At least not
yet. So, let’s change that.
@using Microsoft.AspNetCore.Authorization
@inject IAuthorizationService AuthorizationService
...
<h1>Companies</h1>
66
<p>
<a asp-action="Create">Create New</a>
</p>
}
<table class="table">
...
<tbody>
@foreach (var item in Model)
{
...
@if ((await AuthorizationService.AuthorizeAsync(User,
"CanCreateAndModifyData")).Succeeded)
{
<td>
@Html.ActionLink("Edit", "Edit", new {}) |
@Html.ActionLink("Details", "Details", new {}) |
@Html.ActionLink("Delete", "Delete", new {})
</td>
}
</tr>
}
</tbody>
</table>
[Authorize(Policy = "CanCreateAndModifyData")]
public async Task<IActionResult> Privacy()
And that’s it. We can test this with both John and Jane. For sure, with Jane’s
account, we won’t be able to see Create, Update, Delete, and Details link,
and we are going to be redirected to the AccessDenied page if we try to
navigate to the Privacy page. You can try it out yourself.
Additional Note
To send the role claim to the API through the Access Token, we have to add
a claim to the ApiResource in the Config class:
new ApiResource("companyemployeeapi", "CompanyEmployee API")
{
Scopes = { "companyemployeeapi.scope" },
UserClaims = new List<string> { "role" }
}
67
Then, you can add the Roles property to the [Authorize] attribute on the
API level.
68
If we leave our browser open for some time and after that try to access
API’s protected resource, we will see that it’s not possible anymore. The
reason for that is the token’s limited lifetime – the token has probably
expired.
The lifetime for the identity token is five minutes by default, and, after that,
it shouldn’t be accepted in client applications for processing. This means the
client application shouldn’t use it to create the claims identity. In this
situation, it’s up to the client to create a logic regarding when access to the
client application should expire. Usually, we want to keep the user logged in
as long as they are active.
The situation with access tokens is different. They have a longer lifetime –
one hour by default and after expiration, we have to provide a new one to
access the API. We can be logged in to the client application but we won’t
have access to the API’s resources. So, we can see that the client is not
responsible for renewing the access token, this is the IDP’s responsibility.
• IdentityTokenLifetime,
• AuthorizationCodeLifetime,
• AccessTokenLifetime
In our IDP application, we are going to leave the first two as is, with their
default values. The default value for AccessTokenLifetime is one hour, and
we are going to reduce that value in the Config class:
69
{
new Client
{
...
AccessTokenLifetime = 120
}
};
When a token expires, the flow could be triggered again and the user could
be redirected to the login page and then to the consent page. But, doing this
over and over again is not user-friendly at all. Luckily, with a confidential
client, we don’t have to do this. This type of client application can use
refresh tokens over the back channel, without user interaction. The refresh
token is a credential to get new tokens, usually before the original token
expires.
So, the flow is similar to the diagram we have already seen but with minor
modifications. When the client application does the authentication with the
user's credentials, it provides the refresh token as well in the request’s body.
At the IDP level, this refresh token is validated and IDP sends back the id
token, access token, and optionally the refresh token, so we could refresh
again later on.
70
The offline access scope is required to support refresh tokens, so let’s
configure that in the Config class:
AllowOfflineAccess = true,
UpdateAccessTokenClaimsOnRefresh = true
That’s it regarding the IDP. Now, we can move on to the client application
and request this new offline_access scope in the OIDC configuration:
Excellent. We are ready to start our applications and log in. We are going to
see a new Offline Access scope on the consent screen:
If we navigate to the Privacy page, we are going to see the refresh token:
71
Now, let’s use this refresh token in our application.
if (!string.IsNullOrWhiteSpace(accessToken))
request.SetBearerToken(accessToken);
72
Here, we just replace the previous code for fetching the access token with a
private GetAccessTokenAsync method. So, let’s inspect it:
currentAuthenticateResult.Properties.StoreTokens(updatedTokens);
await _httpContextAccessor.HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
currentAuthenticateResult.Principal,
currentAuthenticateResult.Properties);
return refreshResponse.AccessToken;
}
73
After storing the updated tokens, we call the AuthenitcateAsync method to
get the authentication result, store the updated tokens in the Properties list
and use this Properties list to sign in again. Finally, we just return the new
access token.
return refreshResponse;
}
updatedTokens.Add(new AuthenticationToken
{
74
Name = OpenIdConnectParameterNames.IdToken,
Value = refreshResponse.IdentityToken
});
updatedTokens.Add(new AuthenticationToken
{
Name = OpenIdConnectParameterNames.AccessToken,
Value = refreshResponse.AccessToken
});
updatedTokens.Add(new AuthenticationToken
{
Name = OpenIdConnectParameterNames.RefreshToken,
Value = refreshResponse.RefreshToken
});
updatedTokens.Add(new AuthenticationToken
{
Name = "expires_at",
Value = (DateTime.UtcNow + TimeSpan.FromSeconds(refreshResponse.ExpiresIn)).
ToString("o", CultureInfo.InvariantCulture)
});
return updatedTokens;
}
Here we take all the updated tokens from the refreshResponse object and
store them into a single list.
With all these in place, as soon as we request access to the protected API’s
endpoint, this logic will kick in. If the access token has expired or is about to
expire, it will be refreshed. Feel free to wait a couple of minutes and try
accessing the Companies action. You will see the data fetched from the API
for sure.
75
In all the previous sections of this book, we have been working with the in-
memory IDP configuration. But, every time we wanted to change something
in that configuration, we had to restart our Identity Server to load the new
configuration. In this section, we are going to learn how to migrate the
IdentityServer4/Duende configuration to the database using Entity
Framework Core (EF Core), so we could persist our configuration across
multiple IdentityServer instances.
This package implements the required stores and services using two context
classes: ConfigurationDbContext and PersistedGrantDbContext . It uses
the first context class for the configuration of clients, resources, and scopes.
The second context class is used for temporary operational data like
authorization codes and refresh tokens.
First thing’s first – let’s create the appsettings.json file and modify it:
"ConnectionStrings": {
"sqlConnection": "server=.; database=CompanyEmployeeOAuth; Integrated Security=true"
}
After adding the SQL connection string, we are going to modify the Startup
class by injecting the IConfiguration interface:
77
{
// see https://identityserver4.readthedocs.io/en/latest/topics/resources.html
options.EmitStaticAudienceClaim = true;
})
.AddTestUsers(TestUsers.Users)
.AddConfigurationStore(opt =>
{
opt.ConfigureDbContext = c =>
c.UseSqlServer(Configuration.GetConnectionString("sqlConnection"),
sql => sql.MigrationsAssembly(migrationAssembly));
})
.AddOperationalStore(opt =>
{
opt.ConfigureDbContext = o =>
o.UseSqlServer(Configuration.GetConnectionString("sqlConnection"),
sql => sql.MigrationsAssembly(migrationAssembly));
});
builder.AddDeveloperSigningCredential();
}
So, we start by extracting the assembly name for our migrations. We need
that because we have to inform EF Core that our project will contain the
migration code. Additionally, EF Core needs this information because our
project is in a different assembly than the one containing the DbContext
classes.
78
PM> Add-Migration InitialConfigurationMigration -c ConfigurationDbContext -o
Migrations/IdentityServer/ConfigurationDb
As we can see, we are using two flags for our migrations: – c and – o. The –
c flag stands for Context and the – o flag stands for OutputDir. So basically,
we have created migrations for each context class in a separate folder:
Once we have our migration files, we are going to create a new InitialSeed
folder with a new class to seed our data:
if (!context.Clients.Any())
{
foreach (var client in Config.Clients)
{
context.Clients.Add(client.ToEntity());
}
79
context.SaveChanges();
}
if (!context.IdentityResources.Any())
{
foreach (var resource in Config.Ids)
{
context.IdentityResources.Add(resource.ToEntity());
}
context.SaveChanges();
}
if (!context.ApiScopes.Any())
{
foreach (var apiScope in Config.ApiScopes)
{
context.ApiScopes.Add(apiScope.ToEntity());
}
context.SaveChanges();
}
if (!context.ApiResources.Any())
{
foreach (var resource in Config.Apis)
{
context.ApiResources.Add(resource.ToEntity());
}
context.SaveChanges();
}
}
catch (Exception ex)
{
//Log errors or do anything you think it's needed
throw;
}
}
}
return host;
}
}
using IdentityServer4.EntityFramework.DbContexts;
using IdentityServer4.EntityFramework.Mappers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System;
using System.Linq;
80
Just for the Duende implementation, you will have to replace the two
IdentityServer4 namespaces with Duende namespaces:
using Duende.IdentityServer.EntityFramework.DbContexts;
using Duende.IdentityServer.EntityFramework.Mappers;
So, we create a scope and use it to migrate all the tables from the
PersistedGrantDbContext class. A few lines after that, we create a context
for the ConfigurationDbContext class and use the Migrate method to
apply the migration. Then, we go through all the clients, identity resources,
and API scopes and resources, add each of them to the context and call the
SaveChanges method.
try
{
Log.Information("Starting host...");
CreateHostBuilder(args).Build().MigrateDatabase().Run();
return 0;
}
And that’s all it takes. Once the IDP starts, the database will be created with
all the tables inside it. You will now find additional tables like ApiScopes and
ApiResourceScopes to support the changes in the newest IS4 version.
Additionally, we can start other projects and confirm that everything is still
working as it was before applying these changes.
81
Up until now, we have been working with in-memory users placed inside the
TestUsers class. But, as we did with the IdentityServer4/Duende
configuration, we want to transfer these users to the database as well.
Additionally, with IS4/Duende, we can work with Authentication and
Authorization, but we can’t work with user management, and for that, we
are going to integrate the ASP.NET Core Identity library. We are going to
create a new database for this purpose and transfer our users to it. Later on,
you will see how to use ASP.NET Core Identity features like registration,
password reset, etc.
Or for Duende:
As we said, this would create a new project with the basic configuration for
the IdentityServer4/Duende and ASP.NET Core Identity.
82
manual implementation, we can use the template command for our next
projects.
"ConnectionStrings": {
"sqlConnection": "server=.; database=CompanyEmployeeOAuth; Integrated Security=true",
"identitySqlConnection": "server=.; database=CompanyEmployeeOAuthIdentity; Integrated
Security=true"
}
Our User class must inherit from the IdentityUser class to accept all the
default fields that Identity provides. Additionally, we extend the IdentityUser
class with our properties.
83
Then, we can create a context class in the Entities folder:
The last step for the integration process is the Startup class modification:
services.AddIdentity<User, IdentityRole>()
.AddEntityFrameworkStores<UserContext>()
.AddDefaultTokenProviders();
84
builder.AddDeveloperSigningCredential();
}
So, first, we register the UserContext class into the service collection. Then,
by calling the AddIdentity method, we register ASP.NET Core Identity with
a specific user and role classes. Additionally, we register the Entity
Framework Core for Identity and provide a default token provider. The last
thing we changed here is the call to the AddAspNetIdentity method. This
way, we add an integration layer to allow IdentityServer to access user data
from the ASP.NET Core Identity user database.
As you can see, we have to specify the context class as well, since we
already have two context classes related to IS4.
After the file creation, let’s execute this migration with the Update-
Database command:
This should create a database with all the required tables, and, if you inspect
the AspNetUsers table, you are going to see additional columns (FirstName,
LastName, Address and Country).
Since we have all the required tables, we can create default roles required
for our users. To do that, we are going to create a Configuration folder
inside the Entities folder. Next, let’s create a new class in the
Configuration folder:
85
{
public void Configure(EntityTypeBuilder<IdentityRole> builder)
{
builder.HasData(new IdentityRole
{
Name = "Administrator",
NormalizedName = "ADMINISTRATOR",
Id = "c3a0cb55-ddaf-4f2f-8419-f3f937698aa1"
},
new IdentityRole
{
Name = "Visitor",
NormalizedName = "VISITOR",
Id = "6d506b42-9fa0-4ef7-a92a-0b5b0a123665"
});
}
}
As you can see, we are preparing two roles related to our test users.
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
builder.ApplyConfiguration(new RoleConfiguration());
}
With this in place, all we have to do is to create and execute the migration:
86
It’s time to transfer our test users to the database. For this action, we can’t
create a seed configuration class as we did with the roles because we have
additional actions that are related to hashing passwords and adding roles
and claims to our users. To be able to do that, we have to use the
UserManager class and register Identity as we did in the Startup class.
So, let’s create a new SeedUserData class and register Identity in the local
service collection:
Here, we create a new local service collection and register the logging
service and our UserContext and Identity as services as well. Identity
registration is almost the same as in the Startup class. The only difference is
87
that we modify the Password identity options (we need this to support
current passwords from our test users).
Next, we create a local service provider and with its help, we create a
service scope that we need to retrieve the UserManager service from the
service collection. As soon as we have our scope, we call the CreateUser
method twice, with data for each of our test users.
In this method, we use the scope object with the ServiceProvider and the
GetRequiredService method to get the UserManager service. Once we
have it, we use it to fetch a user by their username. If it doesn’t exist, we
create a new user, add a role to that user and add claims. As you can see,
88
for each create operation, we check the result with the CheckResult
method:
try
{
Log.Information("Starting host...");
var builder = CreateHostBuilder(args).Build();
builder.MigrateDatabase().Run();
return 0;
}
89
So, we create a builder object to be able to get the IConfiguration service.
With this service, we fetch the connection string from the appsettings.json
file. After we have the connection string, we call the EnsureSeedData
method to start the migration.
And that’s it. As soon as we start our IDP project, we can check the tables in
the CompanyEmployeeOAuthIdentity database. All the required tables
(AspNetUsers, AspNetUserRoles, AspNetUserClaims) are most definitely
populated with valid data.
public AccountController(
IIdentityServerInteractionService interaction, IClientStore clientStore,
IAuthenticationSchemeProvider schemeProvider, IEventService events,
UserManager<User> userManager, SignInManager<User> signInManager)
{
_interaction = interaction;
_clientStore = clientStore;
_schemeProvider = schemeProvider;
_events = events;
_userManager = userManager;
_signInManager = signInManager;
}
90
After that, we have to modify the HttpPost Login method by replacing the
code inside the model validation check:
if (ModelState.IsValid)
{
var result = await _signInManager.PasswordSignInAsync(model.Username, model.Password,
model.RememberLogin, lockoutOnFailure: true);
if (result.Succeeded)
{
var user = await _userManager.FindByNameAsync(model.Username);
await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id,
user.UserName, clientId: context?.Client.ClientId));
if (context != null)
{
if (context.IsNativeClient())
{
return this.LoadingPage("Redirect", model.ReturnUrl);
}
return Redirect(model.ReturnUrl);
}
if (Url.IsLocalUrl(model.ReturnUrl))
{
return Redirect(model.ReturnUrl);
}
else if (string.IsNullOrEmpty(model.ReturnUrl))
{
return Redirect("~/");
}
else
{
throw new Exception("invalid return URL");
}
}
The main difference with this approach is the use of the _userManager and
_signInManager objects for authentication actions.
91
with this one:
await _signInManager.SignOutAsync();
Now, we can start all three applications and log in with [email protected] or
[email protected] username. We are going to be navigated to the consent
screen and we can access the Companies page. Since John is an
Administrator, he can see the Privacy page and additional actions on the
Companies page.
So, everything is working as it was before, but this time we are using
ASP.NET Core Identity for the user management actions. Furthermore, we
are now able to work with other actions like user registration, email
confirmation, forgot password, etc.
92
As we already know, we have our users in a database and we can use their
credentials to log in to our application. But, these users were added to the
database with the migration process and we don’t have any other way in our
application to create new users. Well, in this section, we are going to create
the user registration functionality by using ASP.NET Core Identity, thus
providing a way for a user to register into our application.
Let’s start with the UserRegistrationModel class that we are going to use
to transfer the user data between the view and the action. So, in the
IDP/ Entities folder, we are going to create a new ViewModels folder and
inside it the mentioned class:
[DataType(DataType.Password)]
[Compare("Password", ErrorMessage = "The password and confirmation password do not
match.")]
public string ConfirmPassword { get; set; }
}
93
The Address, Country, Email, and Password properties are required and the
ConfirmPassword property must match the Password property. We require
the Address and Country because we have a policy-based authorization
implemented that relies on these two claims.
Now, let’s continue with the required actions. For this, we are going to use
the existing Account controller in the IDP project:
[HttpGet]
public IActionResult Register(string returnUrl)
{
ViewData["ReturnUrl"] = returnUrl;
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Register(UserRegistrationModel userModel, string returnUrl)
{
return View();
}
So, we create two Register actions (GET and POST). We are going to use the
first one to show the view and the second one for the user registration logic.
That said, let’s create a view for the GET Register action:
@model CompanyEmployees.IDP.Entities.ViewModels.UserRegistrationModel
<h2>Register</h2>
<h4>UserRegistrationModel</h4>
<hr />
<div class="row">
<div class="col-md-4">
<form asp-action="Register" asp-route-returnUrl="@ViewData["ReturnUrl"]" >
<partial name="_ValidationSummary" />
<div class="form-group">
<label asp-for="FirstName" class="control-label"></label>
<input asp-for="FirstName" class="form-control" />
<span asp-validation-for="FirstName" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="LastName" class="control-label"></label>
<input asp-for="LastName" class="form-control" />
<span asp-validation-for="LastName" class="text-danger"></span>
</div>
94
<div class="form-group">
<label asp-for="Address" class="control-label"></label>
<input asp-for="Address" class="form-control" />
<span asp-validation-for="Address" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Country" class="control-label"></label>
<input asp-for="Country" class="form-control" />
<span asp-validation-for="Country" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Email" class="control-label"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Password" class="control-label"></label>
<input asp-for="Password" class="form-control" />
<span asp-validation-for="Password" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="ConfirmPassword" class="control-label"></label>
<input asp-for="ConfirmPassword" class="form-control" />
<span asp-validation-for="ConfirmPassword" class="text-danger"></span>
</div>
<div class="form-group">
<input type="submit" value="Register" class="btn btn-primary" />
</div>
</form>
</div>
</div>
Basically, we have all the input fields from our model in this view. Of course,
when we click the Create button, it will direct us to the POST Register
method with the UserRegistrationModel populated.
Unfortunately, having this page created isn’t enough. We also need a way to
navigate to this registration page. So, for that, let’s modify the _Lauout
view in the client application:
@if (!User.Identity.IsAuthenticated)
{
<section>
<a class="nav-link text-dark" asp-area="" asp-controller="Auth"
asp-action="Login">Login</a>
</section>
<section>
<a class="nav-link text-dark" asp-action="Register"
95
asp-controller="Account" asp-protocol="https"
asp-host="localhost:5005"
asp-route-returnUrl="https://localhost:5010">Register</a>
</section>
}
We just create a new menu link that navigates to the Register page at the
IDP level. As you can see, we are using additional attributes to navigate to
the Register page at the IDP level and provide a query string to return. If we
start the IDP and Client applications, click the Register link, and then just
click the Register button without entering data, we are going to see the
following validation messages:
Before we start with the implementation, let’s install the AutoMapper library
in the IDP project so that we can map the UserRegistrationModel to the
User class:
96
{
services.AddAutoMapper(typeof(Startup));
services.AddControllersWithViews();
Since we are using the Email property to populate the UserName column,
we have to add a rule to this mapping profile.
The final action related to the AutoMapper is to inject it into the Account
controller:
public AccountController(
...
UserManager<User> userManager, SignInManager<User> signInManager,
IMapper mapper)
{
...
_userManager = userManager;
_signInManager = signInManager;
_mapper = mapper;
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Register(UserRegistrationModel userModel string returnUrl)
{
if (!ModelState.IsValid)
{
return View(userModel);
97
}
return View(userModel);
}
return Redirect(returnUrl);
}
You have probably noticed that the action is asynchronous now. That is
because the UserManager’s helper methods are asynchronous as well. Inside
this action, we check the model validity. If it is invalid, we just return the
same view with the invalid model. If it is valid, we map the registration
model to the user.
As you can see, we use the CreateAsync method to register the user. But,
this method does more than that. It hashes a password, performs additional
user checks, and returns a result. If the registration was successful, we
attach the default role and claims to the user, again, with the UserManager’s
help and redirect the user to the Home page.
But, if the registration fails, we loop through all the errors and add them to
the ModelState .
98
Now, we are ready to test this.
Let’s start all three applications and click the Register link:
Then, let’s populate all fields and click the Register button. After successful
registration, we should be directed to the Home page. If we inspect the
database, we can find a new user inside the AspNetUsers table:
99
})
With this configuration, once we try to register with the “pass” password, an
error message will show several violations but none of them will be about an
uppercase character or a digit for sure:
100
A common practice in user account management is to provide users with the
possibility to change their passwords if they forget them. The password reset
process shouldn’t involve application administrators because the users
themselves should be able to go through the entire process on their own.
Usually, the user is provided with the Forgot Password link on the login page
and that is exactly what we are going to do.
So, let’s explain how the Password Reset process should work in a nutshell.
A user clicks on the Forgot password link and gets redirected to a view with
an email input field. After a user populates that field, the application sends a
valid link to that email address. The user clicks on the link in the email and
gets redirected to the reset password view with a generated token. After the
user populates all the fields in the form, the application resets the password
and the user gets redirected to the Login (or Home) page.
We have already prepared the EmailService project and you can find it in the
source code folder called 10-Reset Password. So, the first thing we are going
to do is to add this EmailService inside the IDP project as an existing
project:
101
After that, we have to add the reference to the EmailService project inside
the IDP project:
Next, we are going to add a configuration for the email service in the
appsettings.json file:
{
"ConnectionStrings": {
"sqlConnection": "server=.; database=CompanyEmployeeOAuth; Integrated Security=true",
"identitySqlConnection": "server=.; database=CompanyEmployeeOAuthIdentity; Integrated
Security=true"
},
"EmailConfiguration": {
"From": "[email protected]",
"SmtpServer": "smtp.gmail.com",
"Port": 465,
"Username": "[email protected]",
"Password": "*******"
}
}
Of course, you are going to use your email provider with your credentials.
After that, we are going to extract this data in a singleton service and
register our EmailService as a scoped service:
services.AddControllersWithViews();
102
By registering the email configuration as a service, we are allowing our
EmailService application to access this data.
public AccountController(
...
IMapper mapper, IEmailSender emailSender)
{
...
_mapper = mapper;
_emailSender = emailSender;
}
The Email property is the only one we require for the ForgotPassword
view. Now, let’s continue by creating additional actions in the Account
controller:
[HttpGet]
public IActionResult ForgotPassword(string returnUrl)
{
ViewData["ReturnUrl"] = returnUrl;
return View();
}
[HttpPost]
103
[ValidateAntiForgeryToken]
public async Task<IActionResult> ForgotPassword(ForgotPasswordModel forgotPasswordModel,
string returnUrl)
{
return View(forgotPasswordModel);
}
This is a familiar setup. The first action is just for the view creation, the
second one is for the main logic and the last one just returns the
confirmation view. You can also notice the returnUrl parameter. We need it
because, in that parameter, IS4/Duende has all the information required to
navigate to the Login screen (redirect_uri, response type, scope ...).
@model CompanyEmployees.IDP.Entities.ViewModels.ForgotPasswordModel
<h2>ForgotPassword</h2>
<h4>ForgotPasswordModel</h4>
<hr />
<div class="row">
<div class="col-md-4">
<form asp-action="ForgotPassword" asp-route-returnUrl="@ViewData["ReturnUrl"]">
<div class="form-group">
<label asp-for="Email" class="control-label"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="form-group">
<input type="submit" value="Submit" class="btn btn-primary" />
</div>
</form>
</div>
</div>
<h1>ForgotPasswordConfirmation</h1>
<p>
104
The link has been sent, please check your email to reset your password.
</p>
<div class="form-group">
<a asp-action="ForgotPassword"
asp-route-returnUrl="@Model.ReturnUrl">Forgot Password</a>
</div>
<button class="btn btn-primary" name="button" value="login">Login</button>
<button class="btn btn-default" name="button" value="cancel">Cancel</button>
As soon as we start our applications and navigate to the Login page, we can
see the Forgot Password link:
After we click the link, we are going to see our page with the returnUrl
parameter populated:
105
Now, we can modify the POST action:
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ForgotPassword(ForgotPasswordModel forgotPasswordModel,
string returnUrl)
{
if (!ModelState.IsValid)
return View(forgotPasswordModel);
var message = new Message(new string[] { user.Email }, "Reset password token", callback,
null);
await _emailSender.SendEmailAsync(message);
return RedirectToAction(nameof(ForgotPasswordConfirmation));
}
If the model is valid, we fetch the user information from the database by
using their email. If the user doesn’t exist, we don’t show a message that
the user with the provided email doesn’t exist in the database, but rather
just redirect that user to the confirmation page.
106
This is a good practice for security reasons.
builder.AddDeveloperSigningCredential();
services.Configure<DataProtectionTokenProviderOptions>(opt =>
opt.TokenLifespan = TimeSpan.FromHours(2));
[DataType(DataType.Password)]
[Compare("Password", ErrorMessage = "The password and confirmation password do not
match.")]
public string ConfirmPassword { get; set; }
107
Now, let’s create the required actions in the Account controller:
[HttpGet]
public IActionResult ResetPassword(string token, string email, string returnUrl)
{
ViewData["ReturnUrl"] = returnUrl;
var model = new ResetPasswordModel { Token = token, Email = email };
return View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetPassword(ResetPasswordModel resetPasswordModel,
string returnUrl)
{
return View();
}
[HttpGet]
public IActionResult ResetPasswordConfirmation(string returnUrl)
{
ViewData["ReturnUrl"] = returnUrl;
return View();
}
This is a similar setup to the one we had with the ForgotPassword actions.
The HttpGet ResetPassword action will accept a request from the email,
extract the token, email, and returnUrl values, and create a view. The
HttpPost ResetPassword action is here for the main logic. And the
ResetPasswordConfirmation is just a helper action to create a view for the
user to get a confirmation regarding the action.
Now, let’s create our views. First, we are going to create the
ResetPassword view:
@model CompanyEmployees.IDP.Entities.ViewModels.ResetPasswordModel
<h2>ResetPassword</h2>
<div class="row">
<div class="col-md-4">
<form asp-action="ResetPassword" asp-route-returnUrl="@ViewData["ReturnUrl"]">
<partial name="_ValidationSummary" />
<div class="form-group">
<label asp-for="Password" class="control-label"></label>
<input asp-for="Password" class="form-control" />
108
<span asp-validation-for="Password" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="ConfirmPassword" class="control-label"></label>
<input asp-for="ConfirmPassword" class="form-control" />
<span asp-validation-for="ConfirmPassword" class="text-danger"></span>
</div>
<input type="hidden" asp-for="Email" class="form-control" />
<input type="hidden" asp-for="Token" class="form-control" />
<div class="form-group">
<input type="submit" value="Reset" class="btn btn-primary" />
</div>
</form>
</div>
</div>
Pay attention that Email and Token are fields – they are hidden and we
already have these values.
<h2>ResetPasswordConfirmation</h2>
<p>
Your password has been reset. Please
<a asp-action="Login" asp-route-returnUrl="@ViewData["returnUrl"]">click here to log
in</a>.
</p>
Excellent.
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetPassword(ResetPasswordModel resetPasswordModel,
string returnUrl)
{
if (!ModelState.IsValid)
return View(resetPasswordModel);
109
{
ModelState.TryAddModelError(error.Code, error.Description);
}
return View();
}
The first two actions are the same as in the ForgotPassword action. We
check the model validity and whether the user exists in the database. After
that, we execute the password reset action using the ResetPasswordAsync
method. If the action fails, we add errors to the model state and return a
view. Otherwise, we just redirect the user to the confirmation page.
Now, let’s start our applications, navigate to the Login page and click the
Forgot Password link. In the Email field, we are going to enter our user’s
email address:
110
After we click the Submit button, the following message is displayed:
Now, let’s open the email we have received and click the provided link:
This should navigate us to the page where we need to enter a new password
and confirm it:
111
Finally, we can click the provided link that will navigate us to the Login page
and enter our new credentials. After allowing access to the consent page, we
are going to be redirected to the Home page.
112
Email Confirmation is quite an important part of the user registration
process. It allows us to verify the registered user is indeed the owner of the
provided email. But why is this so important?
Well, let’s imagine the following scenario – we have two users with similar
email addresses who want to register in our application. Michael registers
first with [email protected] instead of [email protected] which is his real
address. Without an email confirmation, this registration will execute
successfully. Now, Michel comes to the registration page and tries to register
with his email [email protected]. Our application will return an error that the
user with that email is already registered. So, thinking that he already has
an account, he just resets the password and successfully logs in to the
application.
We can see where this could lead and what problems it could cause.
If we inspect our codemazetest user in the database, we can see his email
is not confirmed:
So, even though we didn’t confirm our email, we were able to register. Now,
we are going to change that by modifying the Identity configuration in the
Startup class:
113
{
opt.Password.RequireDigit = false;
opt.Password.RequiredLength = 7;
opt.Password.RequireUppercase = false;
opt.User.RequireUniqueEmail = true;
opt.SignIn.RequireConfirmedEmail = true;
})
But, we know this is not the case. So, let’s make some modifications to the
AccountOptions class in the Account folder:
114
is exactly what we want, since it was used just in the Login method in the
Account controller. If you renamed it manually, make sure to rename the
same field in the Login action as well.
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Register(UserRegistrationModel userModel, string returnUrl)
{
...
return Redirect(nameof(SuccessRegistration));
}
115
var confirmationLink = Url.Action(nameof(ConfirmEmail), "Account",
new { token, email = user.Email, returnUrl }, Request.Scheme);
await _emailSender.SendEmailAsync(message);
}
[HttpGet]
public async Task<IActionResult> ConfirmEmail(string token, string email, string returnUrl)
{
ViewData["ReturnUrl"] = returnUrl;
[HttpGet]
public IActionResult SuccessRegistration()
{
return View();
}
116
Next, we have to create the ConfirmEmail view:
<h2>ConfirmEmail</h2>
<p>
Thank you for confirming your email. Please
<a href="@ViewData["ReturnUrl"]">click here to navigate to the Home page </a>.
</p>
<h2>SuccessRegistration</h2>
<p>
Please check your email for the verification action.
</p>
[HttpGet]
public IActionResult Error(string returnUrl)
{
ViewData["ReturnUrl"] = returnUrl;
return View();
}
<h2 class="text-danger">Error.</h2>
<h3 class="text-danger">An error occurred while processing your request.</h3>
<a href="@ViewData["ReturnUrl"]">click here navigate to the Home page</a>.
You can register a new user with a valid email address if you want, but we
are going to register the same one, and for that, we have to remove them
from the AspNetUsers table, their related role from the AspNetUserRoles
table, and their claims from the AspNetUserClaims table. It is enough to
remove the user from the AspNetUsers table and SQL Management Studio
will resolve the rest for you.
117
Now, let’s start our applications and navigate to the register view. After we
populate all the fields and click the Register button, we are going to be
redirected to the SuccessRegistration page:
As you can see, we are navigated to the ConfirmEmail view, which means
that we have successfully confirmed our email. You can click the link on the
page that will navigate you to the Home page and then log in with a newly
created user.
Excellent.
118
Now, if we look at the ConfigureServices method, we are going to see that
this token lasts for two hours, the same as the reset password token. That’s
because the reset and confirmation functionalities both use the same data
protection token provider with the same instance of the
DataProtectionTokenProviderOptions class.
But, we don’t want our email token to last two hours – usually, it should last
longer. For the reset password functionality, a short period is quite ok, but
for the email confirmation, it isn’t. A user could, for example, easily get
distracted and come back to confirm their email address after a day. Thus,
we have to increase the lifespan of this type of token.
namespace CompanyEmployees.IDP.CustomTokenProviders
{
public class EmailConfirmationTokenProvider<TUser> : DataProtectorTokenProvider<TUser>
where TUser : class
{
public EmailConfirmationTokenProvider(IDataProtectionProvider dataProtectionProvider,
IOptions<EmailConfirmationTokenProviderOptions> options,
ILogger<DataProtectorTokenProvider<TUser>> logger)
: base(dataProtectionProvider, options, logger)
{
}
}
119
Though, we do need additional namespaces to be included:
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
services.Configure<DataProtectionTokenProviderOptions>(opt =>
opt.TokenLifespan = TimeSpan.FromHours(2));
services.Configure<EmailConfirmationTokenProviderOptions>(opt =>
opt.TokenLifespan = TimeSpan.FromDays(3));
}
And that’s all it takes. Our user has more time to confirm the provided email
address.
120
The user lockout feature is the way to improve the application’s security by
locking out a user who enters the password incorrectly several times. This
technique can help us protect the application against brute force attacks,
where an attacker repeatedly tries to guess the password.
The default configuration for the lockout functionality is already in place, but,
if we want, we can apply our configuration. To do that, we have to modify
the AddIdentity method in the ConfigureService method:
The user lockout feature is enabled by default, but, just as an example, let’s
explicitly set the AllowedForNewUsers property to true. Additionally, let’s
set the lockout period to two minutes (default is five) and maximum failed
login attempts to three (default is five). Of course, the period is set to two
minutes just for the sake of this example, that value should be a bit higher
in a production environment.
121
We use the last parameter from this method to enable or disable the lockout
feature. By setting the lockoutOnFailure parameter to true, we enable the
lockout functionality, thus enabling modification of the AccessFailedCount
and LockoutEnd columns in the AspNetUsers table:
The AccessFailedCount column will increase for every failed login attempt
and reset once the account is locked out. The LockoutEnd column
represents the period until this account is locked out.
if (ModelState.IsValid)
{
var result = await _signInManager.PasswordSignInAsync ...
if (result.Succeeded)
{
...
}
if (result.IsLockedOut)
{
await HandleLockout(model.Username, model.ReturnUrl);
}
else
{
await _events.RaiseAsync(...);
ModelState.AddModelError(string.Empty, AccountOptions.InvalidLoginAttempt);
}
}
If the sign-in was not successful, we check if the account is locked out. If it
is locked out, we call the HandleLockout method and just return a Login
view. So, let’s inspect the HandleLockout method:
122
to reset your password, please click this link: {0}", forgotPassLink);
Here, we fetch the user from the database, create a link and content for the
email message and send that email to the user. It is a good practice to send
an email to the user. By doing that, we encourage them to act proactively.
They can reset the password or report that something is wrong because they
didn’t try to log in, which could mean that someone is trying to hack their
account.
If we login with the wrong credentials, we are going to get the familiar error
message:
If we inspect the database, we are going to see increased value for the
AccessFailedCount column:
123
Now, if we try to log in with the wrong credentials two more times, we can
see our account got locked out:
If we check our email, we will find a link to the ForgotPassword action. The
rest of the process is the same as explained in the Reset Password section of
this book.
Now, there is one important thing to mention. We have two situations here.
Our project is a good example of the first situation. After the user resets the
password, they will have to wait for the lockout period to expire to try to log
in again. If you want this kind of behavior, you can leave the code as-is.
In the second situation, the user account gets unlocked as soon as the
password is reset. If you want a behavior like this one, you should modify
the HttpPost ResetPassword action. With the await
_userManager.IsLockedOutAsync(user); expression you can check if the
account is locked out, and with the await
_userManager.SetLockoutEndDateAsync(user, new
124
DateTimeOffset(dateInThePast)); expression, you can set the date in the
past, which will unlock the account.
If your project requires the second approach, the code should look
something like this:
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetPassword(ResetPasswordModel resetPasswordModel,
string returnUrl)
{
...
if (!resetPassResult.Succeeded)
{
...
}
if(await _userManager.IsLockedOutAsync(user))
{
await _userManager.SetLockoutEndDateAsync(user, new DateTimeOffset(new
DateTime(1000, 1, 1, 1, 1, 1)));
}
As we said, any date in the past will unlock the account immediately.
125
The two-step verification is a process where a user enters their credentials
and, after successful password validation, receives an OTP (one-time-
password) via email or SMS. That OTP has to be entered in the Two-Step
Verification form on our site to log in successfully.
The first thing we have to do is to edit our user in the AspNetUsers table by
setting the TwoFactorEnabled column to true:
The confirmed email is also a requirement, but we have already done that.
You can always set the value for the TwoFactorEnabled column from the
code during the registration process:
if (result.IsLockedOut)
{
await HandleLockout(model.UserName, model.ReturnUrl);
}
if(result.RequiresTwoFactor)
{
return RedirectToAction(nameof(LoginTwoStep),
new { Email = model.Username, model.RememberLogin, model.ReturnUrl });
}
else
{
await _events.RaiseAsync(...)
ModelState.AddModelError(string.Empty, AccountOptions.InvalidLoginAttempt);
}
126
One of the properties the result variable contains is the
RequiresTwoFactor property. The PasswordSignInAsync method will set
that property to true if the TwoFactorEnabled column for the current user
is set to true. Also, the Succeeded property will be set to false. Therefore,
we check if the RequiresTwoFactor property is true and if it is, we redirect
the user to a different action with the email (we use the email for the
username), rememberLogin, and ReturnUrl parameters.
[HttpGet]
public async Task<IActionResult> LoginTwoStep(string email, bool rememberLogin, string
returnUrl)
{
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> LoginTwoStep(TwoStepModel twoStepModel, string returnUrl,
string email)
{
return View();
}
Excellent.
We have prepared everything for the two-step verification process. So, let’s
implement it.
127
With everything in place, we are ready to modify the HttpGet
LoginTwoStep action:
[HttpGet]
public async Task<IActionResult> LoginTwoStep(string email, bool rememberLogin, string
returnUrl)
{
var user = await _userManager.FindByEmailAsync(email);
if (user == null)
{
return RedirectToAction(nameof(Error), new { returnUrl });
}
var message = new Message(new string[] { email }, "Authentication token", token, null);
await _emailSender.SendEmailAsync(message);
We check if the current user exists in the database. If that’s not the case, we
display the error page. But if we do find the user, we have to check if there
is a provider for Email because we want to send our two-step code by using
an email message. After that check, we just create a token with the
GenerateTwoFactorTokenAsync method and send the email message.
@model CompanyEmployees.IDP.Entities.ViewModels.TwoStepModel
<h2>LoginTwoStep</h2>
<div class="row">
128
<div class="col-md-4">
<form asp-action="LoginTwoStep"
asp-all-route-data="(Dictionary<string, string>)@ViewData["RouteData"]">
<div class="form-group">
<partial name="_ValidationSummary" />
<label asp-for="TwoFactorCode" class="control-label"></label>
<input asp-for="TwoFactorCode" class="form-control" />
<span asp-validation-for="TwoFactorCode" class="text-danger"></span>
</div>
<input type="hidden" asp-for="RememberLogin" />
<div class="form-group">
<input type="submit" value="Login" class="btn btn-primary" />
</div>
</form>
</div>
</div>
After the vew creation, we can modify the HttpPost LoginTwoStep action:
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> LoginTwoStep(TwoStepModel twoStepModel, string returnUrl,
string email)
{
if (!ModelState.IsValid)
{
return View(twoStepModel);
}
129
First, we check the model validity. If the model is valid, we use the
GetTwoFactorAuthenticationUserAsync method to get the current user.
We do that with the help of our Identity.TwoFactorUserId cookie (it was
automatically created by Identity in the HttpGet LoginTwoStep action). This
will prove that the user indeed went through all the verification steps to get
to this point. If we find that user, we use the TwoFactorSignInAsync
method to verify the TwoFactorToken value and sign in the user.
If the sign-in was successful, we use the returnUrl parameter to redirect the
user. Otherwise, we carry out additional checks on the result variable and
force appropriate actions.
Now, we can check our email and look for the sent code:
130
Once we enter a valid token in the LoginTwoStep form, we are going to be
redirected to the consent page. If we click the Allow button, we are going to
be redirected to the Home page.
So, everything works great. If we inspect the console log window, we can
see the value for the amr property (Authentication Method Reference) is
now mfa (Multiple-factor authentication):
131
Using an external provider when logging in to the application is quite
common. This enables us to log in with our external accounts such as
Google, Facebook, etc.
In this part of the book, we are going to learn how to configure an external
identity provider in our IdentityServer4 application and how to use a Google
account to successfully authenticate. Of course, in a very similar way, you
can configure the system to use any other external account.
There is one important thing to keep in mind here. Once an external user
logs in to our system, they will always have an identifier that is unique for
that user in our system. That means that the user could have different Ids
for different sites but for our site, that Id will always be the same.
After clicking that link, we are going to be redirected to the page for creating
our credentials. If we don’t have any project created, we have to click the
create project button at the top-right corner of the screen, add a project
name, and click the create button.
132
Then, we need to choose the External user type:
And finally, we have to add the name of the application, some required
emails, and then just leave everything else to default (scopes, test users...):
133
There, we can click the create credentials link menu and choose the
OAuth client ID :
Now, we have to choose the Application type, Name and add an authorized
redirect URI for our application:
Once we click the Create button, we will get the ClientID and ClientSecret
values. You can save them but Google will save them for you anyway. To
134
find these credentials, all we have to do is to click on the created web
application:
services.Configure<EmailConfirmationTokenProviderOptions>(opt =>
opt.TokenLifespan = TimeSpan.FromDays(3));
services.AddAuthentication()
.AddGoogle(options =>
{
options.ClientId = "456249573827-
r048bp38s62kjtgibcbja2d99ct9gsob.apps.googleusercontent.com";
135
options.ClientSecret = "WYmkEyj02ZpX6iGudxS2FCae";
});
@if (Model.VisibleExternalProviders.Any())
{
<div class="col-md-6 col-sm-6 external-providers">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">External Login</h3>
</div>
<div class="panel-body">
<ul class="list-inline">
@foreach (var provider in Model.VisibleExternalProviders)
{
<li>
<a class="btn btn-default"
asp-controller="External"
asp-action="Challenge"
asp-route-provider="@provider.AuthenticationScheme"
asp-route-returnUrl="@Model.ReturnUrl">
@provider.DisplayName
</a>
</li>
}
</ul>
</div>
</div>
</div>
}
This code will iterate through all the registered external providers and add a
button for them on the right side of the Login page:
136
In addition to this file, we have the ExternalController file in the
Quickstart/Account folder. But, the code in this file is not valid for us,
since we are now using ASP.NET Core Identity for the user management
actions. Moreover, we don’t have the test users anymore and this file works
with test users.
Since modifying this file to support ASP.NET Core Identity would be quite
messy and hard to explain without additional confusion, we are going to
remove this file from the project. Then, let’s open the 14-External
Provider folder in our source code and navigate to the
CompanyEmployees.IDP/Quickstart/Account folder. There, we can find
the ExternalController file.
This file is taken from the Identity + IS4/Duende template, modified to suit
our needs, and made more readable and easier to maintain.
Let’s copy this file and paste it into our current project at the same location
– Quickstart/Account .
We could have created our custom logic for the external provider, but, since
we already have it implemented (in the template project that supports
Identity), we have decided to use that file and modify it to suit our needs.
137
You can find the command for creating this template in section 10 of this
book.
If you open the file, you will see a lot of code broken into smaller methods
for better readability. So, let’s explain it.
As soon as we click the Google button, we are going to hit the Challenge
action. In this action, we create AuthenticationProperties object required
for the challenge window and call the Challenge action which returns a
ChallengeResult .
Then, back in the Callback method, we extract the additional claims, local
sign-in properties, and the name claim and use them to sign in the user
locally. We do that by calling the LocalSignIn method.
138
If you want to inspect the code yourself, you can always place a breakpoint
in the Callback method and click the Google button on the Login screen.
Then, you can inspect the code line by line.
We are going to test this logic with an existing user first and then with a new
one. So, let’s start the client and IDP applications, navigate to the Login
screen and click the Google button:
We are going to choose the Testing Mail account first. Once we click it, we
are going to be redirected to the consent screen. If we click the Allow
button, we are going to see the Home screen, which means that we are
logged in.
We can see that we have only attached a new provider to the existing
account.
139
Now, let’s log out and then log in with the external provider, but this time
we are going to choose the first option in the Challenge window:
After choosing that account, we are going to see the consent screen again
and after clicking the Allow button, we are going to see the Home screen.
So, we have successfully logged in again, but, if we inspect the database, we
can see a new account has been created with the external provider and
claims:
Awesome job.
140
With this out of the way, we have finished our ASP.NET Core security
journey.
Best regards and all the best from the Code Maze team.
141