JWT login practice of front and back end separation

About JWT, SongGe actually wrote relevant tutorials before. Recently, a small partner sent a message on wechat and asked SongGe if he could analyze the JWT login process in the project, because many people now use it as a scaffold to develop commercial projects. I took the time to read it at the weekend. I felt it was quite simple, so I shared the whole article with you about how to play JWT login here.

I will analyze this article from the following aspects:

  1. Verification code analysis
  2. Login process analysis
  3. Authentication verification process analysis

Well, no more nonsense. Let's start the whole thing!

1. Preparation

If there are both monomer version and microservice version of this project, I'll take the monomer version as an example to share with my partners. When I'm free, I can also write a whole article with you.

For the single version of the project, you can clone from Gitee. Clone address:

  • https://gitee.com/y_project/RuoYi-Vue.git

First of all, you have to start according to this project. This is the most basic requirement. I don't think there's anything to say. Moreover, it is easy to run, and the database can be completed. You can match the database user name, password and redis related information in the project configuration file.

I'm sure everyone can handle this by themselves, so I won't say more.

2. Verification code

After the project is started successfully, there is a verification code on the startup page. Press F12 in the browser. We can easily see that the verification code comes from the / captchaImage interface, and that the verification code image is returned to the front end in the form of Base64 string.

We find the verification code interface of the server in Src / main / Java / COM / ruoyi / Web / controller / common / captchacontroller In Java:

@GetMapping("/captchaImage")
public AjaxResult getCode(HttpServletResponse response) throws IOException {
    AjaxResult ajax = AjaxResult.success();
    boolean captchaOnOff = configService.selectCaptchaOnOff();
    ajax.put("captchaOnOff", captchaOnOff);
    if (!captchaOnOff) {
        return ajax;
    }
    //Save verification code information
    String uuid = IdUtils.simpleUUID();
    String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;
    String capStr = null, code = null;
    BufferedImage image = null;
    //Generate verification code
    String captchaType = RuoYiConfig.getCaptchaType();
    if ("math".equals(captchaType)) {
        String capText = captchaProducerMath.createText();
        capStr = capText.substring(0, capText.lastIndexOf("@"));
        code = capText.substring(capText.lastIndexOf("@") + 1);
        image = captchaProducerMath.createImage(capStr);
    } else if ("char".equals(captchaType)) {
        capStr = code = captchaProducer.createText();
        image = captchaProducer.createImage(capStr);
    }
    redisCache.setCacheObject(verifyKey, code, Constants.CAPTCHA_EXPIRATION, TimeUnit.MINUTES);
    //Conversion flow information write out
    FastByteArrayOutputStream os = new FastByteArrayOutputStream();
    try {
        ImageIO.write(image, "jpg", os);
    } catch (IOException e) {
        return AjaxResult.error(e.getMessage());
    }
    ajax.put("uuid", uuid);
    ajax.put("img", Base64.encode(os.toByteArray()));
    return ajax;
}

The general logic of the verification code is as follows:

  1. First call configservice selectCaptchaOnOff() method to the database sys_ The config table queries whether the verification code is on or off. If the verification code is off, there is no need to return the picture of the verification code, and the verification code will not be displayed on the front end in the future. This system configuration will be automatically saved to Redis when the project is started. Therefore, when the selectCaptchaOnOff method is called, it does not query in the database every time.
  2. Next, we are ready to generate the verification code. Here, we use the open source project kaptcha on GitHub( https://github.com/penggle/kaptcha )There are four operation modes to generate the verification code, mat and char, and mat gives the verification code on the picture; The char verification code picture shows the common string. Which one to use is through ruoyiconfig Getcaptchatype () is configured. The value of this configuration is from application Read from yaml, that is, modify application Ruoyi in yaml Captchatype attribute value, and the form of verification code can be modified.
  3. Next, save the generated verification code text into redis and set an expiration time. The default expiration time is two minutes, which means that after a verification code is generated, if the user has not logged in within two minutes, the verification code will expire. Here, you should pay attention to the key in redis. This key is generated by a fixed string plus uuid, so as to ensure that the authentication codes of each user will not conflict.
  4. Finally, the generated verification code image is made into a Base64 string and returned to the front end.

This is the generation process of verification code.

3. Login configuration

Login related configuration is in Src / main / Java / COM / ruoyi / framework / config / securityconfig In the Java class, let's take a look at:

@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * User defined authentication logic
     */
    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * Authentication failure handling class
     */
    @Autowired
    private AuthenticationEntryPointImpl unauthorizedHandler;

    /**
     * Exit processing class
     */
    @Autowired
    private LogoutSuccessHandlerImpl logoutSuccessHandler;

    /**
     * token Authentication filter
     */
    @Autowired
    private JwtAuthenticationTokenFilter authenticationTokenFilter;

    /**
     * Cross domain filter
     */
    @Autowired
    private CorsFilter corsFilter;

    /**
     * Solve the problem that authentication manager cannot be injected directly
     *
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                //CSRF is disabled because session is not used
                .csrf().disable()
                //Authentication failure handling class
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                //Based on token, so session is not required
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                //Filter request
                .authorizeRequests()
                //For login , register , verification code captchaImage , anonymous access is allowed
                .antMatchers("/login", "/register", "/captchaImage").anonymous()
                .antMatchers(
                        HttpMethod.GET,
                        "/",
                        "/*.html",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js",
                        "/profile/**"
                ).permitAll()
                .antMatchers("/swagger-ui.html").anonymous()
                .antMatchers("/swagger-resources/**").anonymous()
                .antMatchers("/webjars/**").anonymous()
                .antMatchers("/*/api-docs").anonymous()
                .antMatchers("/druid/**").anonymous()
                // All requests except the above require authentication
                .anyRequest().authenticated()
                .and()
                .headers().frameOptions().disable();
        httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
        // Add JWT} filter
        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // Add CORS} filter
        httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
        httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
    }

    /**
     * Implementation of strong hash encryption
     */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * Identity authentication interface
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
    }
}

It's all the conventional configuration of Spring Security. There's nothing to say. Brother song took a look at it. I've talked to you about all the knowledge points involved here in the Spring Security Series tutorials, so I won't repeat them here.

Here are some links to old articles to help you understand the configuration here:

  1. Brother song takes you to Spring Security. Don't ask how to decrypt the password
  2. Teach you how to customize the form login in Spring Security
  3. Spring Security separates the front end from the back end. Let's not jump the page! All JSON interactions
  4. Spring Security+Spring Data Jpa join forces to make security management simpler!
  5. Multiple encryption schemes of Spring Security coexist, which is a sharp weapon for the integration of old and dilapidated systems!

If you are not familiar with the usage of Spring Security, you can reply ss to the Spring Security tutorial link in the official account.

4. Login interface

The login interface here is on COM ruoyi. web. controller. system. Syslogincontroller#login method, as follows:

@PostMapping("/login")
public AjaxResult login(@RequestBody LoginBody loginBody) {
    AjaxResult ajax = AjaxResult.success();
    //Generate token
    String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
            loginBody.getUuid());
    ajax.put(Constants.TOKEN, token);
    return ajax;
}

Then we can see that the core logic of login is in the loginService#login method. Let's look at it together:

public String login(String username, String password, String code, String uuid) {
    boolean captchaOnOff = configService.selectCaptchaOnOff();
    //Verification code switch
    if (captchaOnOff) {
        validateCaptcha(username, code, uuid);
    }
    //User authentication
    Authentication authentication = null;
    try {
        //This method will call userdetailsserviceimpl loadUserByUsername
        authentication = authenticationManager
                .authenticate(new UsernamePasswordAuthenticationToken(username, password));
    } catch (Exception e) {
        if (e instanceof BadCredentialsException) {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
            throw new UserPasswordNotMatchException();
        } else {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
            throw new ServiceException(e.getMessage());
        }
    }
    AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
    LoginUser loginUser = (LoginUser) authentication.getPrincipal();
    recordLoginInfo(loginUser.getUserId());
    //Generate token
    return tokenService.createToken(loginUser);
}
  • First, check the verification code. There's nothing to say about this logic. Just take the data from Redis for comparison.
  • Call the authenticationManager#authenticate method to manually complete the user verification. If the login is successful, it will be executed normally. If the login fails, an exception will be thrown.
  • Next, there is an asynchronous task to write the user's login log to the database.
  • Then it also updated the user table (more detailed login IP, time and other information).
  • Finally, create a JWT token.

Let's take a look at the token creation process:

public String createToken(LoginUser loginUser) {
    String token = IdUtils.fastUUID();
    loginUser.setToken(token);
    setUserAgent(loginUser);
    refreshToken(loginUser);
    Map<String, Object> claims = new HashMap<>();
    claims.put(Constants.LOGIN_USER_KEY, token);
    return createToken(claims);
}
private String createToken(Map<String, Object> claims) {
    String token = Jwts.builder()
            .setClaims(claims)
            .signWith(SignatureAlgorithm.HS512, secret).compact();
    return token;
}
public void refreshToken(LoginUser loginUser) {
    loginUser.setLoginTime(System.currentTimeMillis());
    loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
    //Cache loginUser based on uuid
    String userKey = getTokenKey(loginUser.getToken());
    redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}

As like as two peas brother, brother basically didn't say anything about the two methods. Before the song's article, we talked about JWT (the background Reply of 666). The process of JWT's generation is basically the same as that of song. You can see from the code generated by JWT that JWT is generated through the claims variable, which has only one key value pair, that is, a uuid string. In the process of generating a token, there is a refreshToken. In this method, the current uuid will be used as the key, the logged in user information will be stored in redis, and an expiration time will be set for this information. The default expiration time is 30 minutes.

Finally, the token here will be written back to the front end. After the front end login is successful, the user can get the token.

In the future, the front end will bring this token with it every time it requests. Of course, this is the front end's business, and we don't care.

When talking about JWT with you in the previous article, brother Song said that this is a typical stateless login scheme, but stateless login cannot solve the problems of user logout. Therefore, we saw in ruoyi's project that although he uses JWT, it is actually a kind of stateful login in essence, but the login information does not exist in session, but in redis, In the past, the jsessionid automatically passed by the browser is now changed to the user manually passing the token, which is such a process.

5. Certification

When a user logs in successfully, he / she should carry a token with him / her every time he / she sends a request. Of course, this is a front-end issue and we won't discuss it here.

Let's see how subsequent requests verify whether there is a login.

The relevant code is in Src / main / Java / COM / ruoyi / framework / security / filter / jwtauthenticationtokenfilter java:

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    @Autowired
    private TokenService tokenService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        LoginUser loginUser = tokenService.getLoginUser(request);
        if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())) {
            tokenService.verifyToken(loginUser);
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        chain.doFilter(request, response);
    }
}
  1. tokenService.getLoginUser takes the token from the request header of the login request, and then obtains the LoginUser from redis according to the token.
  2. If we get LoginUser from redis and there is no user authentication information in the current securitycontextholder, we will authenticate. As you know, in Spring Security, the user's authentication information is actually stored in SecurityContextHolder. If you don't understand, there is a tutorial in the official account back to ss. In Spring Security, I just want to get user login information from the sub thread. What should I do?).
  3. Call tokenservice The verifytoken method is used to verify the token. The verification here is to verify whether the token has expired. If the loginUser object can be obtained earlier, it means that the token is legal, so there is no need to verify the legitimacy here. At the same time, after verifying the validity period of the token, refresh the validity period of the token in redis. In the past, when using session, the server can renew automatically, but now it can only be manually.
  4. Finally, save the user information with successful authentication into the SecurityContextHolder. The information will be read naturally if verification is required later.

If there is no token in the current request, the loginUser obtained will be null. This filter continues to go down. In the final filter chain of Spring Security, it will automatically detect that the user is not logged in, and then throw an exception.

Well, it's probably such a process. This filter will be added to the filter chain of Spring Security in the configuration of section 2.

6. Summary

Well, today, I simply sort out with you. If you log in on this project, I have not done much to use the details of Spring Security. If you are not proficient in the usage of Spring Security, you can see the Spring Security tutorial before brother song, and the official account has a link back to ss.

Posted by MrXander on Mon, 18 Apr 2022 13:10:44 +0930