Secure REST API with Spring Boot 3.0, Spring Security 6.0 and PASETO
--
Welcome to another Spring Boot tutorial. Today let’s have a look at how to build a Spring Boot REST API that supports Token based Authentication with PASETO.
· Prerequisites
· Overview
∘ What is PASETO?
∘ PASETO Vs JOSE (JWS, JWE and JWT)
∘ PASETO token format
· Getting Started
∘ Creating entities
∘ The UserDetailsService
∘ Spring Security configuration
∘ PASETO Utility service
∘ Project structure
· Testing
· Conclusion
· References
Prerequisites
This is the list of all the prerequisites:
- Spring Boot 3 +
- Maven 3.6.+
- Java 17 (Spring Security 6 requires JDK 17)
- PostgreSQL
- Postman / insomnia or any other API testing tool.
Overview
What is PASETO?
PASETO (Platform-Agnostic SEcurity TOken) is a specification and reference implementation for secure stateless tokens. It is pronounced paw-set-oh (pɔːsɛtəʊ).
PASETO encodes claims to be transmitted in a JSON (RFC8259) object and is either encrypted symmetrically or signed using public-key cryptography.
PASETO Vs JOSE (JWS, JWE and JWT)
The key difference between PASETO and the JOSE family of standards (JWS [RFC7516], JWE [RFC7517], JWK [RFC7518], JWA [RFC7518], and JWT [RFC7519]) is that JOSE allows implementors and users to mix and match their own choice of cryptographic algorithms (specified by the “alg” header in JWT), while PASETO has clearly defined protocol versions to prevent unsafe configurations from being selected.
PASETO token format
A PASETO token consists of three or four segments that have been base64-encoded and dot-separated data, similar to JWTs.
- Without the Optional Footer:
version.purpose.payload
Example:
- With the Optional Footer:
version.purpose.payload.footer
Example:
- The version is a string that represents the current version of the protocol. Although the RFC is still in draft form, there are two versions (v1, v2) specified, and each with its own cipher suites. v2 is the recommended one which uses the latest cryptographic primitives. Both v1 and v2 provide authentication of the entire PASETO message, including the version, purpose, payload, and footer.
2. The purpose is a short string describing the purpose of the token. It can be “local” or “public”.
- local: shared-key authenticated encryption
- public: public-key digital signatures; not encrypted
3. The payload is a string that contains the token’s data. In a “local” token, this data is encrypted with a symmetric cipher. In a “public” token, this data is unencrypted.
4. The footer is authenticated through inclusion in the calculation of the authentication tag along with the header and payload. It is optional and MUST NOT be encrypted. If no footer is provided, implementations SHOULD NOT append a trailing period to each payload.
Getting Started
We will start by creating a simple Spring project from start.spring.io, with the following dependencies: Spring Web, PostgreSQL Driver, Spring Data JPA, Spring Security, Lombok, and Validation.
There are many PASETO implementations in different languages. In this story, we will be using the java library JPaseto.
To use the JPaseto java library, we’ll define the following dependencies in the pom.xml file:
<dependency>
<groupId>dev.paseto</groupId>
<artifactId>jpaseto-api</artifactId>
<version>0.7.0</version>
</dependency>
<dependency>
<groupId>dev.paseto</groupId>
<artifactId>jpaseto-impl</artifactId>
<version>0.7.0</version>
</dependency>
<dependency>
<groupId>dev.paseto</groupId>
<artifactId>jpaseto-jackson</artifactId>
<version>0.7.0</version>
</dependency>
<dependency>
<groupId>dev.paseto</groupId>
<artifactId>jpaseto-sodium</artifactId>
<version>0.7.0</version>
</dependency>
Creating entities
Let’s define the entities. In the entities package, we’ll create User
and Role
classes.
User.java
The user class implements the UserDetails interface that provides security core user information.
@Entity(name = "app_user")
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
@JsonIgnoreProperties(ignoreUnknown = true)
@ToString
@Table(uniqueConstraints = {
@UniqueConstraint(columnNames = "username"),
@UniqueConstraint(columnNames = "email")
})
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
@SequenceGenerator(name = "sequenceGenerator")
private Long id;
@NotNull
@Size(min = 1, max = 50)
@Column(length = 50, unique = true, nullable = false)
private String username;
@Size(max = 50)
@Column(name = "first_name", length = 50)
private String firstName;
@Size(max = 50)
@Column(name = "last_name", length = 50)
private String lastName;
@Email
@Size(min = 5, max = 254)
@Column(length = 254, unique = true)
private String email;
@NotBlank
@NotNull
private String password;
private boolean enabled = true;
@Column(name = "account_non_expired")
private boolean accountNonExpired;
@Column(name = "credentials_non_expired")
private boolean credentialsNonExpired;
@Column(name = "account_non_locked")
private boolean accountNonLocked;
/*
* Get all roles associated with the User
*/
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "role_user", joinColumns = {
@JoinColumn(name = "user_id", referencedColumnName = "id") }, inverseJoinColumns = {
@JoinColumn(name = "role_id", referencedColumnName = "id") })
private List<GroupRole> roles;
@Override
public boolean isEnabled() {
return enabled;
}
@Override
public boolean isAccountNonExpired() {
return !accountNonExpired;
}
@Override
public boolean isCredentialsNonExpired() {
return !credentialsNonExpired;
}
@Override
public boolean isAccountNonLocked() {
return !accountNonLocked;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles.stream().map(role -> new SimpleGrantedAuthority(role.getCode().name())).collect(Collectors.toSet());
}
}
GroupRole.java
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@EqualsAndHashCode
@Entity(name = "app_role")
public class GroupRole implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull
@Enumerated(EnumType.STRING)
@Column(length = 15)
private RoleEnum code;
private String description;
}
The UserDetailsService
UserDetailsService is used by DaoAuthenticationProvider for retrieving a username, a password, and other attributes for authenticating with a username and password.
We will need to implement the UserDetailsService interface to override the interface’s loadUserByUsername(String username)
method.
public interface UserService extends UserDetailsService, UserDetailsPasswordService{
/**
* @return list of User
*/
List<User> getUsers();
/**
* @param user ussr object
* @return user saved or updated
*/
User save(User user);
}
Let’s define the UserDetailsServiceImpl class as follows:
@Service(value = "userService")
@RequiredArgsConstructor
@Transactional
public class UserDetailsServiceImpl implements UserService {
private final UserRepository userRepository;
private final AccountStatusUserDetailsChecker detailsChecker = new AccountStatusUserDetailsChecker();
/**
* Load user info by credential
*
* @param usernameValue username or email
* @return UserDetails object
*/
@Override
public UserDetails loadUserByUsername(String usernameValue) {
Optional<User> user = getUserByUsername(usernameValue);
if (user.isEmpty()) {
throw new UsernameNotFoundException("Invalid username or password.");
}
detailsChecker.check(user.get());
return user.get();
}
/**
* @param usernameValue username or email
* @return Optional User
*/
private Optional<User> getUserByUsername(String usernameValue) {
// trim username value
var username = StringUtils.trimToNull(usernameValue);
if (StringUtils.isEmpty(username)) {
return Optional.empty();
}
return username.contains("@") ? userRepository.findActiveByEmail(username) : userRepository.findActiveByUsername(username);
}
/**
* {@inheritDoc}
*/
@Override
public List<User> getUsers() {
return userRepository.findAll();
}
/**
* {@inheritDoc}
*/
@Override
public User save(User user) {
if (userRepository.findByUsername(user.getUsername()).isPresent()) {
throw new DuplicateException("Username Already exist !!");
}
if (userRepository.findByEmail(user.getEmail()).isPresent()) {
throw new DuplicateException("Email Already exist !!");
}
return userRepository.save(user);
}
/**
* {@inheritDoc}
*/
@Override
public UserDetails updatePassword(UserDetails user, String newPassword) {
return null;
}
}
Spring Security configuration
We need to create a new class in a config package called SecurityConfig.java.
Spring Security 6.0 no longer supports WebSecurityConfigurerAdapter class.
SecurityConfig class will have the following configuration:
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final UserDetailsService userDetailsService;
private final AuthTokenFilter authTokenFilter;
@Bean // (1)
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean // (2)
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
@Bean // (3)
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
return daoAuthenticationProvider;
}
@Bean // (4)
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.csrf()
.disable()
// Add Paseto token filter
.addFilterBefore(authTokenFilter, UsernamePasswordAuthenticationFilter.class)
.authenticationProvider(authenticationProvider())
// Set unauthorized requests exception handler
.exceptionHandling()
.authenticationEntryPoint(new HandlerAuthenticationEntryPoint())
.accessDeniedHandler(new HandlerAccessDeniedHandler())
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// Set permissions on endpoints
.authorizeHttpRequests()
.requestMatchers("/api/account/authenticate").permitAll()
.requestMatchers("/api/account/register").permitAll()
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers(
"/configuration/ui",
"/swagger-resources/**",
"/configuration/security",
"/webjars/**").permitAll()
.requestMatchers("/api/**").authenticated();
// @formatter:on
return http.build();
}
}
- It is used for storing a password that needs to be compared to the user-provided password at the time of authentication.
- It defines how Spring Security Filters perform authentication.
- The AuthenticationProviders will handle each authentication request and, in the event of successful authentication, return an Authentication object. Otherwise, the provider will throw an exception.
- Defines all filters chain which is capable of being matched against an HttpServletRequest.
PASETO Utility service
Now we will create a common utility service that will be responsible for decoding and parsing the Paseto token.
@Slf4j
@Component
public class TokenProvider {
private final SecretKey secretKey;
private final KeyPair keyPair;
public TokenProvider() {
secretKey = Keys.secretKey();
keyPair = Keys.keyPairFor(Version.V2);
}
/**
* Extract username from token string.
*
* @param token a valid token value
* @return String username
*/
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
private Instant extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
/**
* @param token token
* @return true if token is expired otherwise false
*/
private boolean isTokenExpired(String token) {
return extractExpiration(token).isBefore(Instant.now());
}
/**
* retrieve claims
*
* @param token token
* @return claims object
*/
public Claims getClaims(String token) {
return parseToken(token).getClaims();
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = getClaims(token);
return claimsResolver.apply(claims);
}
/**
* Generate token string.
*
* @param authentication the user
* @return the string token generated
*/
public String generateToken(Authentication authentication) {
Instant now = Instant.now();
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).collect(Collectors.joining(","));
User userPrincipal = (User) authentication.getPrincipal();
return Pasetos.V2.LOCAL.builder()
.setSharedSecret(secretKey)
.setIssuedAt(now)
.setExpiration(now.plus(2, ChronoUnit.HOURS))
.setSubject(userPrincipal.getUsername())
.setKeyId(UUID.randomUUID().toString())
.setAudience("bootlabs.com")
.setIssuer("dev.com")
.claim("aut", authorities)
.compact();
}
/**
* Extract tenant id from token string.
*
* @param token the auth token value
* @return the string tenant id from token
*/
public Paseto parseToken(String token) {
PasetoParser parser = Pasetos.parserBuilder()
.setSharedSecret(secretKey)
.setPublicKey(keyPair.getPublic())
.build();
return parser.parse(token);
}
/**
* Validate token value.
*
* @param authToken String token
* @return true if token is valid or false otherwise
*/
public boolean validateToken(String authToken) {
try {
parseToken(authToken);
return !isTokenExpired(authToken);
} catch (PasetoException e) {
log.error("Token validation error: {}", e.getMessage());
return false;
}
}
Project structure
This is the final folders & files structure for our project:
Testing
Now we are all done with our code. We can run our application and test it.
- User Registration
We have the following data in the database.
- User login
The response contains the generated Paseto token when login is successful.
- Get all users
With the generated Paseto token, we need to retrieve all users from the database. To do this, paste the token in Authorization Bearer Token.
if the token is wrong, the client will receive an HTTP 401 response.
Conclusion
Well done !!. In this story, We have seen how to secure a REST API with Spring Boot 3.0, Spring Security 6.0, and PASETO.
In my opinion, PASETO can be a safe alternative to JWT.
The complete source code is available on GitHub.
If you enjoyed this article, please give it a few claps 👏.
Thanks for reading!
References
- https://token.dev/paseto/
- https://paseto.io/rfc/
- https://developer.okta.com/blog/2019/10/17/a-thorough-introduction-to-paseto
- https://docs.spring.io/spring-security/reference/whats-new.html
- https://github.com/paseto-standard/paseto-spec
- https://paragonie.com/blog/2018/03/paseto-platform-agnostic-security-tokens-is-secure-alternative-jose-standards-jwt-etc
- https://paragonie.com/files/talks/NoWayJoseCPV2018.pdf