How to Secure Spring REST API with OAuth2 JWT Authentication

In the previous article, we implemented JWT authentication using custom spring security. However, it is not recommended to customize spring security, especially in enterprise-level applications. Because one defect in custom security can jeopardize an entire organization’s data.

Therefore, instead of customizing the spring security, we can use OAuth 2.0 Resource server for token-based authentication. In addition, we will also implement user registration in this article.

Introduction

Firstly, presenting an access token to a server for authentication is part of the Oauth2 standard.  Hence, we are going to implement a small part of the Oauth2 spec and not necessarily implement the entire oauth2 specification.

Secondly, Spring Security has in-built support for the OAuth2 Resource server and BearerTokenAuthenticationFilter to parse the request for bearer tokens and make an authentication attempt. Therefore, we don’t need to implement our own custom filters as we did in our previous articles.

Thirdly, Jwt and Opaque Token are the only supported formats for bearer tokens in Spring Security. So, we are gonna configure our OAuth2 resource server with Jwt-encoded bearer token support.

OAuth 2.0 Terminologies

Here are some of the OAuth2 terminologies that are relevant to this article to keep it precise and simple.

OAuth 2.0

OAuth 2.0 is the industry-standard protocol for authorization and it uses Access Tokens for that.

Access Token

Firstly, an OAuth Access Token is a string that the OAuth client uses to make requests to the resource server. Secondly, access tokens do not have to be in any particular format, and in practice, various OAuth servers have chosen many different formats for their access tokens. Most importantly, access tokens may be either “bearer tokens” or “sender-constrained” tokens.

Bearer Token

Bearer Tokens are the predominant type of access token used with OAuth 2.0. A Bearer Token is an opaque string, not intended to have any meaning to clients using it. Some servers will issue tokens that are a short string of hexadecimal characters, while others may use structured tokens such as JSON Web Tokens.

JSON Web Token (JWT)

JSON Web Token (JWT, RFC 7519) is a way to encode claims in a JSON document that is then signed. JWTs can be used as OAuth 2.0 Bearer Tokens to encode all relevant parts of an access token into the access token itself instead of having to store them in a database.

Resource Server

A server that protects the user’s resources and receives access requests from the Client. It accepts and validates an Access Token from the Client and returns the appropriate resources to it.

What you’ll do?

We will be implementing JWT authentication with Spring Security 6:

  • Generate private & public key pairs for signing/verifying the token.
  • Configure Spring Security to enable OAuth 2.0 Resource Server with JWT bearer token support
  • Define JwtEncoder & JwtDecoder beans for token generation and verification
  • Expose a POST API with mapping /signin. On passing the username and password in the request body, it will generate a JSON Web Token (JWT).
  • Expose a POST API with mapping /signup. On passing the user details in the request body, the new user will be registered.

What you’ll need?

  • IntelliJ or any other IDE of your choice
  • JDK 17
  • MySQL Server 8
  • OpenSSL for generating private & public key pairs (Optional)

Generating Keys

For generating keys, you can use OpenSSL or an online tool like cryptool.

Using OpenSSL

Step 1

Generate a private RSA key and output it to a file with the following command

openssl genrsa -out privkey.pem 4096

Step 2

Generate a public RSA key with the private key as input and output it to a file with the following command and keep these files aside as we need them later.

openssl rsa -pubout -in /privkey.pem -outform PEM -out pubkey.pem

Using Cryptool

Step 1

Generate a private RSA key and output it to a file as shown below:

RSA Private Key Generation

Step 2

Generate a public RSA key with the private key as input and output it to a file as shown below. Keep these files aside as we need them later.

RSA Public Key generation

Developing REST APIs

Now, let’s develop Auth APIs for user login and registration. We will also develop some other movie-related APIs for testing the authentication. However, we will not cover them here as it is out of the scope of this article.

Project Dependencies

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>3.0.2</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<artifactId>movie-service</artifactId>
	<version>1.0.0</version>
	<name>movie-service</name>
	<description>Movies Service</description>

	<properties>
		<java.version>17</java.version>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-validation</artifactId>
		</dependency>
		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-configuration-processor</artifactId>
			<optional>true</optional>
		</dependency>
	</dependencies>
	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>
</project>

Creating JPA Entities

BaseEntity.java

This is our Base JPA Entity which will be extended by all the other Entities.

package com.javachinna.model;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

import java.io.Serial;
import java.io.Serializable;

/**
 * Base class for all JPA entities
 *
 * @author Chinna
 */
@Getter
@Setter
@MappedSuperclass
public abstract class BaseEntity implements Serializable {

    /**
     *
     */
    @Serial
    private static final long serialVersionUID = -7363399724812884337L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", updatable = false, nullable = false)
    protected Long id;

    @Override
    public boolean equals(Object o) {
        if (this == o)
            return true;

        if (!this.getClass().isInstance(o))
            return false;

        BaseEntity other = (BaseEntity) o;

        return id != null && id.equals(other.getId());
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();
    }
}

User.java

User Entity maps to the User table.

package com.javachinna.model;

import com.fasterxml.jackson.annotation.JsonManagedReference;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.NaturalId;

import java.io.Serial;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;

@Entity
@Table(name = "user")
@Getter
@Setter
@NoArgsConstructor
public class User extends BaseEntity{

	/**
	 * 
	 */
	@Serial
	private static final long serialVersionUID = -467324267912994552L;

	@NaturalId(mutable = true)
	@Column(name = "email", unique = true, nullable = false)
	private String email;

	@NotNull
	private String password;

	@Column(name = "DISPLAY_NAME")
	private String displayName;

	@Column(name = "enabled", columnDefinition = "BIT", length = 1)
	private boolean enabled;

	@Column(name = "USING_2FA")
	private boolean using2FA;

	private String secret;

	@JsonManagedReference
	@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
	private Set<UserRole> roles = new HashSet<>();

	public User(String email, String password) {
		this.email = email;
		this.password = password;
	}

	public void addRole(Role role) {
		UserRole userRole = new UserRole(this, role);
		roles.add(userRole);
	}

	public void removeRole(Role role) {
		for (Iterator<UserRole> iterator = roles.iterator(); iterator.hasNext();) {
			UserRole userRole = iterator.next();

			if (userRole.getUser().equals(this) && userRole.getRole().equals(role)) {
				iterator.remove();
				userRole.setUser(null);
				userRole.setRole(null);
			}
		}
	}
}

Role.java

Role Entity maps to the role table.

package com.javachinna.model;

import jakarta.persistence.Entity;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.io.Serial;

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Role extends BaseEntity {
	@Serial
	private static final long serialVersionUID = 1L;
	public static final String ROLE_USER = "ROLE_USER";
	public static final String ROLE_ADMIN = "ROLE_ADMIN";

	private String name;

	public Role(String name) {
		this.name = name;
	}

	@Override
	public String toString() {
        return "Role [name=" + name + "]" + "[id=" + id + "]";
	}
}

UserRolePK.java

The embeddable type is used to define the composite primary key of the user_role intermediary table.

package com.javachinna.model.pk;

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.io.Serial;
import java.io.Serializable;
import java.util.Objects;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Embeddable
public class UserRolePK implements Serializable {

	@Serial
	private static final long serialVersionUID = 1L;

	@Column(name = "USER_ID")
	private Long userId;

	@Column(name = "ROLE_ID")
	private Long roleId;

	/*
	 * (non-Javadoc)
	 * 
	 * @see java.lang.Object#hashCode()
	 */
	@Override
	public int hashCode() {
		return Objects.hash(roleId, userId);
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see java.lang.Object#equals(java.lang.Object)
	 */
	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		UserRolePK other = (UserRolePK) obj;
		return Objects.equals(roleId, other.roleId) && Objects.equals(userId, other.userId);
	}

}

UserRole.java

UserRole entity defines the many-to-many relationship between the User and Role tables and maps to the user_role intermediary table created by Hibernate.

package com.javachinna.model;

import com.javachinna.model.pk.UserRolePK;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.io.Serial;
import java.io.Serializable;
import java.util.Objects;

@Entity
@Getter
@Setter
@NoArgsConstructor
public class UserRole implements Serializable {

	@Serial
	private static final long serialVersionUID = 1L;

	/**
	 * @param user The user entity
	 * @param role The role entity
	 */
	public UserRole(User user, Role role) {
		this.id = new UserRolePK(user.getId(), role.getId());
		this.role = role;
		this.user = user;
	}

	@EmbeddedId
	private UserRolePK id;

	@ManyToOne(fetch = FetchType.LAZY)
	@MapsId("userId")
	private User user;

	@ManyToOne(fetch = FetchType.LAZY)
	@MapsId("roleId")
	private Role role;

	protected boolean deleted;

	/*
	 * (non-Javadoc)
	 * 
	 * @see java.lang.Object#hashCode()
	 */
	@Override
	public int hashCode() {
		return Objects.hash(role, user);
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see java.lang.Object#equals(java.lang.Object)
	 */
	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		UserRole other = (UserRole) obj;
		return Objects.equals(role, other.role) && Objects.equals(user, other.user);
	}

}

Creating Repositories

UserRepository.java

package com.javachinna.repo;

import com.javachinna.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

/**
 * JPA Repository for user entity
 *
 * @author Chinna
 */
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

	User findByEmailIgnoreCase(String email);
	boolean existsByEmailIgnoreCase(String email);
}

RoleRepository.java

package com.javachinna.repo;

import com.javachinna.model.Role;
import org.springframework.data.jpa.repository.JpaRepository;


public interface RoleRepository extends JpaRepository<Role, Long> {
    Role findByName(String name);
}

Creating a Service Layer to Access the Repositories

UserService.java

package com.javachinna.service;

import com.javachinna.dto.SignUpRequest;
import com.javachinna.exception.UserAlreadyExistAuthenticationException;
import com.javachinna.model.User;

/**
 * Service interface for user operations
 *
 * @author Chinna
 * @since 06/11/22
 */
public interface UserService {

    User findUserByEmail(String email);

    User registerNewUser(SignUpRequest signUpRequest) throws UserAlreadyExistAuthenticationException;
}

UserServiceImpl.java

package com.javachinna.service.impl;

import com.javachinna.dto.SignUpRequest;
import com.javachinna.exception.UserAlreadyExistAuthenticationException;
import com.javachinna.model.Role;
import com.javachinna.model.User;
import com.javachinna.repo.RoleRepository;
import com.javachinna.repo.UserRepository;
import com.javachinna.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * @author Chinna
 */
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

	private final UserRepository userRepository;
	private final RoleRepository roleRepository;
	private final PasswordEncoder passwordEncoder;

	@Override
	public User findUserByEmail(final String email) {
		return userRepository.findByEmailIgnoreCase(email);
	}

	@Override
	@Transactional(value = "transactionManager")
	public User registerNewUser(final SignUpRequest signUpRequest) throws UserAlreadyExistAuthenticationException {
		if (userRepository.existsByEmailIgnoreCase(signUpRequest.getEmail())) {
			throw new UserAlreadyExistAuthenticationException("User with email id " + signUpRequest.getEmail() + " already exist");
		}
		User user = buildUser(signUpRequest);
		user = userRepository.save(user);
		userRepository.flush();
		return user;
	}

	private User buildUser(final SignUpRequest signUpRequest) {
		User user = new User();
		user.setDisplayName(signUpRequest.getDisplayName());
		user.setEmail(signUpRequest.getEmail());
		user.setPassword(passwordEncoder.encode(signUpRequest.getPassword()));
		user.addRole(roleRepository.findByName(Role.ROLE_USER));
		user.setEnabled(true);
		return user;
	}
}

Implementing Spring’s UserDetailsService

UserDetailsService is a core interface that loads user-specific data. It is used throughout the framework as a user DAO and will be used by the DaoAuthenticationProvider during authentication.

LocalUserDetailService.java

package com.javachinna.service;

import com.javachinna.dto.LocalUser;
import com.javachinna.model.User;
import com.javachinna.util.CommonUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * Implementation for {@link UserDetailsService}
 *
 * @author Chinna
 */
@Service
@RequiredArgsConstructor
public class LocalUserDetailService implements UserDetailsService {

    private final UserService userService;

    @Override
    @Transactional
    public LocalUser loadUserByUsername(final String email) throws UsernameNotFoundException {
        User user = userService.findUserByEmail(email);
        if (user == null) {
            throw new UsernameNotFoundException("User " + email + " was not found in the database");
        }
        return createLocalUser(user);
    }

    /**
     * @param user The user entity
     * @return LocalUser The spring user object
     */
    private LocalUser createLocalUser(User user) {
        return new LocalUser(user.getEmail(), user.getPassword(), user.isEnabled(), true, true, true, CommonUtils.buildSimpleGrantedAuthorities(user.getRoles()));
    }
}

LocalUser.java

Models core user information retrieved by a UserDetailsService.

package com.javachinna.dto;

import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;

import java.io.Serial;
import java.util.Collection;

/**
 * LocalUser class extends User which models core user information retrieved by a UserDetailsService
 *
 * @author Chinna
 */
@Getter
public class LocalUser extends org.springframework.security.core.userdetails.User {

    /**
     *
     */
    @Serial
    private static final long serialVersionUID = -2845160792248762779L;

    public LocalUser(final String userID, final String password, final boolean enabled, final boolean accountNonExpired, final boolean credentialsNonExpired,
                     final boolean accountNonLocked, final Collection<? extends GrantedAuthority> authorities) {
        super(userID, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
    }
}

Creating Validators

PasswordMatches.java

@PasswordMatches custom annotation will be used to check if the password and confirm password field values are matching.

package com.javachinna.validator;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Target({ TYPE, ANNOTATION_TYPE })
@Retention(RUNTIME)
@Constraint(validatedBy = PasswordMatchesValidator.class)
@Documented
public @interface PasswordMatches {

    String message() default "Passwords don't match";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

}

PasswordMatchesValidator.java

package com.javachinna.validator;

import com.javachinna.dto.SignUpRequest;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class PasswordMatchesValidator implements ConstraintValidator<PasswordMatches, SignUpRequest> {

	@Override
	public boolean isValid(final SignUpRequest user, final ConstraintValidatorContext context) {
		return user.getPassword().equals(user.getMatchingPassword());
	}

}

Creating DTOs

SignUpRequest.java

DTO is used to hold the user details for new user registration requests.

package com.javachinna.dto;

import com.javachinna.validator.PasswordMatches;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Data;

/**
 * @author Chinna
 * @since 26/2/22
 */
@Data
@PasswordMatches
public class SignUpRequest {

	@NotEmpty
	private String displayName;

	@NotEmpty
	private String email;

	@Size(min = 6, message = "Minimum 6 chars required")
	private String password;

	@NotEmpty
	private String matchingPassword;

	private boolean using2FA;

	public SignUpRequest(String displayName, String email, String password, String matchingPassword) {
		this.displayName = displayName;
		this.email = email;
		this.password = password;
		this.matchingPassword = matchingPassword;
	}
}

ApiResponse.java

Generic record class for mapping API responses.

package com.javachinna.dto;

/**
 * Common API Response class
 *
 * @author Chinna
 */
public record ApiResponse(Boolean success, String message) {
}

LoginRequest.java

DTO is used to hold the user credentials for new user login requests.

package com.javachinna.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class LoginRequest {
	@NotBlank
	private String email;

	@NotBlank
	private String password;
}

JwtAuthenticationResponse.java

Record class for returning the access token in the response.

package com.javachinna.dto;

public record JwtAuthenticationResponse(String accessToken) {
}

Creating REST Exception Handler

UserAlreadyExistAuthenticationException.java

A custom exception will be thrown when the user already exists with the same email id in the database.

package com.javachinna.exception;

import org.springframework.security.core.AuthenticationException;

import java.io.Serial;

/**
 * 
 * @author Chinna
 *
 */
public class UserAlreadyExistAuthenticationException extends AuthenticationException {

    /**
	 * 
	 */
	@Serial
	private static final long serialVersionUID = 5570981880007077317L;

	public UserAlreadyExistAuthenticationException(final String msg) {
        super(msg);
    }

}

RestResponseEntityExceptionHandler.java

package com.javachinna.exception.handler;

import com.javachinna.dto.ApiResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import java.util.stream.Collectors;

/**
 * This class provides centralized exception handling across all @RequestMapping methods through @ExceptionHandler methods.
 */
@RestControllerAdvice
public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {

    public RestResponseEntityExceptionHandler() {
        super();
    }

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(final MethodArgumentNotValidException ex, final HttpHeaders headers, final HttpStatusCode status,
                                                                  final WebRequest request) {
        logger.error("400 Status Code", ex);
        final BindingResult result = ex.getBindingResult();

        String error = result.getAllErrors().stream().map(e -> {
            if (e instanceof FieldError) {
                return ((FieldError) e).getField() + " : " + e.getDefaultMessage();
            } else {
                return e.getObjectName() + " : " + e.getDefaultMessage();
            }
        }).collect(Collectors.joining(", "));
        return handleExceptionInternal(ex, new ApiResponse(false, error), new HttpHeaders(), HttpStatus.BAD_REQUEST, request);
    }
}

Creating Utils

CommonUtils.java

package com.javachinna.util;

import com.javachinna.model.UserRole;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;

/**
 * 
 * @author Chinna
 *
 */
public class CommonUtils {

	public static List<SimpleGrantedAuthority> buildSimpleGrantedAuthorities(final Set<UserRole> userRoles) {
		List<SimpleGrantedAuthority> authorities = new ArrayList<>();
		for (UserRole userRole : userRoles) {
			authorities.add(new SimpleGrantedAuthority(userRole.getRole().getName()));
		}
		return authorities;
	}
}

Creating REST Controller

AuthController exposes /signin and /signup POST REST APIs for user login and registration respectively.

AuthController.java

package com.javachinna.controller;

import com.javachinna.dto.*;
import com.javachinna.exception.UserAlreadyExistAuthenticationException;
import com.javachinna.model.User;
import com.javachinna.service.UserService;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.Instant;
import java.util.stream.Collectors;

/**
 * REST Controller responsible for user login and registration.
 *
 * @author Chinna
 */
@Slf4j
@RestController
@AllArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {

    final UserService userService;
    final JwtEncoder encoder;

    final AuthenticationManager authenticationManager;

    @PostMapping("/signin")
    public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
        Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(loginRequest.getEmail(), loginRequest.getPassword()));
        SecurityContextHolder.getContext().setAuthentication(authentication);
        LocalUser localUser = (LocalUser) authentication.getPrincipal();
        String jwt = getToken(localUser);
        return ResponseEntity.ok(new JwtAuthenticationResponse(jwt));
    }

    private String getToken(LocalUser localUser) {
        Instant now = Instant.now();
        long expiry = 36000L;
        // @formatter:off
        String scope = localUser.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(" "));
        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("self")
                .issuedAt(now)
                .expiresAt(now.plusSeconds(expiry))
                .subject(localUser.getUsername())
                .claim("scope", scope)
                .build();
        // @formatter:on
        return this.encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
    }

    @PostMapping("/signup")
    public ResponseEntity<?> registerUser(@Valid @RequestBody SignUpRequest signUpRequest) {
        try {
            User user = userService.registerNewUser(signUpRequest);
        } catch (UserAlreadyExistAuthenticationException e) {
            log.error("Exception Occurred", e);
            return new ResponseEntity<>(new ApiResponse(false, "Email Address already in use!"), HttpStatus.BAD_REQUEST);
        }
        return ResponseEntity.ok().body(new ApiResponse(true, "User registered successfully"));
    }
}

Configuring Spring Security

Configuration class which makes use of the RSA keys that we have generated earlier to define the JwtEncoder & JwtDecoder beans, defines SecurityFilterChain bean for securing the private APIs with the OAuth2 Resource server. So, secured APIs can only be accessed by users with admin roles. Also, enables CSRF and XSS protection.

WebConfig.java

package com.javachinna.config;

import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
import org.springframework.security.web.SecurityFilterChain;

import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.HashMap;
import java.util.Map;

/**
 * Configuration for the main application.
 *
 * @author Chinna
 */
@Configuration
public class WebConfig {

    @Value("${jwt.public.key}")
    RSAPublicKey key;

    @Value("${jwt.private.key}")
    RSAPrivateKey privateKey;
    public static final String[] PUBLIC_PATHS = {"/api/auth/**"};
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // @formatter:off
        http
                .authorizeHttpRequests()
                    .requestMatchers(PUBLIC_PATHS).permitAll()
                    .anyRequest().hasAuthority("SCOPE_ROLE_ADMIN").and()
                .csrf().disable()
                .httpBasic().disable()
                .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
                .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .exceptionHandling((exceptions) -> exceptions
                        .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
                        .accessDeniedHandler(new BearerTokenAccessDeniedHandler())
                )
                // XSS protection
                .headers().xssProtection().and()
                .contentSecurityPolicy("script-src 'self'");
        // @formatter:on
        return http.build();
    }

    @Bean
    JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withPublicKey(this.key).build();
    }

    @Bean
    JwtEncoder jwtEncoder() {
        JWK jwk = new RSAKey.Builder(this.key).privateKey(this.privateKey).build();
        JWKSource<SecurityContext> jwks = new ImmutableJWKSet<>(new JWKSet(jwk));
        return new NimbusJwtEncoder(jwks);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        String encodingId = "bcrypt";
        BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(10);
        Map<String, PasswordEncoder> encoders = new HashMap<>();
        encoders.put(encodingId, bCryptPasswordEncoder);
        DelegatingPasswordEncoder delegatingPasswordEncoder = new DelegatingPasswordEncoder(encodingId, encoders);
        delegatingPasswordEncoder.setDefaultPasswordEncoderForMatches(bCryptPasswordEncoder);
        return delegatingPasswordEncoder;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }
}

Creating Users & Roles on Application Startup

SetupDataLoader.java

package com.javachinna.config;

import com.javachinna.model.Movie;
import com.javachinna.model.Role;
import com.javachinna.model.User;
import com.javachinna.repo.MovieRepository;
import com.javachinna.repo.RoleRepository;
import com.javachinna.repo.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.io.FileReader;
import java.io.Reader;

/**
 * Class is responsible for initializing the database with users and movies from CSV file on application startup
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class SetupDataLoader implements ApplicationListener<ContextRefreshedEvent> {

    private boolean alreadySetup = false;

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;
    private final MovieRepository movieRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    @Transactional
    public void onApplicationEvent(final ContextRefreshedEvent event) {
        if (alreadySetup || userRepository.findAll().iterator().hasNext()) {
            return;
        }

        // Create user roles
        var userRole = createRoleIfNotFound(Role.ROLE_USER);
        var adminRole = createRoleIfNotFound(Role.ROLE_ADMIN);

        // Create users
        createUserIfNotFound("[email protected]", passwordEncoder.encode("user@@"), // "user"
                userRole, "User");
        createUserIfNotFound("[email protected]", passwordEncoder.encode("admin@"), // "admin"
                adminRole, "Administrator");
        insertMoviesFromCSV();
        alreadySetup = true;
    }

    @Transactional
    void createUserIfNotFound(final String email, final String password, final Role role, final String displayName) {
        User user = userRepository.findByEmailIgnoreCase(email);
        if (user == null) {
            user = new User(email, password);
            user.addRole(role);
            user.setEnabled(true);
            user.setDisplayName(displayName);
            userRepository.save(user);
        }
    }

    @Transactional
    Role createRoleIfNotFound(final String name) {
        Role role = roleRepository.findByName(name);
        if (role == null) {
            role = new Role(name);
            role = roleRepository.save(role);
        }
        return role;
    }
}

Creating Main Application Class

MovieServiceApplication.java

package com.javachinna;

import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.transaction.annotation.EnableTransactionManagement;

/**
 * Main Spring Boot Application class
 *
 * @author Chinna
 */
@SpringBootApplication(scanBasePackages = "com.javachinna")
@EnableJpaRepositories
@EnableTransactionManagement
public class MovieServiceApplication extends SpringBootServletInitializer {

	public static void main(String[] args) {
		SpringApplicationBuilder app = new SpringApplicationBuilder(MovieServiceApplication.class);
		app.run();
	}

	@Override
	protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
		return application.sources(MovieServiceApplication.class);
	}
}

Creating Application Properties

application.properties

server.port=8082
spring.application.name=movie-service
# Database configuration props
spring.datasource.url=jdbc:mysql://localhost:3306/moviesdb?createDatabaseIfNotExist=true
spring.datasource.username=root
spring.datasource.password=secret
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# Hibernate props
spring.jpa.show-sql=true
#spring.jpa.hibernate.ddl-auto=none
spring.jpa.hibernate.ddl-auto=create
omdb.api.key=fbd6e4a1
jwt.private.key=classpath:app.key
jwt.public.key=classpath:app.pub
logging.level.web=debug
logging.level.org.springframework=debug

Note: I have copied the RSA key pairs to the src/main/resources directory and specified classpath in the key location properties. However, in a production application, you may need to copy them to an external directory outside the application and specify the paths accordingly. So that, you can change the keys without changing the code.

Source Code

https://github.com/JavaChinna/spring-boot-oauth2-jwt

What’s Next?

So far, we have implemented user login and registration with OAuth 2.0 token role-based authentication. In the next article, we will document our API with OpenAPI 3.0 spec. So that we can test the APIs in Swagger UI instead of using SOAP UI or Postman.

This Post Has 2 Comments

  1. Akhil

    Could you please let me know the changes if I want to add the refresh_token concept to the above code

    1. Chinna

      Unlike access tokens, refresh tokens are intended for use only with authorization servers and are never sent to resource servers. Hence, it is not recommended to add refresh token support to an application that has been configured with OAuth2 resource server support only. If you really need the refresh token support, then it’s better to use Spring Security OAuth2 Authorization Server which is a separate component responsible for issuing access tokens with refresh token support. You can refer to this RFC to understand more about the role of OAuth 2 Authorization Server.

Leave a Reply