SpringBoot之Shiro整合JWT

1,679 阅读9分钟

前言

大家好,一直以来我都本着用最通俗的话理解核心的知识点, 我认为所有的难点都离不开 基础知识 的铺垫。目前正在出一个SpringBoot长期系列教程,从入门到进阶, 篇幅会较多~

适合人群

  • 学完Java基础
  • 想通过Java快速构建web应用程序
  • 想学习或了解SpringBoot
  • SpringBoot进阶学习

大佬可以绕过 ~

背景

如果你是一路看过来的,很高兴你能够耐心看完。之前带大家学了Springboot基础部分,对基本的使用有了初步的认识, 接下来的几期内容将会带大家进阶使用,会先讲解基础中间件的使用和一些场景的应用,或许这些技术你听说过,没看过也没关系,我会带大家一步一步的入门,耐心看完你一定会有收获~

情景回顾

上期带大家学习了Shiro中如何进行缓存以及它的Session会话管理,还带大家实现了一个在线用户管理的例子,本期将带大家学习Shiro中如何整合JWT以及跨域处理,本篇是这个系列的终极篇, 同样的,我们集成到Springboot中。

啥是JWT

在实现之前,我们一起来了解一下啥是jwt。首先它的全称是JSON Web Token, 它是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名

使用场景

  • 授权场景 : 这是使用JWT的最常见场景。一旦用户登录,后续每个请求都将包含JWT,允许用户访问该令牌允许的路由、服务和资源。单点登录是现在广泛使用的JWT的一个特性,因为它的开销很小,并且可以轻松地跨域使用。

  • 信息交换 : 对于安全的在各方之间传输信息而言,JSON Web Tokens无疑是一种很好的方式。因为JWT可以被签名,例如,用公钥/私钥对,你可以确定发送人就是它们所说的那个人。另外,由于签名是使用头和有效负载计算的,您还可以验证内容没有被篡改。

工作原理

在认证的时候,当用户用他们的凭证成功登录以后,一个JSON Web Token将会被返回。此后,token就是用户凭证了,你必须非常小心以防止出现安全问题。一般而言,你保存令牌的时候不应该超过你所需要它的时间。

无论何时用户想要访问受保护的路由或者资源的时候,用户代理(通常是浏览器)都应该带上JWT,典型的,通常放在Authorization header中,用Bearer schema

服务器上的受保护的路由将会检查Authorization header中的JWT是否有效,如果有效,则用户可以访问受保护的资源。如果JWT包含足够多的必需的数据,那么就可以减少对某些操作的数据库查询的需要。

如果token是在授权头(Authorization header)中发送的,那么跨源资源共享(CORS)将不会成为问题,因为它不使用cookie。

环境搭建

首先我们要引入相关依赖,在pom.xml中添加如下:

 <!-- jwt -->
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.4.1</version>
</dependency>

添加配置 ShiroConfig

为了不混淆之前的配置,我们新建一个配置,放到authentication包下, 这里直接贴完整例子,没啥好说的,之前都讲过

@Configuration
public class ShiroConfig {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private Integer redisPort;

    private static final Integer expireAt = 1800;

    private static final Integer timeout = 3000;

    @Value("${spring.redis.password}")
    private String redisPassword;

    @Bean
    public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        String prefix = "/api";
        shiroFilterFactoryBean.setLoginUrl(prefix + "/notLogin");
        shiroFilterFactoryBean.setUnauthorizedUrl(prefix + "/notRole");

        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        LinkedHashMap<String, Filter> filters = new LinkedHashMap<>();
        filters.put("jwt", new JwtFilter());
        shiroFilterFactoryBean.setFilters(filters);

        // 所有请求都要经过 jwt过滤器
        filterChainDefinitionMap.put("/**", "jwt");

        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        logger.warn("Shiro jwt 拦截器工厂类注入成功");
        return shiroFilterFactoryBean;
    }

    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }

    /**
     * 注入 securityManager
     */
    @Bean
    public SecurityManager securityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // 设置realm.
        securityManager.setRealm(customRealm());

        // 设置缓存
        securityManager.setCacheManager(cacheManager());

        // 设置会话
        securityManager.setSessionManager(sessionManager());
        return securityManager;
    }

    /**
     * 自定义身份认证 realm;
     * <p>
     * 必须写这个类,并加上 @Bean 注解,目的是注入 CustomRealm,
     * 否则会影响 CustomRealm类 中其他类的依赖注入
     */
    @Bean
    public CustomRealm customRealm() {
        return new CustomRealm();
    }


    /**
     * 加入redis缓存,避免重复从数据库获取数据
     */
    public RedisManager redisManager() {
        RedisManager redisManager = new RedisManager();
        redisManager.setHost(redisHost);
        redisManager.setPort(redisPort);
        redisManager.setPassword(redisPassword);
        redisManager.setExpire(expireAt);
        redisManager.setTimeout(timeout);
        return redisManager;
    }

    public RedisCacheManager cacheManager() {
        RedisCacheManager redisCacheManager = new RedisCacheManager();
        redisCacheManager.setRedisManager(redisManager());
        return redisCacheManager;
    }


    /**
     * session 会话管理
     */
    @Bean
    public RedisSessionDAO sessionDAO() {
        RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
        redisSessionDAO.setRedisManager(redisManager());
        return redisSessionDAO;
    }

    @Bean
    public SimpleCookie sessionIdCookie(){
        SimpleCookie cookie = new SimpleCookie("X-Token");
        cookie.setMaxAge(-1);
        cookie.setPath("/");
        cookie.setHttpOnly(false);
        return cookie;
    }

    @Bean
    public SessionManager sessionManager() {
        DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
        sessionManager.setSessionIdCookie(sessionIdCookie());
        sessionManager.setSessionIdCookieEnabled(true);
        Collection<SessionListener> listeners = new ArrayList<SessionListener>();
        listeners.add(new ShiroSessionListener());
        sessionManager.setSessionListeners(listeners);
        sessionManager.setSessionDAO(sessionDAO());
        return sessionManager;
    }
}

实现JwtFilter过滤器

实际上核心是实现JwtFilter这个过滤器, 下面贴个完整案例给大家参考一下:

public class JwtFilter extends BasicHttpAuthenticationFilter {

    private Logger log = LoggerFactory.getLogger(this.getClass());

    private static final String TOKEN = "Authorization";

    private AntPathMatcher pathMatcher = new AntPathMatcher();
    

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws UnauthorizedException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        
        // 这里大家可以处理白名单逻辑,这里就不实现了 比如 /login 我们需要放行
//        if (match) {
//            return true;
//        }
        
        if (isLoginAttempt(request, response)) {
            return executeLogin(request, response);
        }

        log.error("未传token {}", httpServletRequest.getRequestURI());
        return false;
    }

    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        HttpServletRequest req = (HttpServletRequest) request;
        String token = req.getHeader(TOKEN);
        return token != null;
    }

    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader(TOKEN);
        JwtToken jwtToken = new JwtToken(token);
        try {
            getSubject(request, response).login(jwtToken);
            return true;
        } catch (Exception e) {
            request.setAttribute("fail", e.getMessage());
            log.error("executeLogin {}", e.getMessage());
            return false;
        }
    }

    /**
     * 对跨域提供支持(注意生产)
     */
    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        httpServletResponse.setHeader("Access-control-Allow-Origin", "*");
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
        httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
        if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
            httpServletResponse.setStatus(HttpStatus.OK.value());
            return false;
        }
        return super.preHandle(request, response);
    }
}

这一步可以处理白名单,处理跨域~

实现 JwtToken

下面我们开始实现jwt的逻辑, 首先定义一个实体

public class JwtToken implements AuthenticationToken {

    private static final long serialVersionUID = 1282057025599826155L;

    private String token;

    private String expireAt;

    public JwtToken(String token) {
        this.token = token;
    }

    public JwtToken(String token, String expireAt) {
        this.token = token;
        this.expireAt = expireAt;
    }

    @Override
    public Object getPrincipal() {
        return token;
    }

    @Override
    public Object getCredentials() {
        return token;
    }

    public String getToken() {
        return token;
    }

    public void setToken(String token) {
        this.token = token;
    }

    public String getExpireAt() {
        return expireAt;
    }

    public void setExpireAt(String expireAt) {
        this.expireAt = expireAt;
    }
}

封装jwt工具类

这里直接给大家封装好,直接用,一般写业务的时候,这种常用的工具最好封装起来,也方便别人使用

public class JwtUtil {

    private static Logger log = LoggerFactory.getLogger(JwtUtil.class);

    // 设置过期时间
    private static final long EXPIRE_TIME = 1000 * 72 * 36;

    // 设置秘钥 (这里推荐大家可以写入 yml配置文件里)
    private static final String Secret = "28ca017de15a57e206f0";

    /**
     * 校验 token是否正确
     *
     * @param token  密钥
     * @return 是否正确
     */
    public static boolean verify(String token, User user) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(Secret);
            JWTVerifier verifier = JWT.require(algorithm)
                    .withClaim("userId", user.getId())
                    .withClaim("roleId", user.getRole())
                    .build();
            verifier.verify(token);
            log.info("token is valid");
            return true;
        } catch (Exception e) {
            log.error("token is invalid{}", e.getMessage());
            return false;
        }
    }

    /**
     * 从 token中获取用户id
     *
     * @return token中包含的用户id
     */
    public static String getUserId(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("userId").asString();
        } catch (JWTDecodeException e) {
            log.error("error:{}", e.getMessage());
            return null;
        }
    }

    /**
     * 从 token中获取用户roleId
     *
     * @return token中包含的用户id
     */
    public static Integer getRoleId(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("roleId").asInt();
        } catch (JWTDecodeException e) {
            log.error("error:{}", e.getMessage());
            return null;
        }
    }

    /**
     * 生成 token
     *
     * @param user
     * @return token
     */
    public static String sign(User user) {
        try {
            Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
            // 这里可以加入秘钥
            Algorithm algorithm = Algorithm.HMAC256(Secret);

            // 这里可以存放于jwt中的内容信息,最后可以通过解密拿到
            return JWT.create()
                    .withClaim("userId", user.getId())
                    .withClaim("roleId", user.getRole())
                    .withExpiresAt(date)
                    .sign(algorithm);
        } catch (Exception e) {
            log.error("error:{}", e);
            return null;
        }
    }
}

相关注释已经写在上面了~

实现验证逻辑

我们知道Shiro的验证逻辑部分在于我们自己实现的CustomRealm, 所以下面我们来实现一下它:

public class CustomRealm extends AuthorizingRealm {

    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }

    /**
     * 授权模块,获取用户角色和权限
     * @param token token
     * @return AuthorizationInfo 权限信息
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection token) {
        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
        String userId = JwtUtil.getUserId(token.toString());
        if(userId == null) {
            return simpleAuthorizationInfo;
        }

        String userRole = UserMock.getRoleById(userId);
        Set<String> role = new HashSet<>();
        role.add(userRole);
        simpleAuthorizationInfo.setRoles(role);
        simpleAuthorizationInfo.setStringPermissions(role);
        return simpleAuthorizationInfo;
    }

    /**
     * 用户认证
     *
     * @param authenticationToken 身份认证 token
     * @return AuthenticationInfo 身份认证信息
     * @throws AuthenticationException 认证相关异常
     */
    @Override
    protected SimpleAuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        String token = (String) authenticationToken.getCredentials();
        String userId = JwtUtil.getUserId(token);
        if (StringUtils.isBlank(userId)) {
            throw new AuthenticationException("验证失败");
        }

        String userRole = UserMock.getRoleById(userId);
        User userBean = new User();
        userBean.setUserId(userId);
        userBean.setRole(userRole);
        if (!JwtUtil.verify(token, userBean)) {
            throw new AuthenticationException("token失效");
        }
        return new SimpleAuthenticationInfo(token, token, "shiroJwtRealm");
    }
}

这部分逻辑大家可以根据具体功能自有发挥,方法就那么几个~

如何去验证 & 注意事项

这里就不带大家一一去测试了,留给大家自己去思考。流程给大家简要说一下,首先是用户通过login,验证成功后,你需要调用jwtUtil去签发token给前端,前端拿到后,放入请求头中,这样每次请求都会去携带这个token,服务端会从请求头中获取这个token,然后进行验证,验证通过后继续执行,失败就会返回失败信息,这个之前教过大家如何去捕获。这里需要强调的是token的刷新机制,因为如果让用户频繁的跳登录这样体验是很不友好的,所以过期时间设置刷新机制这个大家要根据自身业务来定,如何去刷新,这个需要跟前端同学协商好~

结束语

本期内容就到这里结束了,总结一下,本节主要讲了Shiro如何进行整合jwt,大家可以举一反三,做一些小功能尝试尝试

下期预告

其实学到这里,我们去做一些业务基本上没啥太大问题了,有的时候我们写完代码需要我们自己去打包并部署到服务器,一般情况下有专门的同学会做这件事。但是,这里还是教大家一下如何去部署服务,这个技能对于服务端的同学还是要必会的,说不定哪天就是你发的呢,下期就带大家学习如何线上部署,将涉及到nginx部署教程,以及jar包的部署与服务启动, 还将会带大家如何搭建测试环境和线上环境。欢迎加群一起学习交流 ~

往期内容

项目源码(源码已更新 欢迎star⭐️)