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:
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.
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.
Could you please let me know the changes if I want to add the refresh_token concept to the above code
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.