I’ve written a similar tutorial before, but at the time I wasn’t happy with it, taking a speculative approach and not respecting the official design of Spring Security. This time is relatively empty, so the study again.

The original address: www.inlighting.org/archives/sp…

Project GitHub: github.com/Smith-Cruis…

Old version: github.com/Smith-Cruis…

features

  • JWT is used for authentication and token expiration is supported
  • Ehcache is used to cache data to reduce the pressure on the database for each authentication
  • As close as possible to the Spring Security design
  • Implement annotation permission control

To prepare

You want to start this tutorial with a cursory understanding of the following.

  • Know the basic concepts of JWT
  • Know Spring Security

I’ve written two articles about security frameworks before, so you can take a look at them to lay the groundwork.

Shiro+JWT+Spring Boot Restful Tutorial

Spring Boot+Spring Security+Thymeleaf

In this project, the JWT key is the user’s own login password, so that each token’s key is different and relatively secure.

General idea:

Login:

  1. POST user name password to \login
  2. The request toJwtAuthenticationFilterIn theattemptAuthentication()Method, which takes the POST parameter from the request and wraps it as aUsernamePasswordAuthenticationTokenDelivered to theAuthenticationManagerauthenticate()Method for authentication.
  3. AuthenticationManagerfromCachingUserDetailsServiceTo find the user information and determine whether the account password is correct.
  4. If the account password is correct, jump toJwtAuthenticationFilterIn thesuccessfulAuthentication()Method, we sign and generate the token to return to the user.
  5. If the account password is incorrect, the page is displayedJwtAuthenticationFilterIn theunsuccessfulAuthentication()Method, we return an error message to let the user log in again.

Request authentication:

Request to the main idea is that we will take the Authorization from the request field token, if there is no user of this field, Spring Security will default will use AnonymousAuthenticationToken () packaging it, It represents anonymous users.

  1. Arbitrary request initiation
  2. arriveJwtAuthorizationFilterIn thedoFilterInternal()Method for authentication.
  3. If authentication is successful we will generateAuthenticationSecurityContextHolder.getContext().setAuthentication()If Security is specified, the authentication is complete. How to authenticate here is written in our own code, which will be explained in detail later.

Prepare 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 http://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>2.1.7. RELEASE</version>
        <relativePath/> <! -- lookup parent from repository -->
    </parent>
    <groupId>org.inlighting</groupId>
    <artifactId>spring-boot-security-jwt</artifactId>
    <version>0.0.1 - the SNAPSHOT</version>
    <name>spring-boot-security-jwt</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <! -- JWT support -->
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.8.2</version>
        </dependency>

        <! -- Cache support -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>

        <! -- Cache support -->
        <dependency>
            <groupId>org.ehcache</groupId>
            <artifactId>ehcache</artifactId>
        </dependency>

        <! -- Cache support -->
        <dependency>
            <groupId>javax.cache</groupId>
            <artifactId>cache-api</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>

        <! -- Ehcache reads XML configuration files -->
        <dependency>
            <groupId>javax.xml.bind</groupId>
            <artifactId>jaxb-api</artifactId>
            <version>2.3.0</version>
        </dependency>

        <! -- Ehcache reads XML configuration files -->
        <dependency>
            <groupId>com.sun.xml.bind</groupId>
            <artifactId>jaxb-impl</artifactId>
            <version>2.3.0</version>
        </dependency>

        <! -- Ehcache reads XML configuration files -->
        <dependency>
            <groupId>com.sun.xml.bind</groupId>
            <artifactId>jaxb-core</artifactId>
            <version>2.3.0</version>
        </dependency>

        <! -- Ehcache reads XML configuration files -->
        <dependency>
            <groupId>javax.activation</groupId>
            <artifactId>activation</artifactId>
            <version>1.1.1</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

Copy the code

There is not much to say about the pom.xml configuration file, except for the following dependencies:

<! -- Ehcache reads XML configuration files -->
<dependency>
  <groupId>javax.xml.bind</groupId>
  <artifactId>jaxb-api</artifactId>
  <version>2.3.0</version>
</dependency>

<! -- Ehcache reads XML configuration files -->
<dependency>
  <groupId>com.sun.xml.bind</groupId>
  <artifactId>jaxb-impl</artifactId>
  <version>2.3.0</version>
</dependency>

<! -- Ehcache reads XML configuration files -->
<dependency>
  <groupId>com.sun.xml.bind</groupId>
  <artifactId>jaxb-core</artifactId>
  <version>2.3.0</version>
</dependency>

<! -- Ehcache reads XML configuration files -->
<dependency>
  <groupId>javax.activation</groupId>
  <artifactId>activation</artifactId>
  <version>1.1.1</version>
</dependency>
Copy the code

Because ehCache uses these dependencies when reading XML configuration files, and these dependencies have been optional modules since JDK 9, users of higher releases will need to add these dependencies to make them work.

Groundwork preparation

The next steps are to create a new entity, simulate a database, and write a JWT utility class.

UserEntity.java

To simplify the code, we directly use the existing role class for Security. In a real project, we will have to convert it to the role class for Security.

public class UserEntity {

    public UserEntity(String username, String password, Collection<? extends GrantedAuthority> role) {
        this.username = username;
        this.password = password;
        this.role = role;
    }

    private String username;

    private String password;

    private Collection<? extends GrantedAuthority> role;

    public String getUsername(a) {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword(a) {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public Collection<? extends GrantedAuthority> getRole() {
        return role;
    }

    public void setRole(Collection<? extends GrantedAuthority> role) {
        this.role = role; }}Copy the code

ResponseEntity.java

In order to facilitate the front end, we need to unify the json return format, so we define a custom responseEntity.java.

public class ResponseEntity {

    public ResponseEntity(a) {}public ResponseEntity(int status, String msg, Object data) {
        this.status = status;
        this.msg = msg;
        this.data = data;
    }

    private int status;

    private String msg;

    private Object data;

    public int getStatus(a) {
        return status;
    }

    public void setStatus(int status) {
        this.status = status;
    }

    public String getMsg(a) {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public Object getData(a) {
        return data;
    }

    public void setData(Object data) {
        this.data = data; }}Copy the code

Database.java

Here we use a HashMap to simulate a database, the password I have pre-encrypted with Bcrypt, which is the official Spring Security recommended encryption algorithm (MD5 encryption has been removed in Spring Security 5, is not secure).

The user name password permissions
jack Jack123 after saving Bcrypt encryption ROLE_USER
danny Danny123 after saving Bcrypt encryption ROLE_EDITOR
smith Smith123 after Bcrypt encryption ROLE_ADMIN
@Component
public class Database {
    private Map<String, UserEntity> data = null;
    
    public Map<String, UserEntity> getDatabase(a) {
        if (data == null) {
            data = new HashMap<>();

            UserEntity jack = new UserEntity(
                    "jack"."$2a$10$AQol1A.LkxoJ5dEzS5o5E.QG9jD.hncoeCGdVaMQZaiYZ98V/JyRq",
                    getGrants("ROLE_USER"));
            UserEntity danny = new UserEntity(
                    "danny"."$2a$10$8nMJR6r7lvh9H2INtM2vtuA156dHTcQUyU.2Q2OK/7LwMd/I.HM12",
                    getGrants("ROLE_EDITOR"));
            UserEntity smith = new UserEntity(
                    "smith"."$2a$10$E86mKigOx1NeIr7D6CJM3OQnWdaPXOjWe4OoRqDqFgNgowvJW9nAi",
                    getGrants("ROLE_ADMIN"));
            data.put("jack", jack);
            data.put("danny", danny);
            data.put("smith", smith);
        }
        return data;
    }
    
    private Collection<GrantedAuthority> getGrants(String role) {
        returnAuthorityUtils.commaSeparatedStringToAuthorityList(role); }}Copy the code

UserService.java

Here to simulate a service, mainly to simulate the operation of the database.

@Service
public class UserService {

    @Autowired
    private Database database;

    public UserEntity getUserByUsername(String username) {
        returndatabase.getDatabase().get(username); }}Copy the code

JwtUtil.java

I write a tool class, mainly responsible for JWT signature and authentication.

public class JwtUtil {

    // The expiration time is 5 minutes
    private final static long EXPIRE_TIME = 5 * 60 * 1000;

    /** * Generate signature, expire after 5min *@paramUsername indicates the username *@paramSecret Specifies the user password *@returnEncrypted token */
    public static String sign(String username, String secret) {
        Date expireDate = new Date(System.currentTimeMillis() + EXPIRE_TIME);
        try {
            Algorithm algorithm = Algorithm.HMAC256(secret);
            return JWT.create()
                    .withClaim("username", username)
                    .withExpiresAt(expireDate)
                    .sign(algorithm);
        } catch (Exception e) {
            return null; }}/** * Verify the token is correct *@paramToken key *@paramSecret Specifies the user password *@returnCorrect */
    public static boolean verify(String token, String username, String secret) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(secret);
            JWTVerifier verifier = JWT.require(algorithm)
                    .withClaim("username", username)
                    .build();
            DecodedJWT jwt = verifier.verify(token);
            return true;
        } catch (Exception e) {
            return false; }}/** * Obtain the information in the token without secret decrypting *@returnUser name */ contained in the token
    public static String getUsername(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("username").asString();
        } catch (JWTDecodeException e) {
            return null; }}}Copy the code

Spring Security reform

For login, we use the custom JwtAuthenticationFilter to log in.

To request authentication, we use the custom JwtAuthorizationFilter.

You might think the two words look a little bit alike, 😜.

UserDetailsServiceImpl.java

We first implement the official UserDetailsService interface, which is responsible for an operation to fetch data from the database.

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity userEntity = userService.getUserByUsername(username);
        if (userEntity == null) {
            throw new UsernameNotFoundException("This username didn't exist.");
        }
        return newUser(userEntity.getUsername(), userEntity.getPassword(), userEntity.getRole()); }}Copy the code

After the sequence, we also need to modify the cache, otherwise each request from the database to take a data authentication, the database pressure is too big.

JwtAuthenticationFilter.java

The main processing log filter operation, we inherited UsernamePasswordAuthenticationFilter, it would greatly simplify our work.

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    /* The filter must set the AuthenticationManager, so here we write it like this. The AuthenticationManager is passed in from the Security configuration
    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        / * run the superclass UsernamePasswordAuthenticationFilter constructor, to set up this filter specified method for POST [\ login] * /
        super(a); setAuthenticationManager(authenticationManager); }@Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        // Take the username and password fields from the requested POST and log in
        String username = request.getParameter("username");
        String password = request.getParameter("password");
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
        // Set some customer IP information. If you want to use it later, you can use it, although it is not useful
        setDetails(request, token);
        // Submit the authentication to the AuthenticationManager
        return getAuthenticationManager().authenticate(token);
    }

    /* If the authentication succeeds, we set the encrypted token */ to return
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        handleResponse(request, response, authResult, null);
    }

    /* If authentication fails, we return incorrect user name or password */
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        handleResponse(request, response, null, failed);
    }

    private void handleResponse(HttpServletRequest request, HttpServletResponse response, Authentication authResult, AuthenticationException failed) throws IOException, ServletException {
        ObjectMapper mapper = new ObjectMapper();
        ResponseEntity responseEntity = new ResponseEntity();
        response.setHeader("Content-Type"."application/json; charset=UTF-8");
        if(authResult ! =null) {
            // Process the login success request
            User user = (User) authResult.getPrincipal();
            String token = JwtUtil.sign(user.getUsername(), user.getPassword());
            responseEntity.setStatus(HttpStatus.OK.value());
            responseEntity.setMsg("Login successful");
            responseEntity.setData("Bearer " + token);
            response.setStatus(HttpStatus.OK.value());
            response.getWriter().write(mapper.writeValueAsString(responseEntity));
        } else {
            // Processing failed login requests
            responseEntity.setStatus(HttpStatus.BAD_REQUEST.value());
            responseEntity.setMsg("Incorrect user name or password");
            responseEntity.setData(null); response.setStatus(HttpStatus.BAD_REQUEST.value()); response.getWriter().write(mapper.writeValueAsString(responseEntity)); }}}Copy the code

Private void handleResponse(); private void handleResponse(); private void handleResponse();

JwtAuthorizationFilter.java

Each request to the filter processing, we choose to inherit BasicAuthenticationFilter, considering the Basic authentication and JWT is like, you chose it.

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private UserDetailsService userDetailsService;

    // Will be passed from the Spring Security configuration file
    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserDetailsService userDetailsService) {
        super(authenticationManager);
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        // Determine whether there is a token and perform authentication
        Authentication token = getAuthentication(request);
        if (token == null) {
            chain.doFilter(request, response);
            return;
        }
        // Authentication succeeded
        SecurityContextHolder.getContext().setAuthentication(token);
        chain.doFilter(request, response);
    }

    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header == null || ! header.startsWith("Bearer ")) {
            return null;
        }

        String token = header.split("") [1];
        String username = JwtUtil.getUsername(token);
        UserDetails userDetails = null;
        try {
            userDetails = userDetailsService.loadUserByUsername(username);
        } catch (UsernameNotFoundException e) {
            return null;
        }
        if (! JwtUtil.verify(token, username, userDetails.getPassword())) {
            return null;
        }
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); }}Copy the code

SecurityConfiguration.java

Here we configure Security and implement caching. Cache the official we use ready-made CachingUserDetailsService, but the downside is that it has no public methods, we can’t normal instantiation, saving the need curve, the following code has detailed instructions.

/ / open the Security
@EnableWebSecurity
// Enable annotation configuration support
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsServiceImpl userDetailsServiceImpl;

    // Spring Boot's CacheManager, where we use JCache
    @Autowired
    private CacheManager cacheManager;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // Enable cross-domain
        http.cors()
                .and()
                // CSRF is enabled by default. We use the token. This is not necessary
                .csrf().disable()
                .authorizeRequests()
                // All requests are approved by default, but we need to add security annotations to the methods that require permissions, which is much more flexible than the write-dead configuration
                .anyRequest().permitAll()
                .and()
                // Add two filters you wrote yourself
                .addFilter(new JwtAuthenticationFilter(authenticationManager()))
                .addFilter(new JwtAuthorizationFilter(authenticationManager(), cachingUserDetailsService(userDetailsServiceImpl)))
                // The front and back ends are STATELESS, so session uses this policy
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

    // Configure AuthenticationManager and implement caching
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // To write their own UserDetailsServiceImpl further packaging, caching
        CachingUserDetailsService cachingUserDetailsService = cachingUserDetailsService(userDetailsServiceImpl);
        // jwt-cache We have the declaration in the ehcache.xml configuration file
        UserCache userCache = new SpringCacheBasedUserCache(cacheManager.getCache("jwt-cache"));
        cachingUserDetailsService.setUserCache(userCache);
        /* security By default, the password will be erased after authentication, but in this case, we use the user's password as the generation key of JWT. If the user's password is erased, the user's password will not be obtained when signing JWT. Therefore, automatic password erasure is disabled here. * /
        auth.eraseCredentials(false);
        auth.userDetailsService(cachingUserDetailsService);
    }

    @Bean
    public PasswordEncoder passwordEncoder(a) {
        return new BCryptPasswordEncoder();
    }

    / * here we implement caching, we use the ready-made CachingUserDetailsService official, but the class constructor is not public, we can't normal instantiation, so here are curve for national salvation. * /
    private CachingUserDetailsService cachingUserDetailsService(UserDetailsServiceImpl delegate) {

        Constructor<CachingUserDetailsService> ctor = null;
        try {
            ctor = CachingUserDetailsService.class.getDeclaredConstructor(UserDetailsService.class);
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        }
        Assert.notNull(ctor, "CachingUserDetailsService constructor is null");
        ctor.setAccessible(true);
        returnBeanUtils.instantiateClass(ctor, delegate); }}Copy the code

Ehcache configuration

Ehcache 3 starts with JCache, which is JSR107 standard. Many online tutorials are based on Ehcache 2, so you may encounter a lot of pitfalls in the online tutorials.

JSR107: EMM. JSR107 is a caching standard. As long as each framework complies with this standard, the reality is unified. Basically, I can change the underlying cache system without having to change the system code.

Create the ehcache.xml file in the resources directory:

<ehcache:config
        xmlns:ehcache="http://www.ehcache.org/v3"
        xmlns:jcache="http://www.ehcache.org/v3/jsr107">

    <ehcache:cache alias="jwt-cache">
        <! We use the user name as the key for the cache, so we use String -->
        <ehcache:key-type>java.lang.String</ehcache:key-type>
        <ehcache:value-type>org.springframework.security.core.userdetails.User</ehcache:value-type>
        <ehcache:expiry>
            <ehcache:ttl unit="days">1</ehcache:ttl>
        </ehcache:expiry>
        <! -- Number of cached entities -->
        <ehcache:heap unit="entries">2000</ehcache:heap>
    </ehcache:cache>

</ehcache:config>
Copy the code

Enable caching support in application.properties:

spring.cache.type=jcache
spring.cache.jcache.config=classpath:ehcache.xml
Copy the code

Uniform Global Exception

We need to make the return form of the exception uniform, so that the front end of the call.

We normally use @RestControllerAdvice to unify exceptions, but it only manages exceptions thrown at the Controller level. Exceptions thrown by Security do not reach the Controller and cannot be caught by @RestControllerAdvice, so we will also modify the ErrorController.

@RestController
public class CustomErrorController implements ErrorController {

    @Override
    public String getErrorPath(a) {
        return "/error";
    }

    @RequestMapping("/error")
    public ResponseEntity handleError(HttpServletRequest request, HttpServletResponse response) {
        return new ResponseEntity(response.getStatus(), (String) request.getAttribute("javax.servlet.error.message"), null); }}Copy the code

test

Write a controller. You can also use the @authenticationPrincipal annotation to get user information from my controller.

@RestController
public class MainController {

    // Anyone can access the method to determine whether the user is valid
    @GetMapping("everyone")
    public ResponseEntity everyone(a) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (! (authentication instanceof AnonymousAuthenticationToken)) {
            // Login user
            return new ResponseEntity(HttpStatus.OK.value(), "You are already login", authentication.getPrincipal());
        } else {
            return new ResponseEntity(HttpStatus.OK.value(), "You are anonymous".null); }}@GetMapping("user")
    @PreAuthorize("hasAuthority('ROLE_USER')")
    public ResponseEntity user(@AuthenticationPrincipal UsernamePasswordAuthenticationToken token) {
        return new ResponseEntity(HttpStatus.OK.value(), "You are user", token);
    }

    @GetMapping("admin")
    @IsAdmin
    public ResponseEntity admin(@AuthenticationPrincipal UsernamePasswordAuthenticationToken token) {
        return new ResponseEntity(HttpStatus.OK.value(), "You are admin", token); }}Copy the code

I also used the @isadmin annotation here. The @isadmin annotation is as follows:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasAnyRole('ROLE_ADMIN')")
public @interface IsAdmin {
}
Copy the code

This saves writing @Preauthorize () a long list at a time, and it’s much more intuitive.

FAQ

How to solve the JWT expiration problem?

If the user is about to expire, return a special status code. The front end can access GET /re_authentication and GET a new token with the old token.

How do I revoke a issued token that has not expired?

My personal idea is to put the token generated every time into the cache, and every request will be taken from the cache, if not, the cache will be scrapped.