There are several issues that need to be covered when you want to secure a frontend module of a Spring Boot and Angular app. Learn how to handle security based on JWT and enhance user experience with the AuthGuard functionality.
In the Securing your Spring Boot and Angular app with JWT #1 – Introduction post you can find the description of the secured multi-module application which we are going to create.
In the Securing your Spring Boot and Angular app with JWT #2 – Backend post you can find the details of safeguarding the backend module.
The finished project is available in the GitHub repository – little-pinecone/jwt-spring-boot-angular-scaffolding.
In this post we are focusing on the frontend module.
To keep the code snippets reasonably short in this post, I don’t include the imports and full tests here. You can find them in the source code so don’t hesitate to inspect the repo. |
You can see the final directory tree for the frontend module on the image below:
Let’s start the frontend module with the following command:
$ ng serve
The landing page displays only the link to the Cookie dispenser
page, where the cookies received from the API should be listed:
Clicking on the Cookie dispenser
link triggers the 401
error:
The request for cookies was send to the API, but the backend module expects a valid JWT token to grant access to the secured resources. So far, everything works as expected – the API security blocks unauthorized calls to protected endpoints.
We need to apply login functionality, obtain a valid token and attach it to every API call. Furthermore, we have to guard routes that shouldn’t be even reached without the token. |
At this moment, the application serves a simple login page with a dummy form. Whatever we insert into the username
and password
inputs won’t be processed:
<!-- src/app/auth/components/login/login.component.html --> … <form #loginForm="ngForm"> <div class="form-group"> <label for="username">Username:</label> <input id="username" class="form-control" type="text" placeholder="Username" name="username" required> </div> …
We need to bind the credentials provided by users to the data model as in any ordinary Angular form.
Create the credentials.ts
file with the following class:
// src/app/auth/credentials.ts export class Credentials { public constructor(public username: string, public password: string){}; }
In the login.component.ts
file import the Credentials
class and instantiate the object with empty username
and password
:
// src/app/auth/components/login/login.component.ts … export class LoginComponent implements OnInit { credentials: Credentials = new Credentials('', ''); …
Bind both inputs with [(ngModel)]
and provide validation messages. Below is the code for the username
input:
<!-- src/app/auth/components/login/login.component.html --> … <form #loginForm="ngForm"> <div class="form-group"> <label for="username">Username:</label> <input id="username" class="form-control" type="text" placeholder="Username" name="username" required [(ngModel)]="credentials.username" #username="ngModel"> <div [hidden]="username.valid || username.pristine" class="text-danger"> Username is required </div> </div> …
Then copy the following code for the password
input:
<!-- src/app/auth/components/login/login.component.html --> … <div class="form-group"> <label for="password">Password:</label> <input id="password" class="form-control" type="text" placeholder="Password" name="password" required [(ngModel)]="credentials.password" #password="ngModel"> <div [hidden]="password.valid || password.pristine" class="text-danger"> Password is required </div> </div> …
Disable the Login
button until the form is completed correctly. You also want to call the login()
function when the form is submitted, therefore add the following code to the view:
<!-- src/app/auth/components/login/login.component.html --> … <button class="btn btn-warning" (click)="login()" [disabled]="!loginForm.form.valid"> <span class="ml-1">Log in</span> </button> …
We have to add the login()
function to the login.component.ts
file:
// src/app/auth/components/login/login.component.ts public login(): void { }
At this moment the method is empty, we will implement the login functionality later.
As a result, the form submission is turned off until the credentials are provided. In the screenshot below you can see that removing the username
value produces the validation message for that field:
The work done in this section is contained in the commit c9be87c3acae01148703bbcd449026eb90bccd79.
We need to call the API to authenticate a user who tries to log in. In case of a successful authentication, the backend sends the Authorization
header containing the JWT token that we need to extract, store and append to every subsequent API call.
Create the token service with the following command:
$ ng generate service auth/services/token
We will use it to call the API login
endpoint. Copy the following code to the service:
// src/app/auth/services/token.service.ts … const httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json' }), observe: 'response' as 'response' }; const API_URL = environment.apiUrl; @Injectable({ providedIn: 'root' }) export class TokenService { constructor(private http: HttpClient) { } public getResponseHeaders(credentials: Credentials) { let loginUrl = API_URL + '/login'; return this.http.post(loginUrl, credentials, httpOptions); } }
The apiUrl
value is already defined in the environment.ts
and environment.prod.ts
files:
// src/environments/environment.ts export const environment = { … apiUrl: 'http://localhost:8080/api' };
Adjust the apiUrl value to your development and production configuration. |
We created the service that sends the login requests. In the next step we are going to provide the logic for extracting the right header from the response.
Create the authorization service:
$ ng generate service auth/services/auth
We are going to define the key that will be used for storing the JWT token in the fronted module. What’s more, after users are authenticated, we want to redirect them from the '/login'
path. The redirectToUrl
variable is set to '/cookies'
by default, but will carry any path that user wanted to reach before logging in to the application. Of course, we need to inject the TokenService
to call the API and Router
to handle redirections.
Copy the following code to the service:
// src/app/auth/services/auth.service.ts … @Injectable({ providedIn: 'root' }) export class AuthService { static readonly TOKEN_STORAGE_KEY = 'token'; redirectToUrl: string = '/cookies'; constructor(private router: Router, private tokenService: TokenService) { } … }
Furthermore, we are going to add the core responsibility – the login()
function. It calls the getResponseHeaders
from the TokenService
and saves the token from the Authorization
header in the LocalStorage
. Add the following code to the auth.service.ts
file
// src/app/auth/services/auth.service.ts … export class AuthService { … public login(credentials: Credentials): void { this.tokenService.getResponseHeaders(credentials) .subscribe((res: HttpResponse<any>) => { this.saveToken(res.headers.get('authorization')); this.router.navigate([this.redirectToUrl]); }); } private saveToken(token: string){ localStorage.setItem(AuthService.TOKEN_STORAGE_KEY, token); } }
We also need a method to reach for the currently stored token, so add this last function to the file:
// src/app/auth/services/auth.service.ts … export class AuthService { … public getToken(): string { return localStorage.getItem(AuthService.TOKEN_STORAGE_KEY); } }
The authorization service is ready to be used in the app.
Let’s get back to our empty login()
function in the login.component.ts
file. We are going to inject the AuthService
to the constructor so we can use it inside the login()
method :
// src/app/auth/components/login/login.component.ts … export class LoginComponent implements OnInit { … constructor(private authService: AuthService) { } public login(): void { this.authService.login(this.credentials); } }
After those changes, submitting the login form with legitimate credentials will result in receiving a valid token from the API that will be stored in the LocalStorage
. A user is authenticated in the app and is automatically redirected to the "/cookies"
page. However, the token is not added to this request – instead of the page with tasty treats we are getting the 401
error. We are going to deal with this issue in the next section.
The work done in this section is contained in the commit 71b13383b4b77819234b854e97fec419c60bc450.
We need to intercept every request to the API and add the token from the LocalStorage
. Create the jwt.token.interceptor.ts
file and copy the following code to it:
// src/app/auth/interceptors/jwt.token.interceptor.ts … @Injectable() export class JwtTokenInterceptor implements HttpInterceptor { constructor(public auth: AuthService) {} intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { let interceptedRequest = request.clone({ setHeaders: { Authorization: `Bearer ${this.auth.getToken()}` } }); return next.handle(interceptedRequest); } }
We are injecting the AuthService
to access the getToken()
function. Then, we are appending the Bearer token to any request send to the API.
We have to declare usage of this interceptor in app.module
:
// src/app/app.module.ts … @NgModule({ … providers: [ { provide: HTTP_INTERCEPTORS, useClass: JwtTokenInterceptor, multi: true } ], … }) export class AppModule { }
Refresh the page and you will see the cookies served by the API.
The work done in this section is contained in the commit e561ff512771a06404cda39e293432429a691c5e.
The API security configuration allows us to call the logout endpoint:
// backend/src/main/java/in/keepgrowing/jwtspringbootangularscaffolding/config/SecurityConfig.java … @Override protected void configure(HttpSecurity httpSecurity) throws Exception { … .and() .logout() …
Let’s use it and add the logout()
function to the token.service
file:
// src/app/auth/services/token.service.ts … public logout() { let logoutUrl = API_URL + '/logout'; return this.http.get(logoutUrl, {responseType: 'text'}); } …
After a successful API call we can remove the token from the LocalStorage
. We will also need a function to check the current state of a user. Copy both methods to your auth.service
file:
// src/app/auth/services/auth.service.ts … public logout(): void { this.tokenService.logout() .subscribe(() =>{ localStorage.removeItem(AuthService.TOKEN_STORAGE_KEY); }); } public isLoggedIn(): boolean { return !!this.getToken(); } …
To display the Login
or Logout
option in the application header we need to inject the AuthService
into the HeaderComponent
and implement the logout()
function:
// src/app/layout/header/header.component.ts … export class HeaderComponent implements OnInit { … constructor(public authService: AuthService) { } public logout():void { this.authService.logout(); } …
We are going to display the Login
or Logout
option depending of the current state of a user returned by the authService.isLoggedIn()
method:
<!-- src/app/layout/header/header.component.html --> … <a routerLink="" class="text-white" [ngClass]="{'hidden': !authService.isLoggedIn() }" (click)="logout()"> <span class="ml-1">Log out</span> </a> <a routerLink="/login" class="text-white" [ngClass]="{'hidden': authService.isLoggedIn() }" routerLinkActive="active"> <span class="ml-1">Login</span> </a> …
We need to declare the hidden
class in the file for the header styles:
/* src/app/layout/header/header.component.scss */ .hidden { display: none; }
The header on your login page should look like the one on the screenshot below:
The work done in this section is contained in the commit bc5a317d7606e8ae56b4df4580f8988e1c3ef1d4.
Our API will return 401
error to the web console when unauthorized users try to access any secured route. That’s not a user friendly solution. We should redirect those users to the login page instead of sending requests without tokens. We are going to add AuthGuard to enhance the usability of our application.
Generate the guard with the following command:
$ ng generate guard auth/guards/auth
The guard will use AuthService
to verify if users are logged in and Router
to redirect them to the login form if they are not. Let’s inject them to the constructor:
// src/app/auth/guards/auth.guard.ts … export class AuthGuard implements CanActivate { constructor(private authService: AuthService, private router: Router) {} … }
We will replace the default code in the canActivate()
function with the following lines:
// src/app/auth/guards/auth.guard.ts … canActivate( next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean { let url: string = state.url; return this.checkLogin(url); } … }
We are declaring the url
variable which holds the path that the user tried to access. Depending on the boolean returned by the checkLogin()
method the access to that path will be granted or not. Copy the function to the file:
// src/app/auth/guards/auth.guard.ts … export class AuthGuard implements CanActivate { … private checkLogin(url: string): boolean { if(this.authService.isLoggedIn()) { return true; } this.authService.redirectToUrl = url; this.router.navigate(['/login']); return false; } }
The last thing that needs to be done is to declare paths that are guarded. Add the canActivate: [AuthGuard] part to your routing module:
// src/app/app-routing.module.ts … const routes: Routes = [ … { canActivate: [AuthGuard], path: 'cookies', component: CookieListComponent } ]; …
The page available for the authenticated and authorized users should look like the one on the screenshot below:
The work done in this section is contained in the commit f9d82ce0c3c4ee26b058c374f999df3f360a0d65.
Right now, when an unauthorised user tries to reach the protected /cookies
route, the Angular AuthGuard blocks sending a request to the backend and the user is redirected to the /login
page. However, when we have a route that is not guarded explicitly by Angular, the request will be sent to the backend and the user will receive a 401
error.
In our example we don’t deal with expired tokens. Therefore, if the token is no longer valid and the user wants to log out, the unauthorised request will be sent to the backend. Right now the frontend does not handle properly the API response shown in the screenshot below:
We can manage this situation by adding error handling in the interceptor:
// frontend/src/main/angular/src/app/auth/interceptors/jwt.token.interceptor.ts … constructor(public auth: AuthService, private router: Router) {} intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { … return next.handle(interceptedRequest).pipe(catchError(x => this.handleErrors(x))); } private handleErrors(err: HttpErrorResponse): Observable<any> { if (err.status === 401) { this.auth.redirectToUrl = this.router.url; this.router.navigate(['/login']); return of(err.message); } }
We created the handleErrors
function that redirects users to the /login
page when the API returns 401
error and we use it when the intercepted request is returned. To achieve this we had to inject the Router
instance in the constructor.
The work done in this section is contained in the commit e26dbc7649a0bb48bbf0f2f66ae32179490d120a.
Photo by Yingchou Han on StockSnap
Spring Security allows us to use role-based control to restrict access to API resources. However,…
A custom annotation in Spring Boot tests is an easy and flexible way to provide…
Delegating user management to Keycloak allows us to better focus on meeting the business needs…
Swagger offers various methods to authorize requests to our Keycloak secured API. I'll show you…
Configuring our Spring Boot API to use Keycloak as an authentication and authorization server can…
Keycloak provides simple integration with Spring applications. As a result, we can easily configure our…
View Comments
WOW! All 3 of your tutorials are THE BEST tutorials I encountered on the internet! Thanks for all the effort you put into this!
Thank you :) Recently I realised that I missed handling 401 errors from the backend so I added it to the tutorial ("Handle 401 errors returned from the backend" section) and to the repository (this commit).
It's not clear to me how your logout is working. When I hit /logout I'm getting a 404. I don't see anywhere in your config where you setup the logout URL as api/logout or how you're handling the logout functionality.
Hi,
We are going to explore files in the following order:
- header.component.html
- header.component.ts
- auth.service.ts
- token.service.ts
- SecurityConfig.java
When I click 'Logout' link in my
header.component.html
, I call thelogout()
function from theheader.component.ts
that calls thelogout()
function from theauthService
.Two things happen there (
auth.service.ts
):1.
logout()
function from thetokenService
is called.2. Token is removed from the localStorage.
So we go to the
token.service.ts
file. Thelogout()
function sets thelogoutUrl
variable. It's API_URL + 'logout'. I set API_URL in the environment.ts file to 'http://localhost:8080/api'. Therefore, the whole url is 'http://localhost:8080/api/logout
'.After setting the url, the
authService
uses it to send a get request to the backend:return this.http.get(logoutUrl, {responseType: 'text'});
There is a test in the token.service.spec.ts file: 'should call logout endpoint'.
In
SecurityConfig
I used thelogout()
functionality in theconfiguration
(line 46). And Spring Boot deals with logging out the user for me.However, as I'm reading the docs for
HttpSecurity.java
, I see that adding the logout to the configuration seems to be redundant as theLogoutConfigurer<HttpSecurity> logout()
"(…) is automatically applied when using WebSecurityConfigurerAdapter." and my
SecurityConfig
extendsWebSecurityConfigurerAdapter
.All in all, if info that the logout link was clicked is transferred through header.component.ts, auth.service.ts to token.service.ts (where the backend is finally called) logging out works.
The process is documented in the 'Add logout functionality section' of this post. You can also find the relevant code in the bc5a317d7606e8ae56b4df4580f8988e1c3ef1d4 commit.
I hope it helps :)