In this tutorial, I will discuss the use of JWT Token with Spring Security for user authentication and authorization.
Introduction
This will be my last tutorial for this year. Sometime in this year, I promised a tutorial on OpenID and how it can be used in a Spring Boot application. After some research, I was no longer interested in that because I didn't like any of the OpenID providers. I can use a self host alternative, but it is not something I enjoy. I put that idea in my back burner. Recently, I have read some articles, and discovered that one can still design a web application using JWT token with user authorization. So I took the time (two weeks of spare time), and tested it out. It works as I have imagined. In order to create something that is truly useful, a lot of work is still needed. This tutorial will discuss the most basic approach of creating an application using Spring Boot and JWT token for security measure.
Just a brief overview, the sample application will demonstrate two different yet cohesive parts. The back end APIs are constrained by role based access. The front end is a single page web application that has two sub pages. One is the login page, the other is the page where user can perform operations once authenticated. There were three different operations, add new data, delete existing data, and load all data. Based on what the role(s) user has, the user will either be able to perform the operation or get an error showing user is not authorized.
I don't have much experience with JWT tokens, or how to utilize it with an AngularJS application. The sample application for this tutorial is likely to have issues within its current design. I recommend you use it for research purposes, and not copy it as a basis for production use. If you are still interested, please read on. In the next section, I will explain the architecture in detail.
Architecture Overview
This application is divided into two different layers. At the front, we have the AngularJS based, single page, web application. At the back end, we have RESTFul web API application. I host both in the same spring application, for demo purposes. It should be easy enough to split the application into two different ones so that the front end web app can be hosted by something other than Spring Boot (like node.js), while the back end RESTFul API can still be hosted in the Spring Boot (or other technology as well).
The RESTFul API web application exposes an web URL for authentication. The front end can send a RESTFul request against this URL and get a response of JSON object containing not only the JWT auth token, but also the user info (including the user roles list). The JWT auth token also contains the user info as well. But at the front end, I don't decode the JWT token and get the user info via JavaScript, hence, getting the user info in plain text is a convenience for the front end. Once the front end receives the user info and auth token, it will store these in the session storage of the browser. If you research on session storage, you will see that the info retained in this storage will only be destroyed (non-retrievable after destruction) is when the application code deletes it or user closes the tab of the page. This is good enough to solve the problem where user refreshes the page and the page will still show the user is logged in instead of using the login page so that user is force to log back in again.
After user logs in, the index page will be displayed. The top section will show a list of books (title, year it was published, and ISBN code of the book). On the right side of each row, there is a Delete button, where user can click and the delete request will be sent to the back end where the RESTFul API will receive the request, check the user has role to delete, and send a response, which will cause the row in the table to disappear. If the user has the admin user role, it will send a success response. If the user lacks the role, the response will be error code 403 (forbidden). On the front end, the deletion request will display an error.
On the bottom of the index page, there is the form allowing user to add a new book info to the list. Again, the add request will be sent to the back end, and the RESTFul API will check the user role for Staff or ADMIN user roles. If either role is associated with the user, the add request will result a success response. Then the new book will be added to the list at the bottom. if the user does not have any of these two roles, then the response with be error 403, and an error will be displayed. The back end does not do much for these two requests and corresponding responses, it merely demonstrates the functionality of intercepting the quest, and use custom filter to check user role and return specific responses with appropriate HTTP status codes.
I will be showing a demo of how to add a security filter just before the user name and password check security filter. This is to extract the JWT token from the HTTP request header, validate the header's expiration time, extract the user info, then compare with user stored in data store to create the authentication of Spring Security and allow the RESTFul request to be filtered by the Spring Security annotations. This sample application also demos how to create and validate the JWT token, how it is attached as a header key-value pair to the HTTP request, and how error conditions can be handled in the entire work flow. In the next section, I will begin this tutorial with the added jar dependency to the Maven POM project file. From there, I will unravel all the good stuff about this sample application.
Addition Jar Dependencies for Maven POM File
In order to create JWT token, I had to include two additional jar dependencies to my Spring Boot project POM file:
io.jsonwebtoken
; javax.xml.bind
;
The first one is necessary to generate JWT token and encrypt and decrypt JWT token. The second one was added when I ran the application and found an exception. Adding this missing jar dependency would bypass it.
In the Maven POM file, these two were added to the end of the dependencies list:
...
<dependencies>
...
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
</dependencies>
...
Back End RESTFul API Design
As described before, this web application has a RESTFul web API layer. This layer exposes the functionalities:
- An API URL used to authenticate user
- A security filter for intercepting the HTTP request and check the JWT token
- A set of API URLs used to test user access
Besides these three, the web application's security configuration has to be setup, or none of these will work. So I will discuss all these in the following sub sections. Let's begin with the application start up entry. The entry looks like this:
package org.hanbo.boot.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class App
{
public static void main(String[] args)
{
SpringApplication.run(App.class, args);
}
}
There is nothing special about this. The App
class has the main entry method. In the main entry method, I register the class App
for Spring Boot start up class. That is all it does. In the next sub section, I will begin the discussion on the security configuration.
The Web Security Configuration
The web security configuration is a little harder than the start up entry code. I have done this many times, so it is very easy for me to copy some existing code and modify a little to make it work with this new application. Let me just show the entire class:
package org.hanbo.boot.app.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.
configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.
configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class WebAppSecurityConfig extends WebSecurityConfigurerAdapter
{
@Autowired
private AccessDeniedHandler accessDeniedHandler;
@Autowired
private MyJwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private MyJwtRequestFilter jwtRequestFilter;
@Override
protected void configure(HttpSecurity http) throws Exception
{
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/assets/**", "/public/**", "/authenticate").permitAll()
.anyRequest().authenticated().and()
.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler).and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
}
}
To me, it is pretty simple, but if you have never worked with Spring Security before, it would be hard. I recommend that you do some research on Spring Security, especially on how to use Spring Security on Spring MVC based web applications. Anyways, if you have experience on this, please go on. I will first explain the annotations used for the class:
...
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
...
These three annotations defined this class as a configuration provider, and it will enable Spring Security settings for the entire application. The last annotation is to enable the @PreAuthroize
for all RESTFul methods so that the methods can be protected.
The next important piece of this class is the method configure()
. This method does two things, one is to set the security filtering on the URL paths. The other is adding the JWT token filter just before the UsernamePasswordAuthenticationFilter
. This is necessary because adding the JWT token filter can transform the JWT token into security authentication token, then the next filter, which is the UsernamePasswordAuthenticationFilter
object, can filter the request as Spring security usually does:
@Override
protected void configure(HttpSecurity http) throws Exception
{
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/assets/**", "/public/**", "/authenticate").permitAll()
.anyRequest().authenticated().and()
.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler).and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
}
As shown, I configure the security filter to only pass sub URL path "/assets/*", "/public/*", and "/authenticate" without any authentication and authorization. For the requests to the URL sub paths, they all have to have proper authentication and authorization. I also added an authenticationEntryPoint
exception handler. This is the handler that would be called when Spring Security detects a request that does not have the authentication and authorization. Normally, this would be translated and transformed into a 302 redirect back to the login page. But for this sample application, a customized handler for this is necessary. Based on whether the request has the right JWT token or not, I would return a 401 (unauthorized) or a 403 (forbidden). I also added a access denied handler, but this might not be necessary. So if you can remove it, try remove it and see if it breaks. Finally, I added the session management policy to be sessionless. Yep. This is the way to set session creation policy to sessionless.
The last line in the method is to add my JWT token filter for HTTP request just before the filter called UsernamePasswordAuthenticationFilter
. Like I discussed before, the customized token filter will transform into a security that Spring Security can handle. These are all there is about the security configurations. And there are several classes that I have defined which are used in this configuration class. In the next section, I will explain how the JWT token filter for the request works - MyJwtRequestFilter
.
JWT Token Filter for HTTP Request
In this section, I will discuss the design of the JWT token filter. The full source code of this class looks like this:
package org.hanbo.boot.app.config;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.hanbo.boot.app.models.LoginUser;
import org.hanbo.boot.app.models.UserRole;
import org.hanbo.boot.app.security.MyJwtUserCheckService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import io.jsonwebtoken.ExpiredJwtException;
@Component
public class MyJwtRequestFilter extends OncePerRequestFilter
{
private final String _authorizationKey = "authorization";
private final String _bearerTokenPrefix = "bearer ";
@Autowired
private MyJwtUserCheckService jwtUserCheckService;
@Autowired
private MyJwtTokenUtils jwtTokenUtils;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException
{
System.out.println("--------------------------------");
System.out.println(request.getRequestURL().toString());
LoginUser tokenUserInfo = null;
String jwtToken = getJwtTokenFromHeader(request);
if (!StringUtils.isEmpty(jwtToken))
{
tokenUserInfo = extractJwtUserInfoFromToken(jwtToken);
if (tokenUserInfo != null)
{
SecurityContextHolder.getContext().setAuthentication(null);
if (!StringUtils.isEmpty(tokenUserInfo.getUserId()))
{
LoginUser userDetails = this
.jwtUserCheckService
.getUserDetails(tokenUserInfo.getUserId());
if (userDetails != null)
{
if (jwtTokenUtils.validateToken(jwtToken, userDetails))
{
List<GrantedAuthority> allAuths = convertUserRolesToGrantedAuthorities
(userDetails.getAllUserRoles());
if (allAuths != null && allAuths.size() > 0)
{
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
= new UsernamePasswordAuthenticationToken(
userDetails, null, allAuths
);
usernamePasswordAuthenticationToken
.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication
(usernamePasswordAuthenticationToken);
}
else
{
System.out.println("User has no roles associated with.");
SecurityContextHolder.getContext().setAuthentication(null);
}
}
else
{
System.out.println("User credential cannot be validated.");
SecurityContextHolder.getContext().setAuthentication(null);
}
}
else
{
System.out.println("No valid user credential available.");
SecurityContextHolder.getContext().setAuthentication(null);
}
}
else
{
System.out.println("Invalid user info detected. Authentication failed.");
SecurityContextHolder.getContext().setAuthentication(null);
}
}
else
{
System.out.println("Unable to get JWT Token");
SecurityContextHolder.getContext().setAuthentication(null);
}
}
else
{
System.out.println("JWT Token does not begin with Bearer String");
SecurityContextHolder.getContext().setAuthentication(null);
}
System.out.println("Try normal filtering");
chain.doFilter(request, response);
System.out.println("--------------------------------");
}
private String getJwtTokenFromHeader(HttpServletRequest request)
{
String retVal = "";
if (request != null)
{
final String requestTokenHeader = request.getHeader(_authorizationKey);
System.out.println("Found Auth Key: [" + requestTokenHeader + "]");
if (!StringUtils.isEmpty(requestTokenHeader) &&
requestTokenHeader.startsWith(_bearerTokenPrefix))
{
retVal = requestTokenHeader.substring(_bearerTokenPrefix.length());
}
}
return retVal;
}
private LoginUser extractJwtUserInfoFromToken(String tokenStrVal)
{
LoginUser retVal = null;
if (!StringUtils.isEmpty(tokenStrVal))
{
try
{
retVal = jwtTokenUtils.getUserInfoFromToken(tokenStrVal);
}
catch (IllegalArgumentException ex)
{
System.out.println("Unable to get JWT Token via token string decryption.");
retVal = null;
}
catch (ExpiredJwtException ex)
{
System.out.println("JWT Token has expired");
retVal = null;
}
}
return retVal;
}
private List<GrantedAuthority> convertUserRolesToGrantedAuthorities
(List<UserRole> allUserRoles)
{
List<GrantedAuthority> retVal = new ArrayList<GrantedAuthority>();
if (allUserRoles != null && allUserRoles.size() > 0)
{
for (UserRole role : allUserRoles)
{
if (role != null)
{
if (!StringUtils.isEmpty(role.getRoleId()))
{
retVal.add(new SimpleGrantedAuthority(role.getRoleId()));
}
}
}
}
return retVal;
}
}
This is a very important class. Yes it is a HTTP request filter. And it is extending the based class called OncePerRequestFilter
. When this class is used in the configuration, every request will be filtered by this, and there is no exception. This is how I intercept the requests, and transform the JWT token to a token that can be processed easily by Spring Security. The heavy lifting is done in the overridden method doFilterInternal()
. This method will use the request object to extract the JWT token, get it decrypted to get the user info and user roles, then transforming the user info and user roles to the appropriate security token.
There are several scenarios while processing the JWT token:
- The JWT token is no where found. In this case, we set the request authentication to
null
. And pass it to the next security filter in the chain. - The JWT token can be found but is invalid (token expired, token has invalid value, etc.). Again, we set the request authentication to
null
. And pass it to the next security filter in the chain. - The JWT token can be found and it is valid. Then we transform the token into the token for Spring Security to handle.
The other methods in this class are all helpers that this important method utilizing. They are self explanatory. I will focus on this method doFilterInternal()
. The first thing is to extract the authorization token from header. This is done as:
String jwtToken = getJwtTokenFromHeader(request);
This is pretty simple. All I have to do is to find the HTTP request header that is named "authorization". Then I have to validate the header value by looking at the prefix, it should be "bearer<space>". Then whatever was left would be the token value. That is the string I had to extract, the security token.
After I successfully get the security token, I will decode it for looged in user info, by this line:
tokenUserInfo = extractJwtUserInfoFromToken(jwtToken);
If the user info extraction from JWT token is successful, I will then use the user id in the extracted user info to find the user in the back end data store. This retrieved user info will be matched against the user info in the JWT token. This is done with this if
block:
if (jwtTokenUtils.validateToken(jwtToken, userDetails))
{
...
}
When the user match validation is successful, I will assign the UsernamePasswordAuthenticationToken
type token to the HTTP request. This is how I do it:
List<GrantedAuthority> allAuths = convertUserRolesToGrantedAuthorities
(userDetails.getAllUserRoles());
if (allAuths != null && allAuths.size() > 0)
{
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
= new UsernamePasswordAuthenticationToken(
userDetails, null, allAuths
);
usernamePasswordAuthenticationToken
.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication
(usernamePasswordAuthenticationToken);
}
The code is pretty simple, first I have to convert the user role from my user role type to Spring Security's GrantedAuthority
. Then I have to create an object of type UsernamePasswordAuthenticationToken
. Then I have to add additional information about the request to the new security token. That is what the usernamePasswordAuthenticationToken.setDetails()
call is for. Finally, I add the security token to the current thread's security context holder. This is how to add security token to the HTTP request.
Next, I like to discuss the utility class for handling the JWT token. This class is necessary to do the encryption and decryption of the JWT token. In addition, this utility class can also validate the expiration date time of the token. It is very useful. I will discuss this in the next sub section.
JWT Token Utility
JWT tokens are a special type of objects. It is basically a BASE64 encoded encrypted string. It has three parts, separated by the character ".
"; the first part is the header, followed by a content body, then finished by a signature. Creating and decoding such an object requires special classes. And a wrapper on top of such classes would simplify the operation a lot. This is why I created this MyJwtTokenUtils
. Here is the class:
package org.hanbo.boot.app.config;
import java.io.Serializable;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import org.hanbo.boot.app.models.LoginUser;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.thymeleaf.util.StringUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
@Component
public class MyJwtTokenUtils implements Serializable {
private static final long serialVersionUID = -2550185165626007488L;
public static final long JWT_TOKEN_VALIDITY = 15 * 60;
@Value("${jwt.secret}")
private String secret;
public LoginUser getUserInfoFromToken(String token)
{
LoginUser retVal = null;
String userInfoStr = getUserInfoStringFromToken(token);
if (!StringUtils.isEmpty(userInfoStr))
{
ObjectMapper mapper = new ObjectMapper();
try
{
retVal = mapper.readValue(userInfoStr, LoginUser.class);
}
catch(Exception ex)
{
System.out.println("Exception occurred. " + ex.getMessage());
ex.printStackTrace();
retVal = null;
}
}
return retVal;
}
public String getUserInfoStringFromToken(String token)
{
String retVal = null;
if (!StringUtils.isEmpty(token))
{
retVal = getClaimFromToken(token, Claims::getSubject);
}
return retVal;
}
public Date getExpirationDateFromToken(String token)
{
Date retVal = null;
if (!StringUtils.isEmpty(token))
{
retVal = getClaimFromToken(token, Claims::getExpiration);
}
return retVal;
}
public <T extends Object> T getClaimFromToken(String token,
Function<Claims, T> claimsResolver)
{
if (!StringUtils.isEmpty(token))
{
Claims claims = getAllClaimsFromToken(token);
return claimsResolver.apply(claims);
}
else
{
return null;
}
}
private Claims getAllClaimsFromToken(String token)
{
if (!StringUtils.isEmpty(token))
{
if (!StringUtils.isEmpty(secret))
{
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
else
{
System.out.println("Secret key is null or empty,
unable to decode claims from token.");
return null;
}
}
else
{
return null;
}
}
private Boolean isTokenExpired(String token)
{
if (!StringUtils.isEmpty(token))
{
Date expiration = getExpirationDateFromToken(token);
if (expiration == null)
{
System.out.println("Invalid expiration data. Invalid token detected.");
return false;
}
return expiration.before(new Date());
}
return false;
}
public String generateToken(LoginUser userInfo, Map<String, Object> claims)
{
String userInfoStr = "";
String retVal = null;
if (claims == null)
{
System.out.println("Claims object is null or empty, cannot createsecurity token.");
return retVal;
}
if (userInfo == null)
{
System.out.println("userInfo object is null or empty, cannot createsecurity token.");
return retVal;
}
try
{
ObjectMapper mapper = new ObjectMapper();
userInfoStr = mapper.writeValueAsString(userInfo);
retVal = doGenerateToken(claims, userInfoStr);
}
catch (Exception ex)
{
System.out.println("Exception occurred. " + ex.getMessage());
ex.printStackTrace();
retVal = null;
}
return retVal;
}
public String generateToken(LoginUser userDetails)
{
Map<String, Object> emptyClaims = new HashMap<String, Object>();
return generateToken(userDetails, emptyClaims);
}
private String doGenerateToken(Map<String, Object> claims, String subject)
{
String retVal = null;
if (StringUtils.isEmpty(secret))
{
System.out.println("Invalid secret key for token encryption.");
return retVal;
}
if (claims == null)
{
System.out.println("Invalid token claims object.");
return retVal;
}
if (StringUtils.isEmpty(subject))
{
System.out.println("Invalid subject value for the security token.");
return retVal;
}
return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt
(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + (JWT_TOKEN_VALIDITY * 1000)))
.signWith(SignatureAlgorithm.HS512, secret).compact();
}
public Boolean validateToken(String token, LoginUser userDetails)
{
if (!StringUtils.isEmpty(token))
{
LoginUser userInfo = getUserInfoFromToken(token);
if (userInfo != null)
{
if (userDetails != null)
{
String actualUserId = userInfo.getUserId();
if (!StringUtils.isEmpty(actualUserId) &&
actualUserId.equalsIgnoreCase(userDetails.getUserId()))
{
if (userDetails.isActive())
{
return !isTokenExpired(token);
}
else
{
System.out.println(String.format("User with id [%s] is not active.
Invalid token.", userInfo.getUserId()));
return false;
}
}
else
{
System.out.println("User in the token has a different
user id than expected. Invalid token.");
return false;
}
}
else
{
System.out.println("Expected user details object is invalid.
Unable to verify token validity.");
return false;
}
}
else
{
System.out.println("Decrypted user details object is invalid. Invalid token.");
return false;
}
}
else
{
System.out.println("Invalid token string detected. Invalid token.");
return false;
}
}
}
There are three methods that are most useful:
getUserInfoFromToken()
: This is useful when we need to extract the user info in my security filter. validateToken()
: This is useful for validating the user info against the user info in the JWT auth token. generateToken()
: This is useful during user authentication to create the token and send it back to the client side.
This is the method for getting the user info from the JWT auth token, getUserInfoFromToken()
:
public LoginUser getUserInfoFromToken(String token)
{
LoginUser retVal = null;
String userInfoStr = getUserInfoStringFromToken(token);
if (!StringUtils.isEmpty(userInfoStr))
{
ObjectMapper mapper = new ObjectMapper();
try
{
retVal = mapper.readValue(userInfoStr, LoginUser.class);
}
catch(Exception ex)
{
System.out.println("Exception occurred. " + ex.getMessage());
ex.printStackTrace();
retVal = null;
}
}
return retVal;
}
It calls the helper method getUserInfoStringFromToken()
which decodes the token body content for the user info as a plain text string. This string
is in JSON format. After it successfully extract the JSON data, then I use Jackson framework objects to deserialize the JSON object to a Java object of type LoginUser
.
The validateToken()
method looks like this:
public Boolean validateToken(String token, LoginUser userDetails)
{
if (!StringUtils.isEmpty(token))
{
LoginUser userInfo = getUserInfoFromToken(token);
if (userInfo != null)
{
if (userDetails != null)
{
String actualUserId = userInfo.getUserId();
if (!StringUtils.isEmpty(actualUserId) &&
actualUserId.equalsIgnoreCase(userDetails.getUserId()))
{
if (userDetails.isActive())
{
return !isTokenExpired(token);
}
else
{
System.out.println(String.format("User with id [%s] is not active.
Invalid token.", userInfo.getUserId()));
return false;
}
}
else
{
System.out.println("User in the token has a different user id
than expected. Invalid token.");
return false;
}
}
else
{
System.out.println("Expected user details object is invalid.
Unable to verify token validity.");
return false;
}
}
else
{
System.out.println("Decrypted user details object is invalid. Invalid token.");
return false;
}
}
else
{
System.out.println("Invalid token string detected. Invalid token.");
return false;
}
}
This method will first get the user info from the token. Then using the user's user id to compare to the user id of the user info from the back end user data store. Afterwards, it checks user is active and the token is not expire. The part of checking token is expire or not might be a bit excessive. This is because if the token is expired, the decoding will throw an exception.
This is the method for generating the security token:
public String generateToken(LoginUser userDetails)
{
Map<String, Object> emptyClaims = new HashMap<String, Object>();
return generateToken(userDetails, emptyClaims);
}
This is pretty simple, I create a empty claim HashMap
. Then I pass the user info and this empty Hashmap
to the helper method to create the actual token. The whole process is somewhat complicated. If you have time, please spend it and explore the inner workings.
In the next sub section, I will show you how the user authentication works. It will be the last of the Java code to be discussed in here. The rest, please feel free to explore by yourself.
RESTFul API for User Authentication
For user authentication, using JWT token is somewhat similar to the form based authentication. There are differences. For one, this sample application does not create session to store user authentication info. After form authentication, it is impossible for the user to get credential into another request. It is easy with session based web requests. And Spring framework provided a lot of components that can take care this easily. For JWT token based authentication and authorization, these nice amenities will not help. Let's just start with the user login. Usually, this is done with a HTTP Post to the back end. In this case, I will pass user login as a JSON object. Once the authentication process is successful, the response will again being sent back as JSON object.
The back end authentication handling is done in the class called LoginController.java. This is the class definition:
package org.hanbo.boot.app.controllers;
import org.hanbo.boot.app.config.MyJwtTokenUtils;
import org.hanbo.boot.app.models.LoginRequest;
import org.hanbo.boot.app.models.LoginUser;
import org.hanbo.boot.app.models.LoginUserResponse;
import org.hanbo.boot.app.security.MyJwtUserCheckService;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.ModelAndView;
@Controller
public class LoginController
extends ControllerBase
{
private MyJwtUserCheckService _authService;
private MyJwtTokenUtils _jwtTknUtils;
public LoginController(MyJwtUserCheckService authService, MyJwtTokenUtils jwtTknUtils)
{
_authService = authService;
_jwtTknUtils = jwtTknUtils;
}
@RequestMapping(value="/authenticate", method = RequestMethod.POST,
consumes=MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<LoginUserResponse> login(
@RequestBody
LoginRequest loginReq
)
{
System.out.println("User Name: " + loginReq.getUserName());
System.out.println("User Pass: " + loginReq.getUserPass());
if (!StringUtils.isEmpty(loginReq.getUserName()) &&
!StringUtils.isEmpty(loginReq.getUserPass()))
{
LoginUser userFound = _authService.authenticateUser
(loginReq.getUserName(), loginReq.getUserPass());
if (userFound != null)
{
String jwtTknVal = _jwtTknUtils.generateToken(userFound);
if (!StringUtils.isEmpty(jwtTknVal))
{
LoginUserResponse resp = new LoginUserResponse();
resp.setActive(userFound.isActive());
resp.setNickName(userFound.getNickName());
resp.setUserEmail(userFound.getUserEmail());
resp.setUserId(userFound.getUserId());
resp.setUserName(userFound.getUserName());
resp.setAllUserRoles(userFound.getAllUserRoles());
resp.setAuthToken(jwtTknVal);
return ResponseEntity.ok(resp);
}
else
{
return ResponseEntity.status(403).body((LoginUserResponse)null);
}
}
else
{
return ResponseEntity.status(403).body((LoginUserResponse)null);
}
}
else
{
return ResponseEntity.status(403).body((LoginUserResponse)null);
}
}
@RequestMapping(value="/public/authFailed", method = RequestMethod.GET)
public ModelAndView authFailed()
{
ModelAndView retVal = new ModelAndView();
retVal.setViewName("authFailedPage");
return retVal;
}
}
This is pretty simple controller class. There is just one method that handles user request. And there is no "PreAuthorize
" annotation for this method because the user has to be anonymous in order to accept the user log in request. If the log in fails, the response would be status code 403. The logic is as the following:
- The constructor is used for dependency injections; I have to inject the auth service object and JWT utility object.
- The method for handling login request, it will take in the JSON request object, basically user name and user password.
- The user name and password will be matched against a preset of user names and passwords.
- If the user matched successfully, the user info is copies and encoded as a JWT token. The same user info will also be send back with the JWT token so that the client end would have user info in case it is needed.
- In all other scenarios, I will send back status 403, with no response body.
This is all there is for the Java side. In the next section, I will start the discussion on the front end code design.
The AngularJS Front End
Now that we are done with the Java back end design, it is time to check out the front end design. The front end is a single page web application. It has to be a single page application because sharing user credential between multiple physical pages under this type of application is pretty hard. It is not impossible, but it could be hard. As a first step, I would design this as a single page web application. Eventually, it can be expanded to multiple physical page. Anyways, let me start with application entry.
Front End Web App Entry
The front end web application is written with AngularJS and ES6 Scripts (JavaScript with modules). The application entry looks like this:
import { appRouting } from '/assets/app/js/app-routing.js';
import { loginUserService } from '/assets/app/js/loginUserService.js';
import { bookKeepingService } from '/assets/app/js/bookKeepingService.js';
import { IndexController } from '/assets/app/js/IndexController.js';
import { LoginController } from '/assets/app/js/LoginController.js';
let app = angular.module('sampleApp', ["ngResource", "ui.router"]);
app.config(appRouting);
app.factory("loginUserService", [
"$resource",
loginUserService]);
app.factory("bookKeepingService", [
"$resource",
bookKeepingService]);
app.controller("IndexController", [
"$rootScope",
"$scope",
"$state",
"bookKeepingService",
IndexController
]);
app.controller("LoginController", [
"$rootScope",
"$scope",
"$state",
"loginUserService",
LoginController
]);
The above code sets up the application execution. It will register:
- The routing configuration, which is defined as a function called
appRouting()
. It is defined in a file called app-routing.js. - The two services which are defined as factory methods, one is called
loginUserService()
. It is defined in file called loginUserService.js. The other one is called bookKeepingService
. It is defined in the file bookKeepingService.js. - The two controller objects. One is called
LoginController
, defined in the file LoginController.js. The other is called IndexController
. It is defined in file IndexController.js.
This JavaScript file is called app.js. It is just the configuration of all the involved methods, objects, and classes. There are also two additional js files, providing essential helper methods. I will get to these helper methods. First, let's take a look at the LoginController
and its HTML view.
The Login Page
The login page will be the first to show because it is the page that user will hit when they visit without logging in. The page is very simple. All it takes is the user name and password. The two buttons each has a event handler. The "Login
" event handler will perform the login operations. This is the page markup:
<div class="row">
<div class="col-xs-12 col-md-offset-2 col-md-8 col-lg-offset-3 col-lg-6">
<div class="panel panel-default">
<div class="panel-body">
<form>
<div class="form-group">
<label for="userName">User Name</label>
<input class="form-control" type="text" ng-model="vm.userName" />
</div>
<div class="form-group">
<label for="userName">Password</label>
<input class="form-control" type="password" ng-model="vm.userPass" />
</div>
<div class="row" ng-if="vm.isLoggingIn">
<div class="col-xs-12 text-center">
<img src="/assets/images/spinner.gif" width="18" height="18"/>
</div>
</div>
<div class="row">
<div class="col-xs-12 col-sm-6 col-md-offset-2 col-md-4
col-lg-offset3 col-lg-3">
<button class="btn btn-primary form-control"
ng-click="vm.login()" ng-disabled="vm.isLoggingIn">Login</button>
</div>
<div class="col-xs-12 col-sm-6 col-md-4 col-lg-3">
<button class="btn btn-default form-control"
ng-click="vm.clearForm()" ng-disabled="vm.isLoggingIn">Clear</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
If you want to imagine what this page looks like, here is what this page looks like:
What is interesting is the AngularJS controller for this page. Here is the full source code:
import { checkUserLoggedIn, setSessionCurrentUser } from '/assets/app/js/userCheckService.js';
export class LoginController {
constructor($rootScope, $scope, $state, loginUserService) {
this._userName = "";
this._userPass = "";
this._rootScope = $rootScope;
this._scope = $scope;
this._state = $state;
this._isLoggingIn = false;
this._loginUserSvc = loginUserService;
if (checkUserLoggedIn()) {
this._state.go("index");
}
}
get userName() {
return this._userName;
}
set userName(val) {
this._userName = val;
}
get userPass() {
return this._userPass;
}
set userPass(val) {
this._userPass = val;
}
get isLoggingIn() {
return this._isLoggingIn;
}
set isLoggingIn(val) {
this._isLoggingIn = val;
}
login() {
let self = this;
self._isLoggingIn = true;
self._loginUserSvc.authenticateUser(self._userName, self._userPass)
.then(function (result) {
if (result && result.userId && result.userId.trim() !== "") {
console.log(result);
setSessionCurrentUser(angular.copy(result));
setTimeout(function() {
self._isLoggingIn = false;
self._state.go("index");
}, 2000);
} else {
self._isLoggingIn = false;
self._state.go("notAuthorized");
}
}, function(error) {
if (error) {
console.log(error);
}
self._isLoggingIn = false;
self._state.go("authenticationError");
});
}
clearForm() {
this._userName = "";
this._userPass = "";
}
}
And the most interesting parts of this JavaScript class are the constructor and the event handler method login()
. The interesting thing about the constructor is that it not only initializes the object properties, but also checks if the user logged in, then it will re-direct to the index page of the application. This is done through the helper methods checkUserLoggedIn()
, which is imported from userCheckService.js. This check is essential because if the user accidentally goes to login page while he/she already logged in, showing the login page would be really really immature for the application. The check is necessary. And with ui-router
for AngularJS, a redirect would be super easy to do.
import { checkUserLoggedIn, setSessionCurrentUser } from '/assets/app/js/userCheckService.js';
...
if (checkUserLoggedIn()) {
this._state.go("index");
}
...
The login event handler is straight forward, it takes the user name and password from the view, then passes them to the login service and it will call the back end to authenticate.
login() {
let self = this;
self._isLoggingIn = true;
self._loginUserSvc.authenticateUser(self._userName, self._userPass)
.then(function (result) {
if (result && result.userId && result.userId.trim() !== "") {
console.log(result);
setSessionCurrentUser(angular.copy(result));
setTimeout(function() {
self._isLoggingIn = false;
self._state.go("index");
}, 2000);
} else {
self._isLoggingIn = false;
self._state.go("notAuthorized");
}
}, function(error) {
if (error) {
console.log(error);
}
self._isLoggingIn = false;
self._state.go("authenticationError");
});
}
As shown, once the user authentication goes successfully, the code above will set the current user as authenticated user. I will get to that later. Once the authentication is set, it will re-direct to the index page.
The login service is called loginUserService
which is dependency injected into the application. This service is basically a function, and it looks like this:
export function loginUserService($resource) {
let retVal = { };
let apiRes = $resource(null, null, {
authenticateUser: {
url: "/authenticate",
method: "post",
isArray: false
}
});
retVal.authenticateUser = function (userName, userPass) {
return apiRes.authenticateUser({
userName: userName,
userPass: userPass
}).$promise;
};
return retVal;
}
These are all there is for the user login functionality. Next, I will discuss the design of the index page, its controller and the index service. After this, I will let you know the cool part of this tutorial.
The AngularJS Index Page
For this tutorial, I want to demonstrate that once the user is successfully logged in, the user will only be able to do things the user roles associated with this user allows. That is what this index page is for. It has a list which displays all the (fake) published books (with title, year of publication, and ISBN code). This list is available to everyone. On the list, each row should have a Delete button. This action can only be performed by ADMIN level users. If other users try to perform this deletion action, there will be an error output display showing the action is not permissible. At the bottom, there is the form that a user can add more books into the list. This action can only be performed by STAFF or ADMIN level users. When regular users try to perform this action, another error would be displayed.
It sounded complicated, but it is pretty easy to implement. Let me show the full source code for the index page, begin with the HTML markup:
<div class="row">
<div class="col-xs-12 text-right">
<a ng-click="vm.logout()">Logout</a>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="row">
<div class="col-xs-12">
<h3>Books List</h3>
<p><b>Note:</b> this section can be seeing by all levels of users.
But deleting a book can only be done by Admin level users.</p>
<table class="table table-bordered">
<thead>
<tr>
<td>Title</td>
<td>Published in</td>
<td>ISBN</td>
<td>Actions</td>
</tr>
</thead>
<tbody>
<tr ng-repeat="book in vm.foundBooks">
<td>{{book.title}}</td>
<td>{{book.yearPublished}}</td>
<td>{{book.isbnCode}}</td>
<td><button class="btn btn-default btn-sm"
ng-click="vm.clickDeleteBook(book)">Delete</button></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="alert alert-danger" ng-if="vm.errorMsg != null &&
vm.errorMsg.trim() !== ''">
{{vm.errorMsg}}
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<h3>Add a Book</h3>
<p><b>Note:</b> this section can only be usable by Staff level or
Admin level users.</p>
<form>
<div class="row">
<div class="form-group col-xs-12 col-lg-6">
<label>Title</label>
<input class="form-control" type="text" maxlength="128"
ng-model="vm.bookTitle"/>
</div>
</div>
<div class="row">
<div class="form-group col-xs-12 col-sm-4">
<label>Year of Publication</label>
<input class="form-control" type="number"
ng-model="vm.bookPublishedYear"/>
</div>
</div>
<div class="row">
<div class="form-group col-xs-12 col-sm-6 col-md-4">
<label>ISBN</label>
<input class="form-control" type="text"
maxlength="16" ng-model="vm.isbnCode"/>
</div>
</div>
<div class="row">
<div class="col-xs-12 text-right">
<button class="btn btn-success"
ng-click="vm.addBook()">Add Book</button>
<button class="btn btn-default"
ng-click="vm.clearInputs()">Clear</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
It is a bit complex, so I will start from the top. At the very top, there is the link that user can press and log out.
<div class="row">
<div class="col-xs-12 text-right">
<a ng-click="vm.logout()">Logout</a>
</div>
</div>
This is where I load and display the list of books:
<div class="row">
<div class="col-xs-12">
<h3>Books List</h3>
<p><b>Note:</b> this section can be seeing by all levels of users.
But deleting a book can only be done by Admin level users.</p>
<table class="table table-bordered">
<thead>
<tr>
<td>Title</td>
<td>Published in</td>
<td>ISBN</td>
<td>Actions</td>
</tr>
</thead>
<tbody>
<tr ng-repeat="book in vm.foundBooks">
<td>{{book.title}}</td>
<td>{{book.yearPublished}}</td>
<td>{{book.isbnCode}}</td>
<td><button class="btn btn-default btn-sm"
ng-click="vm.clickDeleteBook(book)">Delete</button></td>
</tr>
</tbody>
</table>
</div>
</div>
Finally, this is the form that a user can add the new books into the list:
<div class="row">
<div class="col-xs-12">
<h3>Add a Book</h3>
<p><b>Note:</b> this section can only be usable by
Staff level or Admin level users.</p>
<form>
<div class="row">
<div class="form-group col-xs-12 col-lg-6">
<label>Title</label>
<input class="form-control" type="text"
maxlength="128" ng-model="vm.bookTitle"/>
</div>
</div>
<div class="row">
<div class="form-group col-xs-12 col-sm-4">
<label>Year of Publication</label>
<input class="form-control"
type="number" ng-model="vm.bookPublishedYear"/>
</div>
</div>
<div class="row">
<div class="form-group col-xs-12 col-sm-6 col-md-4">
<label>ISBN</label>
<input class="form-control" type="text"
maxlength="16" ng-model="vm.isbnCode"/>
</div>
</div>
<div class="row">
<div class="col-xs-12 text-right">
<button class="btn btn-success"
ng-click="vm.addBook()">Add Book</button>
<button class="btn btn-default"
ng-click="vm.clearInputs()">Clear</button>
</div>
</div>
</form>
</div>
</div>
I purposely did not add any security constraints to hide any elements so that user can try perform any operations. They can either succeed or getting an error saying the operations are not allowed. This is the warning bar for display the errors:
<div class="row">
<div class="col-xs-12">
<div class="alert alert-danger"
ng-if="vm.errorMsg != null && vm.errorMsg.trim() !== ''">
{{vm.errorMsg}}
</div>
</div>
</div>
The mark up is very easy. Now here comes the hard part, the IndexController
, ng-controller
for the index page. This is the whole source code for this controller:
import { checkUserLoggedIn, removeSessionCurrentUser }
from '/assets/app/js/userCheckService.js';
export class IndexController {
constructor($rootScope, $scope, $state, bookKeepingService) {
this._rootScope = $rootScope;
this._scope = $scope;
this._state = $state;
this._errorMsg = null;
this._bookKeepingSvc = bookKeepingService;
this._foundBooks = null;
this._bookTitle = null;
this._bookPublishedYear = 2001;
this._isbnCode = null;
if (!checkUserLoggedIn()) {
this._state.go("login");
}
this.loadAllBooks();
}
get foundBooks() {
return this._foundBooks;
}
set foundBooks(val) {
this._foundBooks = val;
}
get errorMsg() {
return this._errorMsg;
}
set errorMsg(val) {
this._errorMsg = val;
}
get bookTitle() {
return this._bookTitle;
}
set bookTitle(val) {
this._bookTitle = val;
}
get bookPublishedYear() {
return this._bookPublishedYear;
}
set bookPublishedYear(val) {
this._bookPublishedYear = val;
}
get isbnCode() {
return this._isbnCode;
}
set isbnCode(val) {
this._isbnCode = val;
}
loadAllBooks() {
let self = this;
self._errorMsg = null;
self._bookKeepingSvc.getAllBooks()
.then(function (results) {
if (results) {
if (results.length > 0) {
self._foundBooks = results;
} else {
self._errorMsg = "No books list available.";
}
} else {
self._errorMsg = "No books list available.";
}
}, function(error) {
if (error) {
console.log(error);
if (error.status == 401) {
removeSessionCurrentUser();
self._state.go("login");
} else if (error.status == 403) {
self._errorMsg = "You are not authorized to do this.";
} else {
self._errorMsg = "Unable to load books list. Server error.";
}
}
});
}
clickDeleteBook(bookToDelete) {
if (bookToDelete) {
let self = this;
self._errorMsg = null;
self._bookKeepingSvc.deleteBook(bookToDelete.title)
.then(function (result) {
if (result) {
if (result.successful) {
let newBookList = [];
angular.forEach(self._foundBooks, function(book) {
if (book != null && book.isbnCode !== bookToDelete.isbnCode) {
newBookList.push(book);
}
});
self._foundBooks = newBookList;
} else {
self._errorMsg = "Unable to delete book from list. Unknown error.";
}
} else {
self._errorMsg = "Unable to delete book from list. Unknown error.";
}
}, function (error) {
if (error) {
console.log(error);
if (error.status == 401) {
removeSessionCurrentUser();
self._state.go("login");
} else if (error.status == 403) {
self._errorMsg = "You are not authorized to do this.";
} else {
self._errorMsg = "Unable to delete book from list. Unknown error.";
}
} else {
self._errorMsg = "Unable to delete book from list. Unknown error.";
}
});
}
}
logout() {
let self = this;
removeSessionCurrentUser();
self._state.go("login");
}
addBook() {
this._errorMsg = null;
if (this._bookTitle != null && this._bookTitle.length > 0 &&
this._bookPublishedYear > 2001 &&
this._isbnCode != null && this._isbnCode.length > 0) {
let bookToAdd = {
title: this._bookTitle,
isbnCode: this._isbnCode,
yearPublished: this._bookPublishedYear
};
let self = this;
this._bookKeepingSvc.addNewBook(bookToAdd)
.then(function (result) {
if (result) {
if (result.successful) {
if (self._foundBooks == null) {
self._foundBooks = [];
}
self._foundBooks.push(bookToAdd);
self.clearInputs();
} else {
self._errorMsg = "Unable to delete book from list. Unknown error.";
}
} else {
self._errorMsg = "Unable to delete book from list. Unknown error.";
}$rootScope
}, function (error) {
if (error) {
console.log(error);
if (error.status === 401) {
removeSessionCurrentUser();
self._state.go("login");
} else if (error.status === 403) {
self._errorMsg = "You are not authorized to do this.";
} else {
self._errorMsg = "Unable to add new book from list. Unknown error.";
}
} else {
self._errorMsg = "Unable to add new book from list. Unknown error.";
}
});
}
}
clearInputs() {
this._bookTitle = null;
this._bookPublishedYear = 2001;
this._isbnCode = null;
}
}
The first thing I like to point out is in the constructor, the constructor is used to initialize everything for this class. One thing I had to do is that if the user is not logged in, or user lost the user logged in credential, the initialization has to re-direct user back to the login page. It is done with the following code block:
import { checkUserLoggedIn, removeSessionCurrentUser }
from '/assets/app/js/userCheckService.js';
...
if (!checkUserLoggedIn()) {
this._state.go("login");
}
...
This is doing the opposite of what I have in the login page controller. if the user access passed the check, then it will load all the books from the back end data repository. In order to do so, the user credential must be added to the request so that the user access can be verified by the back end API service. This will be covered in the later part of this subsection. Here is the method in the class that loads the list of books:
loadAllBooks() {
let self = this;
self._errorMsg = null;
self._bookKeepingSvc.getAllBooks()
.then(function (results) {
if (results) {
if (results.length > 0) {
self._foundBooks = results;
} else {
self._errorMsg = "No books list available.";
}
} else {
self._errorMsg = "No books list available.";
}
}, function(error) {
if (error) {
console.log(error);
if (error.status == 401) {
removeSessionCurrentUser();
self._state.go("login");
} else if (error.status == 403) {
self._errorMsg = "You are not authorized to do this.";
} else {
self._errorMsg = "Unable to load books list. Server error.";
}
}
});
}
This method is not very hard to understand. The part that is a bit complex is the error handling part. When the data fetching results in error return, the error would be a standard HTTP error response. And I have to check for Unauthorized and Forbidden error code. When they are detected, I have to make appropriate response. If the status is 401, that means the user credential has expired, and I remove the user credential and re-direct the user to the login page. If the error code is 403, that means the user access is not allowed. For all other errors, I would display the "Unknown Error". All other methods that interacts with the back end API services are all using the same approach.
For deleting the book from the list, once the book is successfully deleted, as in the back end service responded with success. I will remove the book from the list in the angular code. This is done by identify the book that was deleted, and exclude it when I copy the books into a new list. This new list will be re-assigned to the scope variable. And the list on the page will refresh. Adding a new book after it is successful, it will be added to the end of the book list. The invocation to back end are all dummy operations. They are done to demonstrate the user role check when access the back end API. As soon as the user request reaches the API code, dummy response will be returned. So if you refresh the page, the deleted book will be added back, and added books will disappear.
Let me just show you one more thing, the event handler method for logout:
logout() {
let self = this;
removeSessionCurrentUser();
self._state.go("login");
}
All this does is remove the user credential from the application and it will re-direct to the login page.
Now let's take a look at the service for loading books list, adding new book and deleting existing books. Here it is:
import { getUserSecurityToken } from '/assets/app/js/userCheckService.js';
export function bookKeepingService($resource) {
let retVal = { };
retVal.getAllBooks = function () {
let authToken = getUserSecurityToken();
let apiRes = $resource(null, null, {
getAllBooks: {
url: "/secure/api/allBooks",
method: "get",
headers: {
"authorization": "bearer " + authToken
},
isArray: true
}
});
return apiRes.getAllBooks().$promise;
};
retVal.addNewBook = function (bookToAdd) {
let authToken = getUserSecurityToken();
let apiRes = $resource(null, null, {
addNewBook: {
url: "/secure/api/newBook",
method: "post",
headers: {
"authorization": "bearer " + authToken
},
isArray: false
}
});
return apiRes.addNewBook(bookToAdd).$promise;
};
retVal.deleteBook = function (isbnCode) {
let authToken = getUserSecurityToken();
let apiRes = $resource(null, null, {
deleteBook: {
url: "/secure/api/deleteBook",
method: "delete",
headers: {
"authorization": "bearer " + authToken
},
isArray: false,
params: {
title: "@title"
}
}
});
return apiRes.deleteBook({ isbn: isbnCode }).$promise;
};
return retVal;
}
All these service methods have similar logic, let me just take one of these, and go over the logic:
retVal.addNewBook = function (bookToAdd) {
let authToken = getUserSecurityToken();
let apiRes = $resource(null, null, {
addNewBook: {
url: "/secure/api/newBook",
method: "post",
headers: {
"authorization": "bearer " + authToken
},
isArray: false
}
});
return apiRes.addNewBook(bookToAdd).$promise;
};
This is the method that can be used to add a book to the list. It does not really add a book to the back end data store, only to call the back end API and receives a success response (if the request passes the security check). Anyways, the way this method was implemented is that, first I get the security token from somewhere (I will explain where in the next sub section). Then I will use the $resource
from AngularJS to construct a API resource object and attach the security token as a header to it. Then the API resource will be invoked and return the promise back to caller. All these are necessary because the security token has to be retrieved right before calling the back end service API. And if I create the resource object before this, there is no way of passing in the latest security token to the API resource. If you look closer, the other two methods are doing exactly the same thing.
Well. This is it for the IndexController
and bookKeepingService.js. In the next sub section, I will explain how the security token is stored and utilized.
How Security Token is Stored and Utilized
Where to store the security token, the user info along with the security token is a big concern for me. At first, I thought it would be OK to store them in the $rootScope
. Then I immediately rejected this idea, because every time the page is refreshed, this info will be lost. The next place would be the sessionStorage
which is a browser based storage. There is a unique characteristic about this storage, as long as the tab of the page is not closed, the data in the sessionStorage
associated with the page will not lose. This is not 100% guaranteed, because you can still use the developer tool to delete it. Still, it is a good place for storing the user credential. I do have to point out that this solution (of storing user credential in the sessionStorage
) is still incomplete. In case the user credential did get lost, there should still be a way to recover the user credential, so that the login page would not be displayed when in this case. I didn't implement such a solution. That would add a lot more details into this already long tutorial.
Here is the full source code for the userCheckService.js file:
export function setSessionCurrentUser(userToAdd) {
if (userToAdd != null &&
userToAdd.userId &&
userToAdd.userId.trim() !== "" &&
userToAdd.authToken &&
userToAdd.authToken.trim() !== "") {
if (sessionStorage.currentUser) {
sessionStorage.currentUser = null;
}
sessionStorage.currentUser = JSON.stringify(userToAdd);
}
}
export function removeSessionCurrentUser() {
sessionStorage.currentUser = null;
sessionStorage.removeItem("currentUser");
}
export function checkUserLoggedIn() {
let retVal = false;
if (sessionStorage.currentUser &&
sessionStorage.currentUser.length > 0) {
let currUser = JSON.parse(sessionStorage.currentUser);
if (currUser &&
currUser.userId &&
currUser.userId.trim() !== "") {
retVal = currUser.authToken && currUser.authToken.trim() !== "";
}
}
return retVal;
}
export function getLoggedinUser() {
let retVal = null;
if (sessionStorage.currentUser &&
sessionStorage.currentUser.length > 0) {
let currUser = JSON.parse(sessionStorage.currentUser);
if (currUser &&
currUser.userId &&
currUser.userId.trim() !== "") {
retVal = currUser;
}
}
return retVal;
}
export function getUserSecurityToken() {
let retVal = "";
if (sessionStorage.currentUser &&
sessionStorage.currentUser.length > 0) {
let currUser = JSON.parse(sessionStorage.currentUser);
if (currUser &&
currUser.userId &&
currUser.userId.trim() !== "") {
if (currUser.authToken && currUser.authToken.trim() !== "") {
retVal = currUser.authToken;
}
}
}
return retVal;
}
It is really not hard to understand this file. The first function:
export function setSessionCurrentUser(userToAdd) {
if (userToAdd != null &&
userToAdd.userId &&
userToAdd.userId.trim() !== "" &&
userToAdd.authToken &&
userToAdd.authToken.trim() !== "") {
if (sessionStorage.currentUser) {
sessionStorage.currentUser = null;
}
sessionStorage.currentUser = JSON.stringify(userToAdd);
}
}
This function is used to set or reset the current user in the sessionStorage
. This function is especially useful for setting a new user credential when existing is expired. It checks to see if there is an existing user credential. If there is, set current user to null
. Then we set the new user credential. The way, it is done is to stringify the user credential as JSON formatted string and assign it to the sessionStorage
current user key.
Here is the next function:
export function removeSessionCurrentUser() {
sessionStorage.currentUser = null;
sessionStorage.removeItem("currentUser");
}
This function can be used to remove the user credential from the sessionStorage
. It is used by the logout functionality in the IndexController
. It is also used when the user credential expired (current time is over the expiration time), to remove the user credential from the sessionStorage
. If I have to implement the more complicated scenario of keep user always logged in, then I will have to add more code here to remove logged in user from the back end server as well. This is just an FYI.
The next three functions are very similar in terms of structure (hence I had the comments on two of them to refactor them further), here they are:
export function checkUserLoggedIn() {
let retVal = false;
if (sessionStorage.currentUser &&
sessionStorage.currentUser.length > 0) {
let currUser = JSON.parse(sessionStorage.currentUser);
if (currUser &&
currUser.userId &&
currUser.userId.trim() !== "") {
retVal = currUser.authToken && currUser.authToken.trim() !== "";
}
}
return retVal;
}
export function getLoggedinUser() {
let retVal = null;
if (sessionStorage.currentUser &&
sessionStorage.currentUser.length > 0) {
let currUser = JSON.parse(sessionStorage.currentUser);
if (currUser &&
currUser.userId &&
currUser.userId.trim() !== "") {
retVal = currUser;
}
}
return retVal;
}
export function getUserSecurityToken() {
let retVal = "";
if (sessionStorage.currentUser &&
sessionStorage.currentUser.length > 0) {
let currUser = JSON.parse(sessionStorage.currentUser);
if (currUser &&
currUser.userId &&
currUser.userId.trim() !== "") {
if (currUser.authToken && currUser.authToken.trim() !== "") {
retVal = currUser.authToken;
}
}
}
return retVal;
}
These three first will check and see if the current user exists in the sessionStorage
. If it exists, and it is string
with a valid length, then I will convert the string
to an object. From there, I can check the user
and userId
, making sure the user credential is valid. Finally, I can:
- return
true
/false
regarding whether user is logged in or not. - return the current user info.
- return the security token in the current user object.
This is all there is about this JS file. They are mainly the helper functions to manage the basic CRUD operations for user credential for the client end. This JS file shields the other part of the client end from dealing with sessionStorage
. Now, that all the source codes are discussed, it is time to see how this application can be tested.
How to Test the Sample Application
At this point, let's discuss the steps to test this sample application. First thing first, please go to the sub folders in the resources/static/assets and rename any file with extension *.sj to *.js.
Next, go to the base directory of this project, where you can find pom.xml. Run the following command:
mvn clean install
Wait for the build to complete and successful. Then run the following command to start up the Spring Boot application:
java -jar target/thymeleaf-security-sample3-1.0.0.jar
Once the application starts up, navigate to the following url, and you will see the login page:
http://localhost:8080/public/index#!/login
I have prepared four different users for testing. Three of them are active users, the other is inactive. One user is the ADMIN user, who has all the access. One user is the STAFF level user. Two other users are USER level users. One is active and one is not. These users have the following credentials:
- User name: testadmin, Password: 111test111. This user is active.
- User name: teststaff, Password: 222test222. This user is active.
- User name: testuser1, Password: 333test333. This user is active.
- User name: testuser2, Password: 444test444. This user is inactive. If you use this to login, you will be returned back to the login page over and over.
- Any invalid user login will also return you back to the login page everytime.
Use the top 3 user credentials, you should be able to login and see the index page. Use these user credentials to try and perform the operations and you will see the difference in response. Because the back end service only checks user credential, and not doing any real work, so when you refresh the page, any added books or deleted book will disappear/re-appear. But the whole application will demo the concepts explained in this tutorial clearly.
Here are some screenshots of the sample application:
When you go to the public index page, you will see the login screen, like this:
After you logged in successfully using one of the active users, you will see the index page, like this:
You can login as "testadmin
" or "teststaff
", and add a book. After click the button "Add Book", you will see one more book in the list:
If you are logged in as "testadmin", you can also delete a book, when book is successfully deleted, you will see the list is back to 3 books instead of 4:
If you logged in as "testuser1", you can attempt both adding a book or deleting a book, both operations will fail with the same error, like this:
Summary
As stated at the beginning of this tutorial. For 2021, this would be the last tutorial for the year. Very soon, I will be creating new tutorial for 2022. This is also the longest one for the whole year. I hope this would be the best one from me for this year. Certainly, this tutorial is very unique.
I also repeated several times that I wanted to do a tutorial on Spring Security using security token. I thought I would do a quick tutorial using 3rd party OpenID provider with Spring Security. After some research, I found that this does not align with what I do. And sometime later with some more research, I was able to come up with this tutorial. Even though it does not look like what I originally intended, it still completes what I set out to do early this year.
In this tutorial, I have discussed how to create (encode/decode) JWT security token; how to use login page to authenticate user and pass the user info and security token back to the front end; how to use security filter to intercept the user security token in the HTTP request header and transform the JWT token into Spring Security security context for the request to be processed by the back end. I also discussed how to store the user credential in the browser sessionStorage
so that it can be retrieved even when user refreshed the page. Even though this way is not the best approach, and more code logic is needed to ensure logged in user credential will not be lost under almost all circumstances. Hopefully, we can discuss this in another tutorial.
I hope you have enjoyed this tutorial. It is as complete as I can do, And with all the details, I hope it will be of some use for your endeavor. Good luck and have a great holiday season.
History
- 19th December, 2021 - Initial draft