Adding JWT Authentication with refresh to Angular Project

 


In my previous post, I showed how to configure an Asp.Net Core application to use the built-in Identity platform to authenticate via a web page and use JWT for SPA's and mobile clients (See the article here). 

This tutorial will show you how to add JWT authentication to an angular project using the ASP.NET Core web API backend from my previous tutorial (see LINK). The project will consist of a register page, login page, and a home page that will be protected by a guard and show the token when logged in.

If you don't have angular installed on your system, you can run the following command.

npm install -g @angular/cli

Next, you will want to open a terminal window and execute the following command to create a new project.

ng new angular-jwt-auth-demo

Change to the new directory, and type the following command to make sure the project is set up and running properly.

ng serve

You should be able to copy the link provided into a browser and see your project. Now you will want to open your project in an IDE. I would recommend using Visual Studio Code. It is free and works really well for editing Javascript projects based on Angular.

Inside the editor, you will want to open the src directory and then open the environments directory. In this directory, you will want to edit the environment.ts file and add a constant that will point to our web API server. If you haven't built the ASP.NET Core project yet, you will want to do that first.

export const environment = {
  production: false,
  apiUrl: 'https://localhost:44372/'
};

Be sure to use the port value from your web api project. Mine happens to be 44372, but yours may be different.

Navigate up to the src directory again and change to the app directory. This is where we put the rest of the code. Right now there arent any subdirectories, but as we add different modules, we will create the directories we want. The structure of our project will look like the following:

app

  • app-routing
  • core
    • guards
    • interceptors
    • models
    • modules
    • services
  • home
  • login
  • register

The Angular CLI can do a lot of the heavy lifting for us. To start the project we will use the CLI to generate all the files we need. Open a terminal window and navigate to the root of the project. If you are using VS Code, you can open a terminal window in the IDE. Execute the following commands which will build the file and directory structure for our project.

App-routing

ng g module app-routing

Core Module

ng g module core

Guards

ng g guard core/guards/auth

When asked select the CanActivate item from the list.

Intercepters

ng g interceptor core/interceptors/jwt
ng g interceptor core/interceptors/unauthorized

Models

ng g interface core/models/application-user
ng g interface core/models/registration-user
ng g interface core/models/login-user
ng g interface core/models/login-result

Services

ng g service core/services/app-initializer
ng g service core/services/auth

Home

ng g component home

Login

ng g component login

Register

ng g component register

Now that the files and structure are all built, let's edit each file.

We will start with the models, so open the app/core/models/application-user.ts file and add the following code.

export interface ApplicationUser {
  username: string;
  roles: string[];
  originalUserName: string;
}

Next, open the app/core/models/registration-user.ts file and add the following code.

export interface RegistrationUser {
    userName: string;
        firstName: string;
        lastName: string;
        phoneNumber: string;
        email: string;
        password: string;
        confirm: string;
}

Next, open the app/core/models/login-user.ts file and add the following code.

export interface LoginUser {
    userName: string;
    password: string;
}

Next, open the app/core/models/login-result.ts file and add the following code.

export interface LoginResult {
  username: string;
  role[]: string[];
  originalUserName: string;
  accessToken: string;
  refreshToken: string;
}

Now let's edit the app/core/services/auth.service.ts file. Open the file and replace the code with the following.

import { Injectable, OnDestroy } from '@angular/core';
import { Router } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, of, Subscription } from 'rxjs';
import { map, tap, delay, finalize } from 'rxjs/operators';
import { ApplicationUser } from '../models/application-user';
import { RegistrationUser } from '../models/registration-user';
import { LoginUser } from '../models/login-user';
import { LoginResult } from '../models/login-result';
import { environment } from 'src/environments/environment';

@Injectable({
  providedIn: 'root'
})
export class AuthService implements OnDestroy {
  private readonly apiUrl = `${environment.apiUrl}api/account`;
  private timer: Subscription;
  private _user = new BehaviorSubject<ApplicationUser>(null);
  user$: Observable<ApplicationUser> = this._user.asObservable();

  private storageEventListener(event: StorageEvent) {
    if (event.storageArea === localStorage) {
      if (event.key === 'logout-event') {
        this.stopTokenTimer();
        this._user.next(null);
      }
      if (event.key === 'login-event') {
        this.stopTokenTimer();
        this.http.get<LoginResult>(`${this.apiUrl}/user`).subscribe((x) => {
          this._user.next({
            username: x.username,
            roles: x.roles,
            originalUserName: x.originalUserName,
          });
        });
      }
    }
  }

  constructor(private router: Router, private http: HttpClient) {
    window.addEventListener('storage', this.storageEventListener.bind(this));
  }

  ngOnDestroy(): void {
    window.removeEventListener('storage', this.storageEventListener.bind(this));
  }

  register(registrationUser: RegistrationUser): Observable<LoginResult> {
    return this.http.post<LoginResult>(`${this.apiUrl}/register`, registrationUser)
    .pipe(
      map((x) => {
        this._user.next({
          username: x.username,
          roles: x.roles,
          originalUserName: x.originalUserName,
        });
        this.setLocalStorage(x);
        this.startTokenTimer();
        return x;
      })
    );
  }

  login(loginUser: LoginUser) {
    return this.http
      .post<LoginResult>(`${this.apiUrl}/login`, loginUser)
      .pipe(
        map((x) => {
          this._user.next({
            username: x.username,
            roles: x.roles,
            originalUserName: x.originalUserName,
          });
          this.setLocalStorage(x);
          this.startTokenTimer();
          return x;
        })
      );
  }

  logout() {
    this.http
      .post<unknown>(`${this.apiUrl}/logout`, {})
      .pipe(
        finalize(() => {
          this.clearLocalStorage();
          this._user.next(null);
          this.stopTokenTimer();
          this.router.navigate(['login']);
        })
      )
      .subscribe();
  }

  refreshToken() {
    const refreshToken = localStorage.getItem('refresh_token');
    if (!refreshToken) {
      this.clearLocalStorage();
      return of(null);
    }

    return this.http
      .post<LoginResult>(`${this.apiUrl}/refresh-token`, { refreshToken })
      .pipe(
        map((x) => {
          this._user.next({
            username: x.username,
            roles: x.roles,
            originalUserName: x.originalUserName,
          });
          this.setLocalStorage(x);
          this.startTokenTimer();
          return x;
        })
      );
  }

  setLocalStorage(x: LoginResult) {
    localStorage.setItem('access_token', x.accessToken);
    localStorage.setItem('refresh_token', x.refreshToken);
    localStorage.setItem('login-event', 'login' + Math.random());
  }

  clearLocalStorage() {
    localStorage.removeItem('access_token');
    localStorage.removeItem('refresh_token');
    localStorage.setItem('logout-event', 'logout' + Math.random());
  }

  private getTokenRemainingTime() {
    const accessToken = localStorage.getItem('access_token');
    if (!accessToken) {
      return 0;
    }
    const jwtToken = JSON.parse(atob(accessToken.split('.')[1]));
    const expires = new Date(jwtToken.exp * 1000);
    return expires.getTime() - Date.now();
  }

  private startTokenTimer() {
    const timeout = this.getTokenRemainingTime();
    this.timer = of(true)
      .pipe(
        delay(timeout),
        tap(() => this.refreshToken().subscribe())
      )
      .subscribe();
  }

  private stopTokenTimer() {
    this.timer?.unsubscribe();
  }
}

Next, open the app/core/services/app-initializer.ts file and replace the contents with the following.

import { AuthService } from './auth.service';

export function appInitializer(authService: AuthService) {
  return () =>
    new Promise((resolve) => {
      console.log('refresh token on app start up')
      authService.refreshToken().subscribe().add(resolve);
    });
}

The next file we are going to edit is the app/core/guards/auth.guard.ts. Open the file and paste in the following code.

import { Injectable } from '@angular/core';
import {
  CanActivate,
  ActivatedRouteSnapshot,
  RouterStateSnapshot,
  UrlTree,
  Router,
} from '@angular/router';
import { Observable } from 'rxjs';
import { AuthService } from '../services/auth.service';
import { map } from 'rxjs/operators';

@Injectable({
  providedIn: 'root',
})
export class AuthGuard implements CanActivate {
  constructor(private router: Router, private authService: AuthService) {}

  canActivate(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ):
    | Observable<boolean | UrlTree>
    | Promise<boolean | UrlTree>
    | boolean
    | UrlTree {
    return this.authService.user$.pipe(
      map((user) => {
        if (user) {
          return true;
        } else {
          this.router.navigate(['login'], {
            queryParams: { returnUrl: state.url },
          });
          return false;
        }
      })
    );
  }
}

Now let's edit the app/core/interceptors/jwt.interceptor.ts and replace the contents with the following.

import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor,
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from 'src/environments/environment';
import { AuthService } from '../services/auth.service';

@Injectable()
export class JwtInterceptor implements HttpInterceptor {
  constructor(private authService: AuthService) {}

  intercept(
    request: HttpRequest<unknown>,
    next: HttpHandler
  ): Observable<HttpEvent<unknown>> {
    // add JWT auth header if a user is logged in for API requests
    const accessToken = localStorage.getItem('access_token');
    const isApiUrl = request.url.startsWith(environment.apiUrl);
    if (accessToken && isApiUrl) {
      request = request.clone({
        setHeaders: { Authorization: `Bearer ${accessToken}` },
      });
    }

    return next.handle(request);
  }
}

This will intercept all HTTP requests to the API and append the auth header with the access token.

Now we need to open the app/core/interceptors/unauthorized.interceptor.ts and replace the code with the following.

import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor,
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AuthService } from '../services/auth.service';
import { environment } from 'src/environments/environment';
import { Router } from '@angular/router';

@Injectable()
export class UnauthorizedInterceptor implements HttpInterceptor {
  constructor(private authService: AuthService, private router: Router) {}

  intercept(
    request: HttpRequest<unknown>,
    next: HttpHandler
  ): Observable<HttpEvent<unknown>> {
    return next.handle(request).pipe(
      catchError((err) => {
        if (err.status === 401) {
          this.authService.clearLocalStorage();
          this.router.navigate(['login'], {
            queryParams: { returnUrl: this.router.routerState.snapshot.url },
          });
        }

        if (!environment.production) {
          console.error(err);
        }
        const error = (err && err.error && err.error.message) || err.statusText;
        return throwError(error);
      })
    );
  }
}

This will intercept a 401 response from the server and redirect to the login page.

Now we want to open the app/core/core.module.ts and replace the contents with the following.

import { NgModule, APP_INITIALIZER, Optional, SkipSelf } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { AuthService } from './services/auth.service';
import { appInitializer } from './services/app-initializer.service';
import { JwtInterceptor } from './interceptors/jwt.interceptor';
import { UnauthorizedInterceptor } from './interceptors/unauthorized.interceptor';

@NgModule({
  declarations: [],
  imports: [CommonModule],
  providers: [
    {
      provide: APP_INITIALIZER,
      useFactory: appInitializer,
      multi: true,
      deps: [AuthService],
    },
    { provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true },
    {
      provide: HTTP_INTERCEPTORS,
      useClass: UnauthorizedInterceptor,
      multi: true,
    },
  ],
})
export class CoreModule {
  constructor(@Optional() @SkipSelf() core: CoreModule) {
    if (core) {
      throw new Error('Core Module can only be imported to AppModule.');
    }
  }
}

Now we need to add a new index.ts file to the core directory, so create a new file app/core/index.ts and add the following code.

export * from './guards/auth.guard';
export * from './services/auth.service';
export * from './models/application-user';
export * from './models/registration-user';
export * from './models/login-user';
export * from './models/login-result';

That should be all we need for the core pieces of the application. Now let's open the app/app-routing.module.ts and replace the contents with the following.

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AuthGuard } from '../core';
import { HomeComponent } from '../home/home.component';
import { LoginComponent } from '../login/login.component';
import { RegisterComponent } from '../register/register.component';

const routes: Routes = [
  {
    path: '',
    pathMatch: 'full',
    component: HomeComponent,
    canActivate: [AuthGuard],
  },
  { path: 'login', component: LoginComponent },
  { path: 'register', component: RegisterComponent },
  /* {
    path: 'management',
    loadChildren: () =>
      import('./management/management.module').then((m) => m.ManagementModule),
    canActivate: [AuthGuard],
  }, */
  { path: '**', redirectTo: '' },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {}

Now open the app/app.module.ts and replace the contents with the following.

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';

import { CoreModule } from './core/core.module';

import { AppRoutingModule } from './app-routing/app-routing.module';
import { AppComponent } from './app.component';
import { LoginComponent } from './login/login.component';
import { HomeComponent } from './home/home.component';
import { RegisterComponent} from './register/register.component'

@NgModule({
  declarations: [
    AppComponent,
    LoginComponent,
    HomeComponent,
    RegisterComponent,
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpClientModule,
    CoreModule,
    AppRoutingModule,
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

Next, we need to set up the component pages.

First, open the app/register/register.component.html and paste in the following code.

<div class="row">
    <div class="col-md-6 offset-md-3">
<div class="card mt-2">
    <div class="card-header">
        <h3>User Registration</h3>
    </div>
    <div class="card-body">
<form #form="ngForm" (ngSubmit)="register(form)">
    <div class="form-group">
    <label for="userName">Username</label>
    
      <input
        type="text"
        id="userName"
        name="username"
        class="form-control"
        placeholder="Username"
        ngModel required
        autofocus
      />
    </div>

    <div class="form-group">
      <label for="firstName">First Name</label>
      
        <input
          type="text"
          id="firstName"
          name="firstName"
          class="form-control"
          placeholder="First Name"
          ngModel required
          autofocus
        />
      </div>

      <div class="form-group">
        <label for="lastName">Last Name</label>
        
          <input
            type="text"
            id="lastName"
            name="lastName"
            class="form-control"
            placeholder="Last Name"
            ngModel required
            autofocus
          />
        </div>

        <div class="form-group">
          <label for="phoneNumber">Phone Number</label>
          
            <input
              type="text"
              id="phoneNumber"
              name="phoneNumber"
              class="form-control"
              placeholder="phoneNumber"
              ngModel
              autofocus
            />
          </div>

    <div class="form-group">
        <label for="email">Email</label>
        
          <input
            type="text"
            id="email"
            name="email"
            class="form-control"
            placeholder="Email"
            ngModel required
            autofocus
          />
        </div>
    

    <div class="form-group">
    <label for="inputPassword">Password</label>
      <input
        type="password"
        id="inputPassword"
        name="password"
        class="form-control"
        placeholder="Password"
        ngModel required
      />
    </div>

    <div class="form-group">
        <label for="confirm">Confirm Password</label>
          <input
            type="password"
            id="confirm"
            name="confirm"
            class="form-control"
            placeholder="Confirm Password"
            ngModel required
          />
        </div>
  
    <div class="checkbox mb-3 text-danger" *ngIf="loginError">
      Login failed. Please try again.
    </div>
    <button
      type="submit"
      class="btn btn-lg btn-primary"
      [disabled]="busy"
    >
      Register
    </button>
  
  </form>
</div>
</div>
</div>
</div>

This is a pretty basic web form. The form tag is where we add the angular form processing <form #form="ngForm" (ngSubmit)="register(form)"> this applies all the fields on the form to the model that is passed into the register method. Each of the input fields also has to have the ngModel attribute to be assigned to the form, and the field names need to match the property names in the RegistrationUser model.

Now open the app/register/register.component.ts file and replace the contents with the following.

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../core';
import { finalize } from 'rxjs/operators';
import { Subscription } from 'rxjs';

@Component({
  selector: 'app-register',
  templateUrl: './register.component.html',
  styleUrls: ['./register.component.css'],
})
export class RegisterComponent implements OnInit, OnDestroy {
  busy = false;
  loginError = false;
  private subscription: Subscription;

  constructor(
    private router: Router,
    private authService: AuthService
  ) {}

  ngOnInit(){}

  register(form) {
    this.busy = true;
    const returnUrl = '';
    this.authService
      .register(form.value)
      .pipe(finalize(() => (this.busy = false)))
      .subscribe(
        () => {
          this.router.navigate([returnUrl]);
        },
        () => {
          this.loginError = true;
        }
      );
  }

  ngOnDestroy(): void {
    this.subscription?.unsubscribe();
  }
}

In this component, we inject the auth service and we call the register method passing the form fields from our HTML form. Since the register method returns an observable, we subscribe to the method, and if no errors are returned, we redirect to the home page.

Now let's edit the app/login/login.component.html file and replace the contents with the following.

<div class="row">
    <div class="col-md-6 offset-md-3">
<div class="card mt-2">
    <div class="card-header">
        <h3>User Login</h3>
    </div>
    <div class="card-body">
<form #form="ngForm" (ngSubmit)="login(form)">
    <div class="form-group">
    <label for="userName">Username</label>
    
      <input
        type="text"
        id="userName"
        name="username"
        class="form-control"
        placeholder="Username"
        ngModel required
        autofocus
      />
    </div>

    <div class="form-group">
    <label for="inputPassword">Password</label>
      <input
        type="password"
        id="inputPassword"
        name="password"
        class="form-control"
        placeholder="Password"
        ngModel required
      />
    </div>
  
    <div class="checkbox mb-3 text-danger" *ngIf="loginError">
      Login failed. Please try again.
    </div>
    <button
      type="submit"
      class="btn btn-lg btn-primary"
      [disabled]="busy"
    >
      Sign in
    </button>
  
  </form>
</div>
</div>
</div>
</div>

<div class="row mt-3">
    <div class="col-md-6 offset-md-3">
<div class="card">
    <h4 class="card-header">If you dont have an account</h4>
    <div class="card-body">
      <div class="m-2">
        Please click <a routerLink="/register">here</a> to register.
      </div>
    </div>
  </div> 
  </div> 
</div>

Like the registration page, this page uses the ngForm to pass an object to the login method and the input fields match the property names in the LoginUser model.

Next, open the app/login/login.component.ts file and replace the code with the following.

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { AuthService } from '../core';
import { finalize } from 'rxjs/operators';
import { Subscription } from 'rxjs';

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.css'],
})
export class LoginComponent implements OnInit, OnDestroy {
  busy = false;
  loginError = false;
  private subscription: Subscription;

  constructor(
    private route: ActivatedRoute,
    private router: Router,
    private authService: AuthService
  ) {}

  ngOnInit(): void {
    this.subscription = this.authService.user$.subscribe((x) => {
      if (this.route.snapshot.url[0].path === 'login') {
        const accessToken = localStorage.getItem('access_token');
        const refreshToken = localStorage.getItem('refresh_token');
        if (x && accessToken && refreshToken) {
          const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '';
          this.router.navigate([returnUrl]);
        }
      } 
    });
  }

  login(form) {
    this.busy = true;
    const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '';
    this.authService
      .login(form.value)
      .pipe(finalize(() => (this.busy = false)))
      .subscribe(
        () => {
          this.router.navigate([returnUrl]);
        },
        () => {
          this.loginError = true;
        }
      );
  }

  ngOnDestroy(): void {
    this.subscription?.unsubscribe();
  }
}

The login component works very similarly to the register component in that it takes the form data and passes it to the auth service login method. If the login is successful the user is redirected to either the redirect URL or the home page. In the init method, the local storage and user observable are checked, and if they exist the user is redirected to the home page since they are already logged in.

Now let's edit the app/home/home.component.html file and replace the markup with the following.

<section class="container">
    <div
      class="card mt-4 shadow"
      *ngIf="authService.user$ | async as applicationUser; else login"
    >
      <h4 class="card-header">Hi {{ applicationUser.userName }},</h4>
      <div class="card-body">
        <div class="m-2">
          <h6>Your access token is</h6>
          {{ accessToken }}
        </div>
        <div class="m-2">
          <h6>Your refresh token is</h6>
          {{ refreshToken }}
        </div>
      </div>
    </div>
  </section>
  <ng-template #login>
    <div class="card mt-4 shadow">
      <h4 class="card-header">You have been logged out</h4>
      <div class="card-body">
        <div class="m-2">
          Please click <a routerLink="login">here</a> to login.
        </div>
      </div>
    </div>
  </ng-template>

Our home page just displays the user information retrieved from the server including the user name, access token, and the refresh token. You wouldn't normally display these items, but this is a demo. In the card header, we access the authService.user$ observable to get the userName property and display it. The rest of the properties are bound to the component.

Finally, let's edit the app/home/home.component.ts file and replace the contents with the following.

import { Component, OnInit } from '@angular/core';
import { AuthService } from '../core';

@Component({
  selector: 'app-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.css'],
})
export class HomeComponent implements OnInit {
  accessToken = '';
  refreshToken = '';
  
  constructor(public authService: AuthService) {}

  ngOnInit(): void {
    this.accessToken = localStorage.getItem('access_token');
    this.refreshToken = localStorage.getItem('refresh_token');
  }
}

This component is pretty simple. We create the accessToken and refreshToken properties and assign them in the ngOnInit method from the localStorage. The AuthService is injected in the constructor so it can be accessed from the HTML template.

You can download the source for this demo here

Comments

Popular posts from this blog

Asp.Net Core with Extended Identity and Jwt Auth Walkthrough

File Backups to Dropbox with PowerShell

Dynamic Expression Builder with EF Core