In my last article, we developed a spring security 5 OAuth application with google sign in and customized most of the default behavior. In this article, we will take a deeper look into customizing OAuth2 login. We have already added social login support to our app and now we will extend it to have an option for custom user registration or signup using email and password. After successful registration, we should be able to support JWT token-based authentication in the app.
Primarily, we will be adding below support in our app.
- Add custom login page in oauth2Login() element.
- User can choose login options with either custom email and password or social login with Google OAuth.
- After a successful login, JWT token should be generated and token-based authentication is enabled and user is redirected to /home.
Spring Security OAuth Configuration
To get started with the app, first of all let us review the OAuth configuration that we did in our last article. Below was the final security config where we have customized the oauth2Login() element to have custom redirection point, user info endpoint, user service, authorization endpoint etc. You can visit this article for details.
SecurityConfig.java@Configuration @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private OidcUserService oidcUserService; @Autowired private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler; @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests().anyRequest().authenticated() .and() .oauth2Login() .redirectionEndpoint() .baseUri("/oauth2/callback/*") .and() .userInfoEndpoint() .oidcUserService(oidcUserService) .and() .authorizationEndpoint() .baseUri("/oauth2/authorize") .authorizationRequestRepository(customAuthorizationRequestRepository()) .and() .successHandler(customAuthenticationSuccessHandler); } @Bean public AuthorizationRequestRepositorycustomAuthorizationRequestRepository() { return new HttpSessionOAuth2AuthorizationRequestRepository(); } }
Customizing Login Endpoint
By default, the OAuth 2.0 Login Page is auto-generated by the DefaultLoginPageGeneratingFilter and it is available at /login. To override the default login page, configure oauth2Login().loginPage() with your custom url. Here, we hav configured it as /auth/custom-login. So, our configure() method becomes
@Override protected void configure(HttpSecurity http) throws Exception { http .oauth2Login() .loginPage("/auth/custom-login") ... ... .and() .successHandler(customAuthenticationSuccessHandler); }
Creating Custom Login Page
With the above configuration, whenever any authentication is required, user will be redirected to /auth/custom-login. Now let us create a login page as login.html. In the above security config, we have configured authorizationEndpoint as /oauth2/authorize and hence on the click of Google icon, user will be redirected to /oauth2/authorize/google
login.html<body> <div class="container"> <h1>SignUp !</h1> <div class="col-md-6"> <div class="row"> <p><a href="/oauth2/authorize/google"><img src="http://pngimg.com/uploads/google/google_PNG19635.png" style="height:60px"></a></p> </div> <div class="row"> <h4>OR</h4> <div class="row"> <form action="/auth/signup/" method="post" name="signup"> <div class="form-group"> <label for="name">Name</label> <input type="string" class="form-control" id="name" name="name"> </div> <div class="form-group"> <label for="email">Email address:</label> <input type="email" class="form-control" id="email" name="email"> </div> <div class="form-group"> <label for="pwd">Password:</label> <input type="password" class="form-control" id="pwd" name="password"> </div> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <button type="submit" class="btn btn-primary">Sign Up</button> </form> </div> </div> </div> </div> </body>
Spring Security Config for Registration
To configure our registration process we will be using exisitng implementation - Spring Boot Jwt. Below is our final security config to accomodate the custom login.
Here, we have injected UserDetailsService required by auth manager to fetch the users from the DB. We have configured our Bcrypt password encoder and added custom filter to intercept before UsernamePasswordAuthenticationFilter and validate the token and set the security context.
We have allowed all the request for matcher /auth and configured login page to be available at "/auth/custom-login". We have registred our custom authentication entry point that will redirect any unauthenticated user to login page.
SecurityConfig .java@Configuration @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter implements ApplicationContextAware { @Autowired private OidcUserService oidcUserService; @Autowired private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler; @Autowired private UserDetailsService userDetailsService; @Autowired private JwtAuthenticationEntryPoint unauthorizedHandler; @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Autowired @Override public void configure(AuthenticationManagerBuilder auth) throws Exception { auth .userDetailsService(userDetailsService) .passwordEncoder(encoder()); } @Bean public JwtAuthenticationFilter authenticationTokenFilterBean() { return new JwtAuthenticationFilter(); } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests(). antMatchers("/auth/**", "/webjars/**").permitAll() .anyRequest().authenticated() .and() .exceptionHandling().authenticationEntryPoint(unauthorizedHandler) .and() .oauth2Login() .loginPage("/auth/custom-login") .redirectionEndpoint() .baseUri("/oauth2/callback/*") .and() .userInfoEndpoint() .oidcUserService(oidcUserService) .and() .authorizationEndpoint() .baseUri("/oauth2/authorize") .authorizationRequestRepository(customAuthorizationRequestRepository()) .and() .successHandler(customAuthenticationSuccessHandler); http .addFilterBefore(authenticationTokenFilterBean(), UsernamePasswordAuthenticationFilter.class); } @Bean public AuthorizationRequestRepositorycustomAuthorizationRequestRepository() { return new HttpSessionOAuth2AuthorizationRequestRepository(); } @Bean public BCryptPasswordEncoder encoder() { return new BCryptPasswordEncoder(); } }
With above configuration, we have achieved below points
1. If an user tries to access any secured page without login e.g. localhost:8080, then he will be redirected to localhost:8080/auth/custom-login.
2. On login page, we have 2 different options to login. Either user can signup with email address and password or else can choose to sign with Google.
3. In both the cases, after a successfull authentication the user will be redirected to /home.
JwtAuthenticationEntryPoint.java@Component public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.sendRedirect("/auth/custom-login"); } }
JWT Authentication Filter Implementation
Below is the filter implementation that intercepts all the request and checks if the token is present in the URL. If the token is present then it will validate the token and set the security context will be set and the request will be chained to next filter in the filter chain. This is the filter which will be executed before UsernamePasswordAuthenticationFilter.
JwtAuthenticationFilter.javapublic class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private UserDetailsService userDetailsService; @Autowired private JwtTokenUtil jwtTokenUtil; @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException { String token = req.getParameter(TOKEN_PARAM); String username = null; if (token != null) { try { username = jwtTokenUtil.getUsernameFromToken(token); } catch (IllegalArgumentException e) { logger.error("an error occured during getting username from token", e); } catch (ExpiredJwtException e) { logger.warn("the token is expired and not valid anymore", e); } catch(SignatureException e){ logger.error("Authentication Failed. Username or Password not valid."); } } else { logger.warn("couldn't find bearer string, will ignore the header"); } if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); if (jwtTokenUtil.validateToken(token, userDetails)) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, Arrays.asList(new SimpleGrantedAuthority("ROLE_ADMIN"))); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(req)); logger.info("authenticated user " + username + ", setting security context"); SecurityContextHolder.getContext().setAuthentication(authentication); } } chain.doFilter(req, res); } }
Spring Controller for Custom Login
Below REST endpoints are responsible for generating our custom login page and accepts sign up request.
AuthController.java@Controller @RequestMapping("/auth") public class AuthController { @Autowired private UserService userService; @GetMapping("/custom-login") public String loadLoginPage(){ return "login"; } @PostMapping("/signup") public String login(@ModelAttribute("signup") User user){ String token = userService.signUp(user); return UriComponentsBuilder.fromUriString(homeUrl) .queryParam("auth_token", token) .build().toUriString(); } }UserController.java
This endpoint will be executed post login and loads the home page.
@Controller public class UserController { @GetMapping("/home") public String home(){ return "home"; } }
Service Implementation For Custom User Authentication
Below implementation will be used by spring security to authenticate user.
UserDetailServiceImpl.java@Service(value = "userDetailsService") public class UserDetailServiceImpl implements UserDetailsService { @Autowired private UserRepository userRepository; public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { User user = userRepository.findByEmail(email); if(user == null){ throw new UsernameNotFoundException("Invalid username or password."); } return new org.springframework.security.core.userdetails.User(user.getEmail(), user.getPassword(), getAuthority()); } private ListgetAuthority() { return Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")); } }
Below implementation is for creating entries of new user in the DB.
UserServiceImpl.java@Service public class UserServiceImpl implements UserService { @Autowired private JwtTokenUtil jwtTokenUtil; @Autowired private UserRepository userRepository; @Autowired private BCryptPasswordEncoder bcryptEncoder; @Override public String signUp(User user) { User dbUser = userRepository.findByEmail(user.getEmail()); if (dbUser != null) { throw new RuntimeException("User already exist."); } user.setPassword(bcryptEncoder.encode(user.getPassword())); userRepository.save(user); return jwtTokenUtil.generateToken(user); } }
Below is the util class that we are using for generating and validating our JWT token.
JwtTokenUtil.java@Component public class JwtTokenUtil implements Serializable { public String getUsernameFromToken(String token) { return getClaimFromToken(token, Claims::getSubject); } public Date getExpirationDateFromToken(String token) { return getClaimFromToken(token, Claims::getExpiration); } publicT getClaimFromToken(String token, Function claimsResolver) { final Claims claims = getAllClaimsFromToken(token); return claimsResolver.apply(claims); } private Claims getAllClaimsFromToken(String token) { return Jwts.parser() .setSigningKey(SIGNING_KEY) .parseClaimsJws(token) .getBody(); } private Boolean isTokenExpired(String token) { final Date expiration = getExpirationDateFromToken(token); return expiration.before(new Date()); } public String generateToken(User user) { return doGenerateToken(user.getUsername()); } private String doGenerateToken(String subject) { Claims claims = Jwts.claims().setSubject(subject); claims.put("scopes", Arrays.asList(new SimpleGrantedAuthority("ROLE_ADMIN"))); return Jwts.builder() .setClaims(claims) .setIssuer("http://devglan.com") .setIssuedAt(new Date(System.currentTimeMillis())) .setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_VALIDITY_SECONDS*1000)) .signWith(SignatureAlgorithm.HS256, SIGNING_KEY) .compact(); } public Boolean validateToken(String token, UserDetails userDetails) { final String username = getUsernameFromToken(token); return ( username.equals(userDetails.getUsername()) && !isTokenExpired(token)); } }
Running the Final Application
Import the project as a Maven project and make sure your DB configuration matches with those defined in application.properties.
Run SpringBootGoogleOauthApplication.java as a java application.
Now open your browser and hit localhost:8080/auth/custom-login to see the login page.
Conclusion
In this tutorial, we looked into providing support for custom user registration in an existing spring boot OAuth2 application. We used spring security 5 and JWT for our custom token generation process.