Mastering ASP - NET Core Security
Mastering ASP - NET Core Security
5.2 Securing the Client Application with the Authorization Code Flow ............. 27
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-
1
based security. A token is related to consent – the user grants consent to a
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.
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 which 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 the 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.
6
You can read more about these flows in the documentation mentioned above.
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.
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"
}
}
}
Right after that, we can inspect all the files this template provides us with.
We can see this project references ASP.NET Core in the Frameworks section
and it has installed the IdentityServer4 (initially it was 4.0.0) library and
Serilog.ASPNetCore library for logging.
9
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.
Now, let’s inspect the Startup.cs class. First, let’s take a look at the
ConfigureServices method:
10
var builder = services.AddIdentityServer (options =>
{
options.EmitStaticAudienceClaim = true;
})
.AddInMemoryIdentityResources(Config.Ids)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryApiResources(Config.Apis)
.AddInMemoryClients(Config.Clients);
// 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.
11
{
SubjectId = "a9ea0f25-b964-409f-bcce-c923266249b4",
Username = "John",
Password = "JohnPassword",
Claims = new List<Claim>
{
new Claim("given_name", "John"),
new Claim("family_name", "Doe")
}
},
new TestUser
{
SubjectId = "c95ddb8c-79ec-488a-a485-fe57a1462340",
Username = "Jane",
Password = "JanePassword",
Claims = new List<Claim>
{
new Claim("given_name", "Jane"),
new Claim("family_name", "Doe")
}
}
};
using System.Security.Claims;
using IdentityServer4.Test;
Great. Let’s start the application and inspect the console logs:
12
Our IDP application is up and running, everything is looking good. But, as
soon as we inspect the browser, we are going to see a “page not found”
message. So, we need a UI for our IDP server.
13
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:
14
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();
});
15
}
In the moment of writing this book, we have to take some additional steps
to make our application functional. First, we have to add the using
IdentityServer4; statement inside the AccountController and
ExternalController files. You can find them in the QuickStart/Account
folder.
16
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.
17
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": {
18
"CompanyEmployees.Client": {
"commandName": "Project",
"launchBrowser": true,
"applicationUrl": "https://localhost:5010",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
services.AddControllersWithViews();
}
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.
We need the ViewModel class, so, let’s create it in the Models folder:
19
{
_httpClientFactory = httpClientFactory;
}
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>
20
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model) {
<tr>
<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-
21
ClientApplication/CompanyEmployees.API. If you have read our main
book Ultimate ASP.NET Core 3 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):
"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:
22
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.
23
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.
24
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:
25
2. The client sends an authentication request to the /authorization
endpoint with the response_type code and other parameters we saw in
the URI
3. The IDP shows the Login page to the user
4. As soon as the user provides credentials and gives consent
5. The IDP replies with the code via URI redirection or the Form POST.
This is the front channel communication.
6. The client then calls the /token endpoint through the back channel by
providing the code, client id and client secret.
7. After IDP validates the code and the client credentials
8. It issues the id token and the access token (it can issue the refresh
token as well if requested)
9. The client application then uses that access token to attach it in the
HTTP request, usually as a Bearer token. It needs the access token for
the verification process against the Web API.
10. Finally, the Web API replies after it successfully validates the
token.
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:
26
ClientSecrets = { new Secret("CompanyEmployeeClientSecret".Sha512()) },
RequirePkce = false,
RequireConsent = true
}
};
As you can see, we have enabled the Consent screen, to better understand
the process happening behind the scene. But if you don’t want to give
consent every time, you can just remove the RequireConsent property
since by default it is set to false.
services.AddAuthentication(opt =>
{
opt.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
27
opt.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
}).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
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;
});
28
The Authority property has the value of our IdentityServer address.
The ClientId and ClientSecret properties have to be the same as the id
and secret from the Config class for this client. We set
the SaveTokens property to true to store the token after successful
authorization. Now, let’s look at the ResponseType property. We set it to a
code value and therefore, we expect the code to be returned from
the /authorization endpoint. The OpenIdConnectResponseType class
lives in the Microsoft.IdentityModel.Protocols.OpenIdConnect
namespace.
app.UseAuthentication();
app.UseAuthorization();
[Authorize]
public async Task<IActionResult> Companies()
{
...
}
29
Right now, we can start the API, IDP and the Client application. Once the
Home screen is shown, we can click the Companies link:
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.
30
We can see that the Client application requests our permission for the data
we specified in allowed scopes in the configuration (OpenId and Profile).
When we click the Yes button, we are going to see our requested data.
31
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)
{
32
<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:
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:
33
As you can see, 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.
34
new Client
{
ClientName = "CompanyEmployeeClient",
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.
35
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 to
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:
36
Then, let’s modify the _Layout file:
</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:
37
First, let’s add one more property in the Config class for the Client
configuration:
RequirePkce = true,
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("https://localhost:5010");
}
...
38
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 in 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:
39
Excellent. We can see our additional claims.
40
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 constructor in the
client’s Startup class:
41
{
Configuration = configuration;
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
}
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:
42
opt.ClientSecret = "CompanyEmployeeClientSecret";
opt.GetClaimsFromUserInfoEndpoint = true;
opt.ClaimActions.DeleteClaim("sid");
opt.ClaimActions.DeleteClaim("idp");
});
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
},
43
After this, we have to open the TestUsers class and add a new claim for both
users:
Now, let’s move on to the client project and modify the OpenIdConnect
configuration:
44
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:
45
opt.ClaimActions.MapUniqueJsonKey("address", "address");
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.
46
As soon as we create our IDPClient, we have to modify the Privacy action in
the Home controller:
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).
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.
47
Up until now, we have been working with Authentication by providing proof
of who we are and 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 GetIdentityResources method:
48
Since a role is not a standard identity resource, we have to create it
ourselves. We add a scope name for the resource, then add a display name
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:
As you can see, 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>
49
<a asp-action="Create">Create New</a>
</p>
}
<table class="table">
...
<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:
50
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:
51
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>
}
52
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:
53
The application redirects us to the /Account/AccessDenied page, but we
get a 404 error code because we don’t have that page.
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>
54
address. But, since we don’t have the Account controller, we have to
override this setting. To do that, let’s modify the AddCookie method in the
Startup class:
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.
55
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.
56
The first thing we want to do is to add a new API scope in the Config class at
the IDP level:
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:
57
And that’s all we have to modify at the level of IDP. We can move on to the
client application.
In the Startup class, we are going to add a new scope to the OIDC
configuration:
58
calling the AddIdentityServerAuthenticaton method. It accepts an action
delegate as a parameter and that’s where we configure the Authority (the
address of our IDP project) and the ApiName.
services.ConfigureAuthenticationHandler();
app.UseAuthentication();
app.UseAuthorization();
[HttpGet]
[Authorize]
public async Task<IActionResult> GetCompanies()
Excellent.
59
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:
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);
60
return await base.SendAsync(request, cancellationToken);
}
}
using IdentityModel.Client;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
61
And to provide an additional message handler to our APIClient configuration:
Now, let’s start all the applications, log in as John and click on the
Companies link in the menu:
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 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:
62
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]
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 });
63
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.
We have to modify the configuration class at the IDP level first. So, let’s add
a new identity resource:
64
new IdentityResource("roles", "User role(s)", new List<string> { "role" }),
new IdentityResource("country", "Your country", new List<string> { "country" })
};
We want our client to be able to request this scope, so let’s modify 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");
services.AddAuthorization(authOpt =>
{
authOpt.AddPolicy("CanCreateAndModifyData", policyBuilder =>
{
policyBuilder.RequireAuthenticatedUser();
policyBuilder.RequireRole("role", "Administrator");
policyBuilder.RequireClaim("country", "USA");
});
});
services.AddControllersWithViews();
65
We use the AddAuthorization method to add Authorization in the service
collection. By using the AddPolicy method, we provide the policy name and
all the policy conditions that are required so that the authorization process is
completed. As you can see, we require the user to be an authenticated
administrator from the USA.
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
@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.
67
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:
68
public static IEnumerable<Client> Clients =>
new Client[]
{
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
69
token, access token and optionally the refresh token, so we could refresh
again later on.
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:
70
If we navigate to the Privacy page, we are going to see the refresh token:
71
Next, let’s modify the SendAsync method:
if (!string.IsNullOrWhiteSpace(accessToken))
request.SetBearerToken(accessToken);
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;
}
72
we just return the access token we already have. As you can see, we
subtract sixty seconds because we want to trigger the refresh logic before
the old token expires. But, if this check returns false, which means that the
token is about to expire or it has already expired, we get the refresh token
response from IDP and store all the updated tokens from the response in the
updatedTokens list. For these actions, we use two private methods which
we will inspect in a minute.
return refreshResponse;
}
73
response with the RequestFrefreshTokenAsync method. This response will
contain the new access token, id token, refresh token and expires_at token
as well.
updatedTokens.Add(new AuthenticationToken
{
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
74
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 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.
76
Finally, we require the Microsoft.EntityFrameworkCore.Tools package to
support migrations:
First thing’s first – let’s create the appsetting.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
.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
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());
}
context.SaveChanges();
}
79
if (!context.IdentityResources.Any())
{
foreach (var resource in Config.IdentityResources)
{
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
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 configuration, we
want to transfer these users to the database as well. Additionally, with IS4,
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.
As we said, this would create a new project with the basic configuration for
the IdentityServer4 and ASP.NET Core Identity.
But, while learning, we always prefer the step by step approach, as we did
with all the content in this book. Furthermore, we have already created an
IDP project, so we have to implement the ASP.NET Core Identity on our
own. Once we completely understand the process – which is the goal of
manual implementation, we can use the template command for our next
projects.
82
First, let’s add a new connection string to the appsettings.json file:
"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
{
public UserContext(DbContextOptions<UserContext> options)
: base(options)
{
}
The last step for the integration process is the Startup class modification:
services.AddIdentity<User, IdentityRole>()
.AddEntityFrameworkStores<UserContext>()
.AddDefaultTokenProviders();
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
84
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
},
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:
It’s a 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
86
roles and claims to our users. To be able to do that, we have to use the
UserManager class and to 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
that we modify the Password identity options (we need this to support
current passwords from our test users).
87
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
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;
}
After that, we have to modify the HttpPost Login method by replacing the
code inside the model validation check:
if (ModelState.IsValid)
90
{
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
await _signInManager.SignOutAsync();
Now, we can start all three applications and login with john@mail.com or
jane@mail.com 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
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
As you can see, 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" />
94
<span asp-validation-for="LastName" class="text-danger"></span>
</div>
<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>
95
</section>
<section>
<a class="nav-link text-dark" asp-action="Register"
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
Then, let’s register it in the Startup class:
services.AddControllersWithViews();
Since we are using the Email property to populate the UserName column,
we have to add a rule to this mapping profile.
public AccountController(
...
UserManager<User> userManager, SignInManager<User> signInManager,
IMapper mapper)
{
...
_userManager = userManager;
_signInManager = signInManager;
_mapper = mapper;
}
[HttpPost]
[ValidateAntiForgeryToken]
97
public async Task<IActionResult> Register(UserRegistrationModel userModel string returnUrl)
{
if (!ModelState.IsValid)
{
return View(userModel);
}
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
98
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 .
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
password must be at least six characters long, with an uppercase character,
non-alphanumeric character and a digit. If we want to override this, we can
do it simply by modifying the AddIdentity method:
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 it. 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
4. Select it and click the Open button
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": "codemazetest@gmail.com",
"SmtpServer": "smtp.gmail.com",
"Port": 465,
"Username": "codemazetest@gmail.com",
"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:
102
services.AddScoped<IEmailSender, EmailSender>();
services.AddControllersWithViews();
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)
103
{
ViewData["ReturnUrl"] = returnUrl;
return View();
}
[HttpPost]
[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 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>
104
</div>
<h1>ForgotPasswordConfirmation</h1>
<p>
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
106
the user with the provided email doesn’t exist in the database, but rather
just redirect that user to the confirmation page.
builder.AddDeveloperSigningCredential();
services.Configure<DataProtectionTokenProviderOptions>(opt =>
opt.TokenLifespan = TimeSpan.FromHours(2));
[DataType(DataType.Password)]
107
[Compare("Password", ErrorMessage = "The password and confirmation password do not
match.")]
public string ConfirmPassword { get; set; }
[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 like 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
108
<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" />
<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 to the 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)
{
109
if (!ModelState.IsValid)
return View(resetPasswordModel);
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.
110
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:
Now, let’s open the email we have received and click the provided link:
111
This should navigate us to the page where we need to enter a new password
and confirm it:
Finally, we can click the provided link that will navigate us to the Login page
and enter our new credentials. After allowing access on 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 michel@mail.com instead of michael@mail.com 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 michel@mail.com. 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
services.AddIdentity<User, IdentityRole>(opt =>
{
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
pressing F2, as we did, Visual Studio will rename it all over the project which
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
private async Task SendEmailConfirmationLink(User user, string returnUrl)
{
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
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
to return either the ConfirmEmail view or redirect to the Error action. The
SuccessRegistration action was just used to show the 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
117
from the AspNetUsers table, their related role from the AspNetUserRoles
table and their claims from the AspNetUserClaims table – in reverse order.
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.
118
Excellent.
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 for 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
{
}
}
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));
}
120
And that’s all it takes. Our user has more time to confirm the provided email
address.
121
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.
122
var result = await _signInManager.PasswordSignInAsync(model.Username, model.Password,
model.RememberLogin, lockoutOnFailure: true);
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:
123
{
var user = await _userManager.FindByEmailAsync(email);
var forgotPassLink = Url.Action(nameof(ForgotPassword), "Account",
new { returnUrl }, Request.Scheme);
var content = string.Format(@"Your account is locked out,
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 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:
124
Now, if we try to log in with 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
125
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
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.
126
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);
}
127
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.
128
Two-Step Implementation
[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>
129
<div class="row">
<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);
}
130
}
}
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:
131
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):
132
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.
133
Then, we need to choose the External user type:
And finally, we have to add the name of the application and click the Save
button at the bottom of the screen:
134
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:
135
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
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));
136
services.AddAuthentication()
.AddGoogle(options =>
{
options.ClientId = "456249573827-
r048bp38s62kjtgibcbja2d99ct9gsob.apps.googleusercontent.com";
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>
}
137
This code will iterate through all the registered external providers and add a
button for them on the right side of the Login page:
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 template, modified to suit our
needs and made more readable and easier to maintain.
Let’s copy this file and paste it in our current project at the same location –
Quickstart/Account .
138
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.
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 .
139
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.
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.
140
We can see that we have only attached a new provider to the existing
account.
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:
141
Awesome job.
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.
142