In order to handle JWT-based token authentication, we need to implement the required middleware for doing these tasks:
POST
requests coming from our client.headers
and cookiesAlthough ASP.NET Core natively supports JWT tokens, the only available middleware is the one validating the request headers
(JwtBearerMiddleware
). This leaves us with two choices: manually implement what's missing or rely on a third-party library that does just that. We'll try the hand-made route throughout the rest of this chapter, leaving the other alternative to the following chapter.
The first thing to do is define the required steps we need to take care of:
POST
requests carrying a username and password, and generate JWT tokens accordingly.JwtBearerMiddleware
to validate incoming requests containing a JWT in their headers
block.Login
form to allow our users to perform the login.Auth
service that will handle login/logout and store the JWT token so it can be reused.AuthHttp
wrapper that will add the JWT (if present) to the headers
block of each request.Sounds like a plan...let's do this.
The first thing we need to do is to add the following packages to our project:
"Microsoft.IdentityModel.Tokens": "5.0.0", "System.IdentityModel.Tokens.Jwt": "5.0.0"
As always, this can be done in a number of ways: NuGet, GUI, project.json, and others. We already know how to do that. The most recent version as we write is 5.0.0
for both packages, but we can expect it to change in the near future.
Once done, right-click to the OpenGameListWebApp project and create a /Classes/
folder. This is where we will put our custom implementations. We could also call it /AppCode/
, /Infrastructure/
, or anything else that we like.
Right-click on the new folder, choose the Add | New Item option, and add a new ASP.NET | Middleware Class, naming it JwtProvider.cs
just like in the following screenshot:
The new class will contain the default code for the ASP.NET core middleware class implementation pattern. We need to implement a lot of stuff here, so we'll split the content into several regions to make it more readable and understandable.
Let's add a private members region, wrapping the existing _next
variable and adding the following (new lines highlighted):
#region private members private readonly RequestDelegate _next; // JWT-related members private TimeSpan TokenExpiration; private SigningCredentials SigningCredentials; // EF and Identity members, available through DI private ApplicationDbContext DbContext; private UserManager<ApplicationUser> UserManager; private SignInManager<ApplicationUser> SignInManager; #endregion Private Members
Don't forget to add the required namespaces as well at the beginning of the file:
using Microsoft.IdentityModel.Tokens; using Microsoft.AspNetCore.Identity; using OpenGameListWebApp.Data.Users; using OpenGameListWebApp.Data; using System.Text;
As we can see, we're defining a number of variables here that we'll be using internally. Most of them will be instantiated in the constructor, either programmatically or by using the dependency injection pattern we've already used several times.
This region includes the minimum amount of info needed to sign in using a JWT token: a SecurityKey
and an Issuer
. We also define a TokenEndPoint
here, which is the URL path that we will use to process the incoming authentication login requests. To put it in other words, it's the route that the JwtProvider
will have to intercept (right before the standard MVC routing strategy) to properly handle the login requests:
#region Static Members private static readonly string PrivateKey = "private_key_1234567890"; public static readonly SymmetricSecurityKey SecurityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(PrivateKey)); public static readonly string Issuer = "OpenGameListWebApp"; public static string TokenEndPoint = "/api/connect/token"; #endregion Static Members
Notice that most of these static members have the public
access modifier. That's because we'll be using them outside of this class when we'll have to configure the token verification middleware.
Here's what the Constructor
region looks like:
#region Constructor public JwtProvider( RequestDelegate next, ApplicationDbContext dbContext, UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager) { _next = next; // Instantiate JWT-related members TokenExpiration = TimeSpan.FromMinutes(10); SigningCredentials = new SigningCredentials(SecurityKey, SecurityAlgorithms.HmacSha256); // Instantiate through Dependency Injection DbContext = dbContext; UserManager = userManager; SignInManager = signInManager; } #endregion Constructor
Here, we define the JWT token expiration time and encrypt the symmetrical security key that will be used to validate JWTs using a standard HmacSha256
encryption algorithm. We're also instantiating the EF/Identity members through DI, like we have done a number of times.
Let's move to the Invoke
method, which we conveniently wrapped inside the public
methods region:
#region public methods public Task Invoke(HttpContext httpContext) { // Check if the request path matches our TokenEndPoint if (!httpContext.Request.Path.Equals(TokenEndPoint, StringComparison.Ordinal)) return _next(httpContext); // Check if the current request is a valid POST with the appropriate content type (application/x-www-form-urlencoded) if (httpContext.Request.Method.Equals("POST") && httpContext.Request.HasFormContentType) { // OK: generate token and send it via a json-formatted string return CreateToken(httpContext); } else { // Not OK: output a 400 - Bad request HTTP error. httpContext.Response.StatusCode = 400; return httpContext.Response.WriteAsync("Bad request."); } } #endregion public methods
Here, we need to check whether the request path matches the chosen login path. If it does, we continue execution, otherwise we entirely skip the request. Right after that, we need to check whether the current request is a valid form-urlencoded
POST
. If that's the case, we call the CreateToken
internal method; otherwise, we return a 400
error response.
The CreateToken
method is where most of the magic takes place. We check the given username and password against our internal Identity database and, depending on the result, generate and return either a JWT token or an appropriate error response:
#region Private Methods private async Task CreateToken(HttpContext httpContext) { try { // retrieve the relevant FORM data string username = httpContext.Request.Form["username"]; string password = httpContext.Request.Form["password"]; // check if there's an user with the given username var user = await UserManager.FindByNameAsync(username); // fallback to support e-mail address instead of username if (user == null && username.Contains("@")) user = await UserManager.FindByEmailAsync(username); var success = user != null && await UserManager.CheckPasswordAsync(user, password); if (success) { DateTime now = DateTime.UtcNow; // add the registered claims for JWT (RFC7519). // For more info, see https: //tools.ietf.org/html/rfc7519#section-4.1 var claims = new[] { new Claim(JwtRegisteredClaimNames.Iss, Issuer), new Claim(JwtRegisteredClaimNames.Sub, user.Id), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Iat, new DateTimeOffset(now).ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64) // TODO: add additional claims here }; // Create the JWT and write it to a string var token = new JwtSecurityToken( claims: claims, notBefore: now, expires: now.Add(TokenExpiration), signingCredentials: SigningCredentials); var encodedToken = new JwtSecurityTokenHandler().WriteToken(token); // build the json response var jwt = new { access_token = encodedToken, expiration = (int)TokenExpiration.TotalSeconds }; // return token httpContext.Response.ContentType = "application/json"; await httpContext.Response.WriteAsync(JsonConvert.SerializeObject(jwt)); return; } } catch (Exception ex) { // TODO: handle errors throw ex; } httpContext.Response.StatusCode = 400; await httpContext.Response.WriteAsync("Invalid username or password."); } #endregion Private Methods
This will also require the following namespace references:
using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Newtonsoft.Json;
The code is pretty much self-documented using some inline comments indicating what we're doing here and there. We can see how the username and password are retrieved from the HttpContext
and checked using the AspNetCore.Identity UserManager
class; if the user exists, we issue a JSON-formatted object containing a JWT token and its expiration time, otherwise we return a HTTP 400
error.
It's also worth noting that, as an additional feature, we configured the method to allow clients to authenticate themselves using their e-mail address in place of the username; we did that to demonstrate how versatile this implementation actually is, since we do have full control over the whole authentication process.
The sample code provided for middleware classes includes a handy extension method that we can use to add our newborn provider to the request pipeline. We don't need to change it, so we'll just wrap it in an extension methods
region:
#region Extension Methods // Extension method used to add the middleware to the HTTP request pipeline. public static class JwtProviderExtensions { public static IApplicationBuilder UseJwtProvider(this IApplicationBuilder builder) { return builder.UseMiddleware<JwtProvider>(); } } #endregion Extension Methods
Here's how our JwtProvider
class will look after all this hard work:
using System; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.IdentityModel.Tokens; using OpenGameListWebApp.Data; using Microsoft.AspNetCore.Identity; using OpenGameListWebApp.Data.Users; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Newtonsoft.Json; namespace OpenGameListWebApp.Classes { public class JwtProvider { #region Private Members private readonly RequestDelegate _next; // JWT-related members private TimeSpan TokenExpiration; private SigningCredentials SigningCredentials; // EF and Identity members, available through DI private ApplicationDbContext DbContext; private UserManager<ApplicationUser> UserManager; private SignInManager<ApplicationUser> SignInManager; #endregion Private Members #region Static Members private static readonly string PrivateKey = "private_key_1234567890"; public static readonly SymmetricSecurityKey SecurityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(PrivateKey)); public static readonly string Issuer = "OpenGameListWebApp"; public static string TokenEndPoint = "/api/connect/token"; #endregion Static Members #region Constructor public JwtProvider( RequestDelegate next, ApplicationDbContext dbContext, UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager) { _next = next; // Instantiate JWT-related members TokenExpiration = TimeSpan.FromMinutes(10); SigningCredentials = new SigningCredentials(SecurityKey, SecurityAlgorithms.HmacSha256); // Instantiate through Dependency Injection DbContext = dbContext; UserManager = userManager; SignInManager = signInManager; } #endregion Constructor #region Public Methods public Task Invoke(HttpContext httpContext) { // Check if the request path matches our LoginPath if (!httpContext.Request.Path.Equals(TokenEndPoint, StringComparison.Ordinal)) return _next(httpContext); // Check if the current request is a valid POST with the appropriate content type (application/x-www-form-urlencoded) if (httpContext.Request.Method.Equals("POST") && httpContext.Request.HasFormContentType) { // OK: generate token and send it via a json-formatted string return CreateToken(httpContext); } else { // Not OK: output a 400 - Bad request HTTP error. httpContext.Response.StatusCode = 400; return httpContext.Response.WriteAsync("Bad request."); } } #endregion Public Methods #region Private Methods private async Task CreateToken(HttpContext httpContext) { try { // retrieve the relevant FORM data string username = httpContext.Request.Form["username"]; string password = httpContext.Request.Form["password"]; // check if there's an user with the given username var user = await UserManager.FindByNameAsync(username); // fallback to support e-mail address instead of username if (user == null && username.Contains("@")) user = await UserManager.FindByEmailAsync(username); var success = user != null && await UserManager.CheckPasswordAsync(user, password); if (success) { DateTime now = DateTime.UtcNow; // add the registered claims for JWT (RFC7519). // For more info, see https://tools.ietf.org/html/rfc7519#section-4.1 var claims = new[] { new Claim(JwtRegisteredClaimNames.Iss, Issuer), new Claim(JwtRegisteredClaimNames.Sub, username), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Iat, new DateTimeOffset(now).ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64) // TODO: add additional claims here }; // Create the JWT and write it to a string var token = new JwtSecurityToken( claims: claims, notBefore: now, expires: now.Add(TokenExpiration), signingCredentials: SigningCredentials); var encodedToken = new JwtSecurityTokenHandler().WriteToken(token); // build the json response var jwt = new { access_token = encodedToken, expiration = (int)TokenExpiration.TotalSeconds }; // return token httpContext.Response.ContentType = "application/json"; await httpContext.Response.WriteAsync(JsonConvert.SerializeObject(jwt)); return; } } catch (Exception ex) { // TODO: handle errors } httpContext.Response.StatusCode = 400; await httpContext.Response.WriteAsync("Invalid username or password."); } #endregion Private Methods } #region Extension Methods // Extension method used to add the middleware to the HTTP request pipeline. public static class JwtProviderExtensions { public static IApplicationBuilder UseJwtProvider(this IApplicationBuilder builder) { return builder.UseMiddleware<JwtProvider>(); } } #endregion Extension Methods }
Now that we have created our JwtProvider
middleware, we can add it to the request pipeline together with the built-in JwtBearerMiddleware
. In order to do that, open the Startup.cs
file and add the following code to the Configure
method (new lines highlighted):
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory, DbSeeder dbSeeder) { loggerFactory.AddConsole(Configuration.GetSection("Logging")); loggerFactory.AddDebug(); // Configure a rewrite rule to auto-lookup for standard default files such as index.html. app.UseDefaultFiles(); // Serve static files (html, css, js, images & more). See also the following URL: // https://docs.asp.net/en/latest/fundamentals/static-files.html for further reference. app.UseStaticFiles(new StaticFileOptions() { OnPrepareResponse = (context) => { // Disable caching for all static files. context.Context.Response.Headers["Cache-Control"] = Configuration["StaticFiles:Headers:Cache-Control"]; context.Context.Response.Headers["Pragma"] = Configuration["StaticFiles:Headers:Pragma"]; context.Context.Response.Headers["Expires"] = Configuration["StaticFiles:Headers:Expires"]; } }); // Add a custom Jwt Provider to generate Tokens app.UseJwtProvider(); // Add the Jwt Bearer Header Authentication to validate Tokens app.UseJwtBearerAuthentication(new JwtBearerOptions() { AutomaticAuthenticate = true, AutomaticChallenge = true, RequireHttpsMetadata = false, TokenValidationParameters = new TokenValidationParameters() { IssuerSigningKey = JwtProvider.SecurityKey, ValidateIssuerSigningKey = true, ValidIssuer = JwtProvider.Issuer, ValidateIssuer = false, ValidateAudience = false } }); // Add MVC to the pipeline app.UseMvc(); // TinyMapper binding configuration TinyMapper.Bind<Item, ItemViewModel>(); // Seed the Database (if needed) try { dbSeeder.SeedAsync().Wait(); } catch (AggregateException e) { throw new Exception(e.ToString()); } }
To avoid compilation errors, be sure to declare the following namespaces to the beginning of the file:
using OpenGameListWebApp.Classes; using Microsoft.IdentityModel.Tokens;
It's important to focus on two important things here:
MVC
gets added after JwtProvider
and JwtBearerAuthentication
, so the MVC
default routing strategies won't interfere with them.app.UseIdentity()
extension because it internally wraps app.UseCookieAuthentication()
, which is something we don't need. We might want to add it if we want to support cookies over headers
, or even use both of them.To know more about what's under the hood of app.UseIdentity()
, it can be useful to take a look at the extension's source code, which is publicly available on GitHub at the following URL:
https://github.com/aspnet/Identity/blob/dev/src/Microsoft.AspNetCore.Identity/BuilderExtensions.cs.
With this, we're done with the server-side part of our job. Let's switch to the client side.
Remember that /Scripts/app/login.component.ts
sample we created back in Chapter 3, Angular 2 Components and Client-Side Routing. The time has come to update it into a proper login
form.
Open that file and modify the existing, almost empty template
with the following code:
<div class="login-container"> <h2 class="form-login-heading">Login</h2> <div class="alert alert-danger" role="alert" *ngIf="loginError"> <strong>Warning:</strong> Username or Password mismatch </div> <form class="form-login" [formGroup]="loginForm" (submit)="performLogin($event)"> <input formControlName="username" type="text" class="form-control" placeholder="Your username or e-mail address" required autofocus /> <input formControlName="password" type="password" class="form-control" placeholder="Your password" required /> <div class="checkbox"> <label> <input type="checkbox" value="remember-me"> Remember me </label> </div> <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button> </form> </div>
That's a simple login form with some Bootstrap and custom classes. Notice that we also defined an ngFormModel
and an event handler method called performLogin
that will trigger on each submit. Both should be added within the component's class
implementation in the following way (new lines highlighted):
export class LoginComponent { title = "Login"; loginForm = null; constructor(private fb: FormBuilder) { this.loginForm = fb.group({ username: ["", Validators.required], password: ["", Validators.required] }); } performLogin(e) { e.preventDefault(); alert(JSON.stringify(this.loginForm.value)); } }
We're introducing two new classes here:
FormGroup
, which is how Angular 2 handles model-driven (or reactive) forms, we'll say more regarding this topic in a short while.Validators.required
, Validators.minLength(n)
, and Validators.maxLength(n)
. The names are self-explanatory, so we'll just say that we're using the first one, at least for now.In order to use these classes, we need to add the following import
statement at the beginning of the file:
import {FormBuilder, Validators} from "@angular/forms";
As we can see, there's also a performLogin
method that we didn't implement much. We're just opening a UI alert to ensure us that everything is working so far, then bring the user back to our welcome view.
While we're here, let's take the chance to also add the Router
component, so we'll be able to send the user somewhere right after the login. We can easily do that using the same DI technique we've already used a number of times.
This is how the login.component.ts
will look after these changes:
import {Component} from "@angular/core";
import {FormBuilder, Validators} from "@angular/forms";
import {Router} from "@angular/router";
@Component({
selector: "login",
template: `
<div class="login-container">
<h2 class="form-login-heading">Login</h2>
<form class="form-login" [ngFormModel]="loginForm" (submit)="performLogin($event)">
<input ngControl="username" type="text" class="form-control" placeholder="Your username or e-mail address" required autofocus />
<input ngControl="password" type="password" class="form-control" placeholder="Your password" required />
<div class="checkbox">
<label>
<input type="checkbox" value="remember-me"> Remember me
</label>
</div>
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
</form>
</div>
`
})
export class LoginComponent {
title = "Login";
loginForm = null;
constructor(
private fb: FormBuilder,
private router: Router) {
this.loginForm = fb.group({
username: ["", Validators.required],
password: ["", Validators.required]
});
}
performLogin(e) {
e.preventDefault();
alert(JSON.stringify(this.loginForm.value));
}
}
As for the custom CSS classes, we can add them to our Scripts/less/style.less
file:
.login-container { max-width: 330px; padding: 15px; .form-login { margin: 0 0 10px 20px; .checkbox { margin-bottom: 10px; } input { margin-bottom: 10px; } } }
Our renewed LoginComponent
should compile just fine. However, if we try to run the app now, we would get a full-scale Angular 2 runtime error in the browser's console log:
Pretty scary, isn't it?
When we see something like that in Angular 2, it usually means that we're missing a required module. That's exactly the case. In order to use reactive forms classes, we need to open our /Scripts/app/app.module.ts
file and append ReactiveFormsModule
to the following existing import
statement, near the beginning of the file:
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
And also add it to the imports
array as follows:
imports: [
BrowserModule,
HttpModule,
FormsModule,
ReactiveFormsModule,
RouterModule,
AppRouting
],
Once done, our application will be able to run without errors.
Wait a minute...FormsModule
has been there since Chapter 3, Angular 2 Components and Client-Side Routing! On top of that, we even used it to build the ItemDetailEditComponent
form, which happens to work just fine! Why do we need ReactiveFormsModule
now?
As a matter of fact, we don't; we could stick to the FormsModule
and build another template-driven form just like the one we already did. As a matter of fact, since this is a tutorial application, we took the chance to use the alternative strategy provided by Angular 2 to build forms: the model-driven (or reactive) forms approach.
This clarification raises a predictable question: which one of them is better? The answer is not easy, as both techniques have their advantages. To keep it extremely simple, we can say that template-driven forms are generally simpler to pull off, but they're rather difficult to test and validate as they become complex; conversely, model-driven forms do have an harder learning curve but they usually perform better when dealing with large forms, as they allow us to unit test their whole validation logic.
We won't explore these topics further, as they would take us way beyond the scope of this book. For more info regarding template-driven and model-driven forms, we strongly suggest reading the following article from the Angular 2 developers blog:
http://blog.angular-university.io/introduction-to-angular-2-forms-template-driven-vs-model-driven/
And also check out the official Angular 2 documentation regarding forms:
Let's do a quick test right now. Hit F5 and click on the Login top navigation bar. We should be welcomed by something like this:
Let's now check the validators by hitting the Sign in button, leaving the input fields empty. We can see the two textboxes react accordingly, since they're both expecting a required value:
Finally, let's test the outcome JSON by filling up the input fields with some random values and pressing the Sign in button again:
That's it. It seems that our login form is working fine.
Now we need to create a dedicated service to handle the login and logout operations.
Right-click on the /Scripts/app/
folder, select Add | New Item and add a new auth.service.ts
file to the project, then fill it with the following code:
import {Injectable, EventEmitter} from "@angular/core"; import {Http, Headers, Response, RequestOptions} from "@angular/http"; import {Observable} from "rxjs/Observable"; @Injectable() export class AuthService { authKey = "auth"; constructor(private http: Http) { } login(username: string, password: string): any { var url = "api/connect/token"; // JwtProvider's LoginPath var data = { username: username, password: password, client_id: "OpenGameList", // required when signing up with username/password grant_type: "password", // space-separated list of scopes for which the token is issued scope: "offline_access profile email" }; return this.http.post( url, this.toUrlEncodedString(data), new RequestOptions({ headers: new Headers({ "Content-Type": "application/x-www-form-urlencoded" }) })) .map(response => { var auth = response.json(); console.log("The following auth JSON object has been received:"); console.log(auth); this.setAuth(auth); return auth; }); } logout(): boolean { this.setAuth(null); return false; } // Converts a Json object to urlencoded format toUrlEncodedString(data: any) { var body = ""; for (var key in data) { if (body.length) { body += "&"; } body += key + "="; body += encodeURIComponent(data[key]); } return body; } // Persist auth into localStorage or removes it if a NULL argument is given setAuth(auth: any): boolean { if (auth) { localStorage.setItem(this.authKey, JSON.stringify(auth)); } else { localStorage.removeItem(this.authKey); } return true; } // Retrieves the auth JSON object (or NULL if none) getAuth(): any { var i = localStorage.getItem(this.authKey); if (i) { return JSON.parse(i); } else { return null; } } // Returns TRUE if the user is logged in, FALSE otherwise. isLoggedIn(): boolean { return localStorage.getItem(this.authKey) != null; } }
This code has some resemblance to the one we used in the item.service.ts
class. This can be expected, since both are Angular 2 service-type components used to instantiate service accessor objects, with the purpose of sending and receiving data to and from the web APIs. However, there are some key differences that might be worthy of attention:
Login
method's POST
request has been set to application/x-www-form-urlencoded
instead of application/json
to comply with the requirements set in the JwtProvider
class.localStorage
object, which is part of HTML5's Web Storage API. This is a local caching object that keeps its content with no given expiration date. That's a great way to store our JWT-related JSON response, as we want to keep it even when the browser is closed. Before doing that, we choose to convert it into a string
using JSON.stringify
, since not all localStorage
browser implementations can store JSON-type objects flawlessly.It's worth noting that we defined three methods to handle localStorage
: setAuth()
, getAuth()
, and isLoggedIn()
. The first one is in charge of insert
, update
, and delete
operations; the second will retrieve the auth
JSON object (if any); and the last one can be used to check whether the current user is authenticated or not, without having to JSON.parse
it.
In order to test our new AuthService
component, we need to hook it up to the AppModule
and to the LoginComponent
we created a short while ago.
Open the
/Scripts/app/app.module.ts
file and add the following
import
line between the AppRouting
and ItemService
lines:
import {AppRouting} from "./app.routing";
import {AuthService} from "./auth.service";
import {ItemService} from "./item.service";
Then scroll down to the providers array and add it there too:
providers: [
AuthService,
ItemService
],
Once done, switch back to the Scripts/app/login.component.ts
file and replace the content of the source code as follows (new/updated lines are highlighted):
import {Component} from "@angular/core"; import {FormBuilder, Validators} from "@angular/forms"; import {Router} from "@angular/router"; import {AuthService} from "./auth.service"; @Component({ selector: "login", template: ` <div class="login-container"> <h2 class="form-login-heading">Login</h2> <div class="alert alert-danger" role="alert" *ngIf="loginError"><strong>Warning:</strong> Username or Password mismatch</div> <form class="form-login" [formGroup]="loginForm" (submit)="performLogin($event)"> <input formControlName="username" type="text" class="form-control" placeholder="Your username or e-mail address" required autofocus /> <input formControlName="password" type="password" class="form-control" placeholder="Your password" required /> <div class="checkbox"> <label> <input type="checkbox" value="remember-me"> Remember me </label> </div> <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button> </form> </div> ` }) export class LoginComponent { title = "Login"; loginForm = null; loginError = false; constructor( private fb: FormBuilder, private router: Router, private authService: AuthService) { this.loginForm = fb.group({ username: ["", Validators.required], password: ["", Validators.required] }); } performLogin(e) { e.preventDefault(); var username = this.loginForm.value.username; var password = this.loginForm.value.password; this.authService.login(username, password) .subscribe((data) => { // login successful this.loginError = false; var auth = this.authService.getAuth(); alert("Our Token is: " + auth.access_token); this.router.navigate([""]); }, (err) => { console.log(err); // login failure this.loginError = true; }); } }
What we did here is pretty straightforward:
AuthService
component and added it to the constructor, so we can have it available using DI.loginError
local variable that will reflect the outcome of the last login attempt.<div>
element acting as an alert, to be shown whenever the loginError
becomes true
.performLogin
method to make it send the username and password values to the AuthService
component's login
method, so it can perform the following tasks:JwtProvider
middlewarelocalStorage
object cachetrue
in case of success or false
in case of failure
Let's run a quick test to see whether everything is working as expected. Hit F5, then navigate through the login view using the top navigation menu. Once there, fill in the login form with some incorrect data to test the Wrong Username or Password alert and left-click on the Sign in button:
Now, let's test a successful login attempt by filling in the form again, this time using the actual Admin user credentials as defined within the DbSeeder
class:
Then, left-click on the Sign in button.
If everything has been set up properly, we should receive the following response:
If we see something like this, it means that our JwtProvider
works!
All we need to do now is to find a way to put that token inside the headers
of all our subsequent requests, so we can check the token validation as well and complete our authentication cycle.
A rather easy way to do that with Angular 2 is create a wrapper class that will internally use the standard Http
component right after having it configured to suit our needs.
Right-click on the /Scripts/app/
folder, then select Add | New Item. Add a new auth.http.ts
file to the project and fill it with the following code:
import {Injectable} from '@angular/core'; import {Http, Headers} from '@angular/http'; @Injectable() export class AuthHttp { http = null; authKey = "auth"; constructor(http: Http) { this.http = http; } get(url, opts = {}) { this.configureAuth(opts); return this.http.get(url, opts); } post(url, data, opts = {}) { this.configureAuth(opts); return this.http.post(url, data, opts); } put(url, data, opts = {}) { this.configureAuth(opts); return this.http.put(url, data, opts); } delete(url, opts = {}) { this.configureAuth(opts); return this.http.delete(url, opts); } configureAuth(opts: any) { var i = localStorage.getItem(this.authKey); if (i != null) { var auth = JSON.parse(i); console.log(auth); if (auth.access_token != null) { if (opts.headers == null) { opts.headers = new Headers(); } opts.headers.set("Authorization", `Bearer ${auth.access_token}`); } } } }
There's not much to say here, it's just a wrapper that calls the configureAuth
method internally to add the JWT token stored in the browser's localStorage
,if any, to each request's headers
.
Since we'll be using the AuthHttp
wrapper anywhere in our application, the first thing we need to do is add it to the application's root
module, just like we did with the AuthService
a short while ago. Open the Scripts/app/app.module.ts
file and add the usual import line between AppRouting
and AuthService
:
import {AppRouting} from "./app.routing";
import {AuthHttp} from "./auth.http";
import {AuthService} from "./auth.service";
And also add it to the providers
array as follows:
providers: [
AuthHttp,
AuthService,
ItemService
],
Now we can update each and every Http
reference included in our other Angular 2 files and replace them with AuthHttp
. As we can easily guess, the affected components are the two service classes we're using to connect through the web API interface:
auth.service.ts
and
item.service.ts
.
For both of them, we need to add the following line at the beginning of the file:
import {AuthHttp} from "./auth.http";
And change the constructor parameters in the following way:
constructor(private http: AuthHttp) {
It's time to see whether our manual JWT-based auth
implementation is working as expected. Before doing that, though, we need to define some testable navigation patterns that will allow us to differentiate the logged-in user from the anonymous one. It's actually easy to do that, since we already have some content that should be made accessible to authenticated users only. We need to handle them on the client side and also on the server side.
Let's start by updating the main menu navigation bar. Open the
Scripts/app/app.component.ts
file and add the following import
reference near the top:
import {AuthService} from "./auth.service";
Right after that, change the template
section in the following way (new/updated lines are highlighted):
<nav class="navbar navbar-default navbar-fixed-top"> <div class="container-fluid"> <input type="checkbox" id="navbar-toggle-cbox"> <div class="navbar-header"> <label for="navbar-toggle-cbox" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar"> <span class="sr-only">Toggle navigation</span> <span class="icon-bar"></span> <span class="icon-bar"></span> <span class="icon-bar"></span> </label> <a class="navbar-brand" href="javascript:void(0)"> <img alt="logo" src="/img/logo.svg" /> </a> </div> <div class="collapse navbar-collapse" id="navbar"> <ul class="nav navbar-nav"> <li [class.active]="isActive([''])"> <a class="home" [routerLink]="['']">Home</a> </li> <li [class.active]="isActive(['about'])"> <a class="about" [routerLink]="['about']">About</a> </li> <li *ngIf="!authService.isLoggedIn()" [class.active]="isActive(['login'])"> <a class="login" [routerLink]="['login']">Login</a> </li> <li *ngIf="authService.isLoggedIn()"> <a class="logout" href="javascript:void(0)" (click)="logout()">Logout</a> </li> <li *ngIf="authService.isLoggedIn()" [class.active]="isActive(['item/edit', 0])"> <a class="add" [routerLink]="['item/edit', 0]">Add New</a> </li> </ul> </div> </div> </nav> <h1 class="header">{{title}}</h1> <div class="main-container"> <router-outlet></router-outlet> </div>
What we did here is pretty easy to understand:
ngIf
built-in directive tothe Login menu element, since we don't want it to appear if the user is already logged in.logout()
method, which we'll be adding shortly.ngIf
condition to the New Item menu element, as it should be seen by logged-in users only.In order to use the authService
object, we also need to instantiate it through dependency injection within the class constructor, which is another thing we have to change (new/updated lines highlighted):
constructor(public router: Router, public authService: AuthService) { }
Finally, we need to implement that logout()
method we talked about earlier:
logout(): boolean { // logs out the user, then redirects him to Welcome View. if (this.authService.logout()) { this.router.navigate([""]); } return false; }
Nothing odd here, just a standard logout and redirect behavior to adopt when the user chooses to perform a logout
.
The changes we applied to the AppComponent
template should also be performed in the ItemDetailViewComponent
templates as well. Open Scripts/app/item-detail-view.component.ts
and add the import line:
import {AuthService} from "./auth.service";
Then move to the constructor and add the AuthService
reference there for DI (new code highlighted):
constructor(
private authService: AuthService,
private itemService: ItemService,
private router: Router,
private activatedRoute: ActivatedRoute) { }
And finally, update the template
section accordingly, using the same ngIf
built-in directive we used before to show/hide the Edit tab accordingly to the current user's logged in status:
<li *ngIf="authService.isLoggedIn()" role="presentation"> <a href="javascript:void(0)" (click)="onItemDetailEdit(item)">Edit</a> </li>
Let's hit F5 and see whether everything is working as it should. We should start as anonymous users and see something like this:
We can see that the New Item menu element is gone. That's expected; we're not logged in, so we shouldn't be able to add a new item.
From there, we can click the Login menu element and be brought to the login view, where we can input the admin credentials (admin
/pass4admin
, in case we forgot). As soon as we hit the Sign In button, we will be routed back to the welcome view, where we should be greeted by something like the following screenshot:
The Login menu element is gone, replaced by Logout and Add New. We can then click on Logout and see both of them replaced by the former again.
So far, so good. However, we're not done with the client yet. These modifications prevent the user from clicking some links they're not allowed to see, yet they are unable to stop the user from going to their given destinations. For example, the user could manually input the routes within the browser's navigation bar and go to the login view while being already logged in, or even worse access the add/edit item view despite being anonymous.
In order to avoid that, we can add a login status check within the login.component.ts
constructor (new lines highlighted):
constructor( private fb: FormBuilder, private router: Router, private authService: AuthService) { if (this.authService.isLoggedIn()) { this.router.navigate([""]); } this.loginForm = fb.group({ username: ["", Validators.required], password: ["", Validators.required] }); }
Also add it to the ngOnInit
startup method within the item-detail-edit.component.ts
file:
ngOnInit() { if (!this.authService.isLoggedIn()) { this.router.navigate([""]); } var id = +this.activatedRoute.snapshot.params["id"]; if (id) { this.itemService.get(id).subscribe( item => this.item = item ); } else if (id === 0) { console.log("id is 0: adding a new item..."); this.item = new Item(0, "New Item", null); } else { console.log("Invalid id: routing back to home..."); this.router.navigate([""]); } }
Doing this will also require adding the corresponding import
reference line near the topmost section of the item-detail-edit.component.ts
file:
import {AuthService} from "./auth.service";
And the DI injection in the constructor method:
constructor(
private authService: AuthService,
private itemService: ItemService,
private router: Router,
private activatedRoute: ActivatedRoute) { }
That way, any unauthorized user will be bounced back whenever they try to manually hack our route mechanism by issuing a direct request to these views.
Now that our client is more or less ready, it's time to shield our web API interface from unauthorized requests as well. We can easily do that using the [Authorize]
attribute, which can be used to restrict access to any controller and/or controller method we don't want to open to unauthorized access.
To implement the required authorization behavior, it could be wise to use it on the Add
, Update
, and Delete
methods of our ItemsController
class (new lines are highlighted):
[HttpPost()] [Authorize] public IActionResult Add([FromBody]ItemViewModel ivm) { [...] } [HttpPut("{id}")] [Authorize] public IActionResult Update(int id, [FromBody]ItemViewModel ivm) { [...] } [HttpDelete("{id}")] [Authorize] public IActionResult Delete(int id) { [...] }
In order to use the [Authorize]
attribute, we also need to declare the following namespace reference at the beginning of the file:
using Microsoft.AspNetCore.Authorization;
Now these methods are protected against unauthorized access, as they will accept only requests coming from logged-in users/clients with a valid JWT
token. Those who don't have it will receive a 401 - Unauthorized
HTTP error response.
Before closing the ItemsController
class file, we should take the chance to remove the item.UserId
value override we defined back in Chapter 5, Persisting Changes, when we had no authentication mechanism in place:
// TODO: replace the following with the current user's id when authentication will be available. item.UserId = DbContext.Users.Where(u => u.UserName == "Admin").FirstOrDefault().Id;
Now that we're working with real users, we definitely have to remove this ugly workaround and find a way to retrieve the actual user ID. Luckily enough, when we implemented our very own JWT provider earlier, we did actually put it in the claims JWT token (JwtProvider
class, CreateToken
method):
new Claim(JwtRegisteredClaimNames.Sub, user.Id),
This means that we can retrieve it in the following way (updated code is highlighted):
item.UserId = User.FindFirst(ClaimTypes.NameIdentifier).Value;>
Let's perform this change and move on.
This minor update should be enough for now. However, it won't work when dealing with external OpenId and/or OAuth2 providers, as they will put their own data in these claims. Retrieving our local UserId
in such scenarios will require some additional work, such as querying a dedicated lookup table. We'll see more about this during Chapter 8, Third-Party Authentication and External Providers.
Before going further, it's definitely time to perform a client/server interaction test to ensure that our authorization pattern is working as expected.
From the Visual Studio source code editing interface, we can put a breakpoint right below the ItemsControllerAdd
method:
Once done, we can hit F5, navigate from the welcome view to the login view, and authenticate ourselves. Right after that, we'll be able to click upon the Add New menu element.
From there, we can fill in the form with some random text and click on the Save button. The form will consequently call the Add
method of ItemsController
, hopefully triggering our breakpoint.
Open a Watch window (Debug | Windows | Watch | Watch 1) and check the HttpContext.User.Identity.IsAuthenticated
property value:
If it's true
, it means that we've been successfully authenticated. That shouldn't be surprising, since our request already managed to get inside a method protected by an [Authorize]
attribute.