SpringBoot项目实战(009)Spring Security(三)JWT+Redis+RefreshToken

SpringSecurity && JWT && Redis

上一章SpringBoot项目实战(008)Spring Security(二)JWT中,实现了Spring Security的JWT认证。但还是存在几个问题:

  1. 每次token访问,都要去数据库访问,效率低下。
  2. token有效期参与了token的生成,无法延长,除非重新生成。
  3. 登出时没有清理token

所以本章打算:

  1. 使用redis作为缓存。
  2. 在登录生成Token后,将token和username的对照关系保存在redis中,同时在redis中设置失效时间。
  3. 将部分高访问频率的数据库内的用户权限信息保存在redis中,提高效率。
  4. 最后在登出时,清理redis中的token。

redis集成

redis方面可参考的资料:

配置文件

pom文件

增加依赖即可:

<!-- redis starter -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- lettuce 池化 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    <version>2.5.0</version>
</dependency>
<!-- jackson json 优化缓存对象序列化 -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.9.6</version>
</dependency>

application-dev.yml

增加redis链接,除了host、port,其他沿用即可。

spring:
    redis:
        # 数据库索引,默认0
        database: 0
        # redis实例IP 端口 密码
        host: 172.17.0.2
        port: 6379
        password: 123456
        timeout: 3000
        lettuce:
            pool:
                max-active: 8
                max-wait: -1
                max-idle: 8
                min-idle: 0
            shutdown-timeout: 3000

LettuceRedisConfig

处理一些redis连接的问题,这里使用StringRedisSerializer,可以防止Redis中出现乱码。

@Configuration
public class LettuceRedisConfig {
    
    
    @Bean
    public RedisTemplate<String, Object> oRedisTemplate(LettuceConnectionFactory connectionFactory) {
    
    
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        redisTemplate.setKeySerializer(stringRedisSerializer);
        redisTemplate.setValueSerializer(stringRedisSerializer);
        redisTemplate.setHashKeySerializer(stringRedisSerializer);
        redisTemplate.setHashValueSerializer(stringRedisSerializer);
        return redisTemplate;
    }
}

RedisUtil

新增一个RedisUtil,封装RedisTemplate的一些操作。

package com.it_laowu.springbootstudy.springbootstudydemo.core.utils;
......
@Component
public final class RedisUtil {
    
    

    @Autowired
    private RedisTemplate<String, Object> oRedisTemplate;
    // ==========common=========
    /**
     * 指定缓存失效时间
     * @param key 键
     * @param time 时间(秒)
     * @return
     */
    public boolean expire(final String key, final long time) {
    
    
        try {
    
    
            if (time > 0) {
    
    
                oRedisTemplate.expire(key, time, TimeUnit.SECONDS);
            }
            return true;
        } catch (final Exception e) {
    
    
            e.printStackTrace();
            return false;
        }
    }
    // =========String==========
    /**
     * 普通缓存获取
     * @param key 键
     * @return 值
     */
    public Object get(final String key) {
    
    
        return key == null ? null : oRedisTemplate.opsForValue().get(key);
    }

    /**
     * 普通缓存放入
     * @param key   键
     * @param value 值
     * @return true成功 false失败
     */
    public boolean set(final String key, final Object value) {
    
    
        try {
    
    
            oRedisTemplate.opsForValue().set(key, value);
            return true;
        } catch (final Exception e) {
    
    
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 普通缓存放入并设置时间
     * @param key   键
     * @param value 值
     * @param time  时间(秒) time要大于0 如果time小于等于0 将设置无限期
     * @return true成功 false 失败
     */
    public boolean set(final String key, final Object value, final long time) {
    
    
        try {
    
    
            if (time > 0) {
    
    
                oRedisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
            } else {
    
    
                set(key, value);
            }
            return true;
        } catch (final Exception e) {
    
    
            e.printStackTrace();
            return false;
        }
    }
......

controller

简单修改一下,以便校验Redis的使用有没有问题:

@RestController
@RequestMapping(value = "/admin")
public class AdminController {
    
    
    @RequestMapping(value="/keys/{key}",method=RequestMethod.GET)
    public String redisGet(@PathVariable(value = "key") String key) {
    
    
        Object val = redisUtil.get(key);
        return (String) val;
    }
    @RequestMapping(value="/keys/{key}",method=RequestMethod.POST)
    public Boolean redisSet(@PathVariable(value = "key") String key,String val) {
    
    
        return redisUtil.set(key, val);
    }
}

redis验证

在postman中,设置一个string类型的key:

redis setkey

在redis-cli中,client list查看链接的客户端,其中一个即redis-clidb=1,所以查不到对应的key,使用select 0命令切换database,然后就可以查看到key了。

redis check

使用postman,读取redis中的key:

redis getkey

token保存到redis

JwtProperties

原本的数据库保存token可以取消,同时我们需要修改JwtProperties和yml文件,增加一些参数。

jwt:
    secret: "this is a secret"
    token-head: "Bearer "
    header-name: "Authorization"
    access-expiration: 3600
    roles-expiration: 300
    refresh-expiration: 604800
@Component
@ConfigurationProperties(prefix="jwt")
@Data
public class JwtProperties {
    
    
    private String secret="this is a secret";
    private String tokenHead = "Bearer ";
    private String headerName ="Authorization";
    private Integer accessExpiration = 60 * 60;
    private Integer rolesExpiration =60*5;
    private Integer refreshExpiration =60 * 60 * 24 * 7;
}

JwtTokenUtil

JwtTokenUtil关于token的种类及生成方式需要大改一下,分为三种token:access、roles、refresh。

  • access:访问token
  • roles:鉴权token
  • refresh:更新token的token

部分代码:

    // 根据用户信息生成token
    public Map<String, String> generateToken(UserDetails userDetails) {
    
    
        Map<String, String> rst = new HashMap<String, String>();
        // 访问token
        Map<String, Object> claims = new HashMap<>();
        claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
        claims.put(CLAIM_KEY_CREATED, new Date());
        rst.put(getAccessTokenKey(), generateToken(claims, jwtProperties.getAccessExpiration()));

        rst.put(getRefreshTokenKey(), generateToken(claims, jwtProperties.getRefreshExpiration()));

        claims.put(CLAIM_KEY_ROLES, userDetails.getAuthorities());
        rst.put(getRoleTokenKey(), generateToken(claims, jwtProperties.getRolesExpiration()));

        return rst;
    }

    // 根据权限生成JWT的token
    private String generateToken(Map<String, Object> claims, Integer seconds) {
    
    
        return Jwts.builder().setClaims(claims).setExpiration(generateExpirationDate(seconds))
                .signWith(SignatureAlgorithm.HS512, jwtProperties.getSecret()).compact();
    }
    /**
     * 生成token的过期时间
     */
    private Date generateExpirationDate(Integer seconds) {
    
    
        return new Date(System.currentTimeMillis() + (int) (seconds * 1000));
    }
    //根据token获得roles
    public List<GrantedAuthority> getRolesFromToken(String token) {
    
    
        Claims claims = getClaimsFromToken(token);
        List<HashMap> roles =  (List<HashMap>) claims.get(CLAIM_KEY_ROLES);
        List<GrantedAuthority> authority = roles.stream().map(i->new SimpleGrantedAuthority((String) i.get("authority"))).collect(Collectors.toList());
        return authority;
    }
    //几个key及生成方式
    public String getAccessTokenKey(){
    
    
        return "accesstoken";
    }
    public String getAccessTokenKey(String username){
    
    
        return username+":accesstoken";
    }
    public String getRefreshTokenKey(){
    
    
        return "refreshtoken";
    }
    public String getRefreshTokenKey(String username){
    
    
        return username+":refreshtoken";
    }
    public String getRoleTokenKey(){
    
    
        return "roletoken";
    }
    public String getRoleTokenKey(String username){
    
    
        return username+":roletoken";
    }

token存入redis

登录时将三个token存入Redis,返回两个token给客户端(roles没必要返回)。

//MyAuthenticationProvider
package com.it_laowu.springbootstudy.springbootstudydemo.core.auth;
...
@Component
public class MyAuthenticationProvider implements AuthenticationProvider {
    
    
...
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    
    
...
        logger.info(String.format("用户%s登录成功", username));
        // 生成新token
        Map<String,String> tokens = jwtTokenUtil.generateToken(user);
        String accesstoken = tokens.get(jwtTokenUtil.getAccessTokenKey());
        String refreshtoken = tokens.get(jwtTokenUtil.getRefreshTokenKey());
        String rolestoken = tokens.get(jwtTokenUtil.getRoleTokenKey());
        // 保存到 redis
        redisUtil.set(jwtTokenUtil.getAccessTokenKey(username),accesstoken);
        redisUtil.expire(jwtTokenUtil.getAccessTokenKey(username), jwtProperties.getAccessExpiration());
        redisUtil.set(jwtTokenUtil.getRefreshTokenKey(username),refreshtoken);
        redisUtil.expire(jwtTokenUtil.getRefreshTokenKey(username),jwtProperties.getRefreshExpiration());
        redisUtil.set(jwtTokenUtil.getRoleTokenKey(username), rolestoken);
        redisUtil.expire(jwtTokenUtil.getRoleTokenKey(username), jwtProperties.getRolesExpiration());
        // 绑定到当前用户
        user.setAccessToken(accesstoken);
        user.setRefreshToken(refreshtoken);
        return new UsernamePasswordAuthenticationToken(user, password, user.getAuthorities());
    }
...
}

同时调整一下MyAuthenticationSuccessHandler,将两个token都返回。

    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
    
    
        //登录成功返回
        String accessToken = ((MyUserDetails) authentication.getPrincipal()).getAccessToken();
        String refreshToken = ((MyUserDetails) authentication.getPrincipal()).getRefreshToken();
        ResultBody resultBody = new ResultBody("200", "登录成功:\n"+accessToken+"\nrefreshtoken:\n"+refreshToken);
        //设置返回请求头
        response.setContentType("application/json;charset=utf-8");
        //写出流
        PrintWriter out = response.getWriter();
        ObjectMapper mapper = new ObjectMapper();  
        out.write(mapper.writeValueAsString(resultBody));
        out.flush();
        out.close();
    }

使用accesstoken

只需要处理JwtokenAuthenticationFilter文件,通过redis而不是数据库验证token的有效性,以及获得roles。
如果roles存在,则利用,如果roles不存在,则从数据库读取,并将他缓存。

package com.it_laowu.springbootstudy.springbootstudydemo.core.auth;
...
@Component
public class JwtokenAuthenticationFilter extends OncePerRequestFilter {
    
    
...
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
    
    
        // 取出auth
        String authHeader = request.getHeader(jwtProperties.getHeaderName());

        if (authHeader != null && authHeader.startsWith(jwtProperties.getTokenHead())) {
    
    
            // tokenBody
            String tokenBody = authHeader.substring(jwtProperties.getTokenHead().length());
            if (tokenBody != null) {
    
    
                String username = jwtTokenUtil.getUserNameFromToken(tokenBody);
                if (username != null) {
    
    
                    String accessToken = (String) redisUtil.get(jwtTokenUtil.getAccessTokenKey(username));
                    if (accessToken.equals(tokenBody)) {
    
    
                        String rolesToken = (String) redisUtil.get(jwtTokenUtil.getRoleTokenKey(username));
                        List<GrantedAuthority> authorities = null;
                        UserDetails userDetails = null;
                        if (rolesToken != null) {
    
    
                            // 缓存内有权限
                            authorities = jwtTokenUtil.getRolesFromToken(rolesToken);
                            userDetails = new MyUserDetails(username, "", "", "", false, authorities);
                        } else {
    
    
                            // 提取数据,并存入缓存
                            userDetails = (MyUserDetails) myUserDetailsService.loadUserByUsername(username);
                            authorities = (List<GrantedAuthority>) userDetails.getAuthorities();
                            //生成三个token,只用一个
                            String newRoleToken = jwtTokenUtil.generateToken(userDetails).get(jwtTokenUtil.getRoleTokenKey());
                            redisUtil.set(jwtTokenUtil.getRoleTokenKey(username),newRoleToken);
                            redisUtil.expire(jwtTokenUtil.getRoleTokenKey(username), jwtProperties.getRolesExpiration());
                        }
                        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                                userDetails, null, authorities);
                        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    }
                }
            }
        }
        filterChain.doFilter(request, response);
    }
}

使用refreshtoken

为了刷新token,我在AdminController中暴露一个服务,根据RefreshToken获得新的AccessToken

返回结构 ResultBody 调整

增加了Data属性。

package com.it_laowu.springbootstudy.springbootstudydemo.core.base;
...
@Data
@Accessors(chain = true)
public class ResultBody<T> {
    
    
    private String code;
    private String message;
    private String detailMessage;
    private T data;

    public ResultBody() {
    
    
    }

    public ResultBody(String code, String message) {
    
    
        this.code = code;
        this.message = message;
    }
    public ResultBody(String code, String message, String detailMessage) {
    
    
        this.code = code;
        this.message = message;
        this.detailMessage = detailMessage;
    }
}

JwtTokenUtil 增加 refreshHeadToken

注意,这里对刷新频率做了控制,你也可以把频率参数放到JwtProperties中。

    public String refreshHeadToken(String refreshtoken,String accesstoken) {
    
    
        if (StrUtil.isEmpty(refreshtoken)) {
    
    
            return null;
        }
        String username = getUserNameFromToken(token);
        if (StrUtil.isEmpty(username)) {
    
    
            return null;
        }
        // 如果token在30分钟之内刚刷新过,返回原token
        if (accesstoken != null && tokenRefreshJustBefore(accesstoken, 30 * 60)) {
    
    
            return "";
        } else {
    
    
            Map<String, Object> accessClaims = new HashMap<>();
            accessClaims.put(CLAIM_KEY_USERNAME,username);
            accessClaims.put(CLAIM_KEY_CREATED, new Date());
            return generateToken(accessClaims, jwtProperties.getAccessExpiration());
        }
    }

AdminController暴露服务

修改AdminController,增加一个服务:

    @RequestMapping(value = "/token/refresh/{token}", method = RequestMethod.GET)
    public ResultBody<String> refreshToken(@PathVariable(value = "token") String token) {
    
    
        ResultBody<String> rst = new ResultBody<String>().setCode("200");
        if (token == null) {
    
    
            return rst.setMessage("令牌不能为空");
        }
        String refreshtoken = token.substring(jwtProperties.getTokenHead().length());
        String username = jwtTokenUtil.getUserNameFromToken(refreshtoken);
        if (username == null) {
    
    
            return rst.setMessage("令牌格式有误");
        }
        String accesstoken = (String) redisUtil.get(jwtTokenUtil.getAccessTokenKey(username));
        String new_accesstoken = jwtTokenUtil.refreshHeadToken(refreshtoken, accesstoken);
        if (new_accesstoken == null) {
    
    
            return rst.setMessage("令牌格式有误");
        }
        if (new_accesstoken == "") {
    
    
            return rst.setMessage("令牌不要频繁刷新");
        }
        redisUtil.set(jwtTokenUtil.getAccessTokenKey(username), new_accesstoken);
        return rst.setData(new_accesstoken);
    }

记得WebSecurityConfig开放访问。

    .antMatchers("/admin/token/**").permitAll()

postman验证

  • 使用用户密码登录成功。

登录成功

  • 查看redis中的key。

redis中的key

  • 使用token访问成功。

token访问成功

  • 清除accesstoken。

清除token

  • 刷新token,用新token访问成功。

刷新token

登出处理

由于客户端长期拥有的仅仅是refreshtoken,所以前端可以根据username,也可以使用refreshtoken登出系统(即清除redis中信息)。

比如我们在admincontroller中加个清除token服务即可:

    @RequestMapping(value = "/token/{token}", method = RequestMethod.DELETE)
    public ResultBody<String> deleteToken(@PathVariable(value = "token") String token) {
    
    
        ResultBody<String> rst = new ResultBody<String>().setCode("200");
        if (token == null) {
    
    
            return rst.setMessage("令牌不能为空");
        }
        String refreshtoken = token.substring(jwtProperties.getTokenHead().length());
        String username = jwtTokenUtil.getUserNameFromToken(refreshtoken);
        if (username == null) {
    
    
            return rst.setMessage("令牌格式有误");
        }
        redisUtil.expire(jwtTokenUtil.getAccessTokenKey(username), 1);
        redisUtil.expire(jwtTokenUtil.getRefreshTokenKey(username), 1);
        redisUtil.expire(jwtTokenUtil.getRoleTokenKey(username), 1);
        return rst;
    }

删除token,登出

猜你喜欢

转载自blog.csdn.net/weixin_36572983/article/details/107025391