Preface
Spring Security
has become the first choice for permission verification in Java backends. Today I will take you through Security in depth by reading the code based on the open source project spring-boot-3-jwt-security. This article mainly explains Spring Security + JWT (Json Web Token) to implement user authentication and permission verification. All code is built on jdk17+
. Let's get started!
Technology Introduction
Springboot 3.0
Spring Security
Json Web Token (JWT)
BCrypt
Maven
Project Construction
- The project uses
postgresql
database to store user information andToken
(why not Redis? Leave this hole for now), you can replace it withmysql
database as you like - Accessing the database uses
jpa
, which is quite convenient for some simple sql that can be automatically mapped based on method names. It doesn't matter if you haven't used it before. It won't affect reading today's article, and can be replaced withmybatis-plus
etc later according to your actual needs - This article uses Lombok to generate fixed template code
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.alibou</groupId>
<artifactId>security</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>security</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- jpa -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- spring security security framework -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- web dependency -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- database -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
</dependency>
<!-- doc remove this if not needed -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.1.0</version>
</dependency>
<!-- validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</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>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
Project Configuration
Authentication Configuration
- When the project references the
Security
dependency, a random password will be generated when the project is started. We need to use this password to log in before we can use it. This will affect the normal use of many functions, such as the evilswagger
. Let's take a detailed look at how to configure the paths we need authentication and the paths we need to release below.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity
public class SecurityConfiguration {
private final JwtAuthenticationFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
private final LogoutHandler logoutHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf()
.disable() //turn off csrf
.authorizeHttpRequests()
//configure paths to release
.requestMatchers(
"/api/v1/auth/**",
"/v2/api-docs",
"/v3/api-docs",
"/v3/api-docs/**",
"/swagger-resources",
"/swagger-resources/**",
"/configuration/ui",
"/configuration/security",
"/swagger-ui/**",
"/webjars/**",
"/swagger-ui.html"
)
.permitAll() //release all above paths
/*
* Permission verification (users need to have specified permissions to access)
* requestMatchers: Specify paths to intercept
* hasAnyAuthority: Specify required permissions
*/
.requestMatchers("/api/v1/management/**").hasAnyRole(ADMIN.name(), MANAGER.name())
.requestMatchers(GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.name(), MANAGER_READ.name())
.requestMatchers(POST, "/api/v1/management/**").hasAnyAuthority(ADMIN_CREATE.name(), MANAGER_CREATE.name())
.requestMatchers(PUT, "/api/v1/management/**").hasAnyAuthority(ADMIN_UPDATE.name(), MANAGER_UPDATE.name())
.requestMatchers(DELETE, "/api/v1/management/**").hasAnyAuthority(ADMIN_DELETE.name(), MANAGER_DELETE.name())
.anyRequest()
.authenticated() //set all requests to be verified
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) //use stateless Session
.and()
.authenticationProvider(authenticationProvider)
//add jwt filter
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
//set logout (will call logoutHandler's logout method when this path is called)
.logout()
.logoutUrl("/api/v1/auth/logout")
.addLogoutHandler(logoutHandler)
.logoutSuccessHandler((request, response,authentication) -> SecurityContextHolder.clearContext())
;
return http.build();
}
}
- The above code mainly implements four functions:
- Release paths that do not require authentication (registration & login, swagger)
- Configure permissions users need to access specific interfaces (e.g. must have permission to delete users to delete users)
- Add preceding filter to determine if user is valid and get user permissions from Token:
jwtAuthFilter
- Configure logout Handler and listened path. The method in
logoutHandler
will be called automatically when accessing this path.
Login Configuration
We've talked about permissions and token
verification above, now let's take a look at the logic of login. A UserDetails
class is needed in security
to define user account behavior. This is the key to user authentication. It mainly includes account, password, permissions, user status, etc. There are detailed comments in the code below
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "_user")
public class User implements UserDetails {
@Id
@GeneratedValue
private Integer id; //primary key ID
private String firstname; //first name
private String lastname; //last name
private String email; //email
private String password; //password
/**
* Role enumeration
*/
@Enumerated(EnumType.STRING)
private Role role;
/**
* Tokens associated with user
* One-to-many mapping is used here with jpa
*/
@OneToMany(mappedBy = "user")
private List<Token> tokens;
/**
* Get user permissions
* Get based on role enum permissions here (static instead of dynamically from database)
* @return User permission list
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return role.getAuthorities();
}
/**
* Get user password
* Mainly used to specify your password field
* @return User password
*/
@Override
public String getPassword() {
return password;
}
/**
* Get user account
* Use email as account here
* @return User account
*/
@Override
public String getUsername() {
return email;
}
/**
* Whether account is expired, the following methods are used to specify account status,
* since this is a demo project, all return true here
* @return true not expired
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* Whether account is unlocked
* @return true not locked
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* Whether password is expired
* @return true not expired
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* Whether account is activated
* @return true activated
*/
@Override
public boolean isEnabled() {
return true;
}
}
After understanding the user entity, let's take a look at how login configuration is done and how security
helps us manage user password verification. Let's take a look at the overall security
configuration below
@Configuration
@RequiredArgsConstructor
public class ApplicationConfig {
/**
* Access user data table
*/
private final UserRepository repository;
/**
* Get user detail Bean
* Query whether user exists by email, throw user not found exception if not exists
*/
@Bean
public UserDetailsService userDetailsService() {
//Call repository's findByEmail method to get user info, return if exists, otherwise throw exception
return username -> repository.findByEmail(username)
//Use Option's orElseThrow method here, return if exists, otherwise throw exception
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
/**
* Authentication Bean
* Pass in bean to get user info & password encoder
* Refer back to AuthenticationProvider configuration in SecurityConfiguration,
* the Bean injected into the container here is used
* This bean is mainly used for identity verification during user login,
* when we log in, security will help us call the authenticate method of this bean
*/
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
//Set bean to get user info
authProvider.setUserDetailsService(userDetailsService());
//Set password encoder
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
/**
* Authentication Manager
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
/**
* Password Encoder
* Mainly used to specify encryption method for storing passwords in database,
* to ensure passwords are not stored in plaintext
* When security needs to verify passwords, it will encrypt the password passed in the request
* then compare it with the password in the database
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
The above code mainly does two things:
- Specify how we get user information from the database based on user account
- Specify password encoder for user
passwordEncoder
You may be wondering now, how does security
know which field in User
entity is my account and which is my password? Do you still remember the UserDetails
class, which is our User
class? It has two methods getPassword
& getUsername
that return the account and password. There are a few other methods in User
class that can be used to disable
accounts etc according to actual business needs.
How Token is Generated
Token generation is mainly accomplished using toolkits. The user info
& user permissions
are mainly stored in the Token in this project. Let's first take a look at the code for the token
toolkit, which mainly includes: generating token
, getting info from token
, and verifying token
@Service
public class JwtService {
/**
* Encrypt salt
*/
@Value("${application.security.jwt.secret-key}")
private String secretKey;
/**
* Token expiration time
*/
@Value("${application.security.jwt.expiration}")
private long jwtExpiration;
/**
* Token refresh time
*/
@Value("${application.security.jwt.refresh-token.expiration}")
private long refreshExpiration;
/**
* Get Username from Token
* @param token Token
* @return String
*/
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
/**
* Get data from Token, return different data according to different Function passed in
* eg: String extractUsername(String token)
*/
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
/**
* Generate Token without extra info
*/
public String generateToken(UserDetails userDetails) {
return generateToken(new HashMap<>(), userDetails);
}
/**
* Generate Token, with extra info
* @param extraClaims Extra data
* @param userDetails User info
* @return String
*/
public String generateToken(
Map<String, Object> extraClaims,
UserDetails userDetails
) {
return buildToken(extraClaims, userDetails, jwtExpiration);
}
/**
* Generate refresh Token
* @param userDetails User info
* @return String
*/
public String generateRefreshToken(
UserDetails userDetails
) {
return buildToken(new HashMap<>(), userDetails, refreshExpiration);
}
/**
* Build Token method
* @param extraClaims Extra info
* @param userDetails //User info
* @param expiration //Expiration time
* @return String
*/
private String buildToken(
Map<String, Object> extraClaims,
UserDetails userDetails,
long expiration
) {
return Jwts
.builder()
.setClaims(extraClaims) //body
.setSubject(userDetails.getUsername()) //subject data
.setIssuedAt(new Date(System.currentTimeMillis())) //set issue time
.setExpiration(new Date(System.currentTimeMillis() + expiration)) //set expiration time
.signWith(getSignInKey(), SignatureAlgorithm.HS256) //set digest algorithm
.compact();
}
/**
* Verify if Token is valid
* @param token Token
* @param userDetails User info
* @return boolean
*/
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
}
/**
* Determine if Token is expired
*/
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
/**
* Get expiration from Token
*/
private Date extractExpiration(String token) {
//generic method, pass in a Function, return a T
return extractClaim(token, Claims::getExpiration);
}
/**
* Get all data from Token
*/
private Claims extractAllClaims(String token) {
return Jwts
.parserBuilder()
.setSigningKey(getSignInKey())
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* Get signIn Key
* Used for Token encryption and decryption
*/
private Key getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
}
Registration and Login
We have looked through token generation, now let's get into the most critical part - user registration
& user login
.
- User registration: Receive user information passed in, generate user information (password will be encrypted by
passwordEncoder
) in database. After user information is saved successfully, an authenticationtoken
and arefreshToken
will be created based on user info. -
User login: Get the account and password passed in by user, create a
UsernamePasswordAuthenticationToken
object. Then authenticate throughauthenticationManager
'sauthenticate
method, different exceptions will be thrown based on different errors
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthenticationController {
private final AuthenticationService service;
/**
* Registration method
* @param request Request body
* @return ResponseEntity
*/
@PostMapping("/register")
public ResponseEntity<AuthenticationResponse> register(
@RequestBody RegisterRequest request
) {
return ResponseEntity.ok(service.register(request));
}
/**
* Authentication (login method)
* @param request Request body
* @return ResponseEntity
*/
@PostMapping("/authenticate")
public ResponseEntity<AuthenticationResponse> authenticate(
@RequestBody AuthenticationRequest request
) {
return ResponseEntity.ok(service.authenticate(request));
}
/**
* Refresh token
* @param request Request
* @param response Response
* @throws IOException Exception
*/
@PostMapping("/refresh-token")
public void refreshToken(
HttpServletRequest request,
HttpServletResponse response
) throws IOException {
service.refreshToken(request, response);
}
}
You can see the methods in controller
are calls to service
methods. Let's look at the code in service
now:
@Service
@RequiredArgsConstructor
public class AuthenticationService {
private final UserRepository repository; //access user database
private final TokenRepository tokenRepository; //access token database
private final PasswordEncoder passwordEncoder; //password encoder
private final JwtService jwtService; //JWT related methods
private final AuthenticationManager authenticationManager; //Spring Security authentication manager
/**
* Registration method
* @param request Request body
* @return AuthenticationResponse (custom response structure)
*/
public AuthenticationResponse register(RegisterRequest request) {
//Construct user info
var user = User.builder()
.firstname(request.getFirstname())
.lastname(request.getLastname())
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.role(request.getRole())
.build();
//Save user info to database
var savedUser = repository.save(user);
//Generate Token via JWT method
var jwtToken = jwtService.generateToken(user);
//Generate RefreshToken
var refreshToken = jwtService.generateRefreshToken(user);
//Save Token to database
saveUserToken(savedUser, jwtToken);
//Return response
return AuthenticationResponse.builder()
.accessToken(jwtToken)
.refreshToken(refreshToken)
.build();
}
/**
* Authentication (login) method
* @param request Request body
* @return AuthenticationResponse (custom response structure)
*/
public AuthenticationResponse authenticate(AuthenticationRequest request) {
//Authenticate via Spring Security authentication manager
//Exception will be thrown if authentication fails eg: BadCredentialsException for wrong password, UsernameNotFoundException for non-existing user
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);
//Query user info by email, email is account in this project
var user = repository.findByEmail(request.getEmail())
.orElseThrow();
//Generate Token via JWT method
var jwtToken = jwtService.generateToken(user);
//Generate RefreshToken
var refreshToken = jwtService.generateRefreshToken(user);
//Set all previous tokens to invalid
revokeAllUserTokens(user);
//Save new Token to database
saveUserToken(user, jwtToken);
//Package response
return AuthenticationResponse.builder()
.accessToken(jwtToken)
.refreshToken(refreshToken)
.build();
}
/**
* Save user Token method
* Save to database after constructing Token entity
* @param user User info
* @param jwtToken Token
*/
private void saveUserToken(User user, String jwtToken) {
var token = Token.builder()
.user(user)
.token(jwtToken)
.tokenType(TokenType.BEARER)
.expired(false)
.revoked(false)
.build();
tokenRepository.save(token);
}
/**
* Set all user Tokens to invalid
* @param user User info
*/
private void revokeAllUserTokens(User user) {
//Get all valid tokens for user
var validUserTokens = tokenRepository.findAllValidTokenByUser(user.getId());
if (validUserTokens.isEmpty()){
return;
}
//If there are still valid tokens, set them to invalid
validUserTokens.forEach(token -> {
token.setExpired(true);
token.setRevoked(true);
});
tokenRepository.saveAll(validUserTokens);
}
/**
* Refresh token method
* @param request Request
* @param response Response
* @throws IOException Throw IO exception
*/
public void refreshToken(
HttpServletRequest request,
HttpServletResponse response
) throws IOException {
//Get authentication info from request header AUTHORIZATION
final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
final String refreshToken;
final String userEmail;
//Directly return if auth info is empty or not starting with Bearer
if (authHeader == null ||!authHeader.startsWith("Bearer ")) {
return;
}
//Get RefreshToken from auth info
refreshToken = authHeader.substring(7);
//Get user info from RefreshToken
userEmail = jwtService.extractUsername(refreshToken);
if (userEmail != null) {
//Query user by info, throw exception if user does not exist
var user = this.repository.findByEmail(userEmail)
.orElseThrow();
//Verify if Token is valid
if (jwtService.isTokenValid(refreshToken, user)) {
//Generate new Token
var accessToken = jwtService.generateToken(user);
revokeAllUserTokens(user);
saveUserToken(user, accessToken);
//Generate new Token and RefreshToken and return via response
var authResponse = AuthenticationResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
new ObjectMapper().writeValue(response.getOutputStream(), authResponse);
}
}
}
}
The above code mainly illustrates the process of returning token
after registration
& login
. Since the validity period of token
& refreshToken
is quite long in the current project, the choice is made to save token
to database (personal opinion!!!). Whether to save to redis
can be decided based on actual business needs.
Request Filtering
Request filtering is mainly to dynamically parse token
on each request to get user info
and permissions
, to ensure security of requested resources. Prevent unauthorized access etc.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
private final TokenRepository tokenRepository;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
//Determine if request is login request, if yes do not process
if (request.getServletPath().contains("/api/v1/auth")) {
filterChain.doFilter(request, response);
return;
}
//Get authHeader from request header
final String authHeader = request.getHeader("Authorization");
final String jwt;
final String userEmail;
//If Token does not exist or does not start with Bearer, do not process
if (authHeader == null ||!authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
//Extract Token info from authHeader
jwt = authHeader.substring(7);
//Get userEmail (account) from Token
userEmail = jwtService.extractUsername(jwt);
//Only process if Authentication in SecurityContextHolder is null
if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
//Get user info
UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);
//Query Token from database and determine if Token status is normal
var isTokenValid = tokenRepository.findByToken(jwt)
.map(t -> !t.isExpired() && !t.isRevoked())
.orElse(false);
//If Token is valid and Token status is normal, store user info in SecurityContextHolder
if (jwtService.isTokenValid(jwt, userDetails) && isTokenValid) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails, //user info
null,
userDetails.getAuthorities() //user permissions
);
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request) //access info
);
//Save user info and permissions to SecurityContextHolder context for later use
//eg: get current user id, current user permissions etc
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
The main logic above is: Get token
from request header. Verify validity of token
and parse info in token
to store in SecurityContextHolder
context for later use.
Logout
We have talked about login
and token
verification, now just missing a logout. Do you still remember we configured a logout request path before: /api/v1/auth/logout
. When we request this path, security
will help us find the corresponding LogoutHandler
, then call the logout
method to implement logout.
@Service
@RequiredArgsConstructor
public class LogoutService implements LogoutHandler {
private final TokenRepository tokenRepository;
@Override
public void logout(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication
) {
//Get authentication info from request header
final String authHeader = request.getHeader("Authorization");
final String jwt;
if (authHeader == null ||!authHeader.startsWith("Bearer ")) {
return;
}
//Extract token
jwt = authHeader.substring(7);
//Query token info from database
var storedToken = tokenRepository.findByToken(jwt)
.orElse(null);
if (storedToken != null) {
//Set token expired
storedToken.setExpired(true);
storedToken.setRevoked(true);
tokenRepository.save(storedToken);
//Clear SecurityContextHolder context
SecurityContextHolder.clearContext();
}
}
}
security
has done a lot for us, we just need to set the token
to invalid and clear the SecurityContextHolder
context to solve all problems.
Authentication
Below are some examples to explain two different authentication configuration methods
controller
@RestController
@RequestMapping("/api/v1/admin")
@PreAuthorize("hasRole('ADMIN')") //User needs ADMIN role to access
public class AdminController {
@GetMapping
@PreAuthorize("hasAuthority('admin:read')") //User needs admin:read permission to access
public String get() {
return "GET:: admin controller";
}
@PostMapping
@PreAuthorize("hasAuthority('admin:create')") //User needs admin:create permission to access
@Hidden
public String post() {
return "POST:: admin controller";
}
@PutMapping
@PreAuthorize("hasAuthority('admin:update')")
@Hidden
public String put() {
return "PUT:: admin controller";
}
@DeleteMapping
@PreAuthorize("hasAuthority('admin:delete')")
@Hidden
public String delete() {
return "DELETE:: admin controller";
}
}
Configuration File
Below is part of the code for SecurityConfiguration
configuration class:
Comments