美文网首页
springboot+shiro+jwt

springboot+shiro+jwt

作者: _麻辣香锅不要辣 | 来源:发表于2019-03-27 20:59 被阅读0次

    jwt简介

    1.什么是JWT
    JWT(Json Web Token),是一种工具,格式为XXXX.XXXX.XXXX的字符串,JWT以一种安全的方式在用户和服务器之间传递存放在JWT中的不敏感信息。
    2.为什么要用JWT
    设想这样一个场景,在我们登录一个网站之后,再把网页或者浏览器关闭,下一次打开网页的时候可能显示的还是登录的状态,不需要再次进行登录操作,通过JWT就可以实现这样一个用户认证的功能。当然使用Session可以实现这个功能,但是使用Session的同时也会增加服务器的存储压力,而JWT是将存储的压力分布到各个客户端机器上,从而减轻服务器的压力。
    3.JWT长什么样子

    JWT由3个子字符串组成,分别为Header,Payload以及Signature,结合JWT的格式即:Header.Payload.Signature。(Claim是描述Json的信息的一个Json,将Claim转码之后生成Payload)。

    Header

    Header是由以下这个格式的Json通过Base64编码(编码不是加密,是可以通过反编码的方式获取到这个原来的Json,所以JWT中存放的一般是不敏感的信息)生成的字符串,Header中存放的内容是说明编码对象是一个JWT以及使用“SHA-256”的算法进行加密(加密用于生成Signature)

    { 
    "typ":"JWT", 
    "alg":"HS256" 
    } 
    

    Claim

    Claim是一个Json,Claim中存放的内容是JWT自身的标准属性,所有的标准属性都是可选的,可以自行添加,比如:JWT的签发者、JWT的接收者、JWT的持续时间等;同时Claim中也可以存放一些自定义的属性,这个自定义的属性就是在用户认证中用于标明用户身份的一个属性,比如用户存放在数据库中的id,为了安全起见,一般不会将用户名及密码这类敏感的信息存放在Claim中。将Claim通过Base64转码之后生成的一串字符串称作Payload。

    { 
    "iss":"Issuer —— 用于说明该JWT是由谁签发的", 
    "sub":"Subject —— 用于说明该JWT面向的对象", 
    "aud":"Audience —— 用于说明该JWT发送给的用户", 
    "exp":"Expiration Time —— 数字类型,说明该JWT过期的时间", 
    "nbf":"Not Before —— 数字类型,说明在该时间之前JWT不能被接受与处理", 
    "iat":"Issued At —— 数字类型,说明该JWT何时被签发", 
    "jti":"JWT ID —— 说明标明JWT的唯一ID", 
    "user-definde1":"自定义属性举例", 
    "user-definde2":"自定义属性举例" 
    } 
    

    Signature

    Signature是由Header和Payload组合而成,将Header和Claim这两个Json分别使用Base64方式进行编码,生成字符串Header和Payload,然后将Header和Payload以Header.Payload的格式组合在一起形成一个字符串,然后使用上面定义好的加密算法和一个密匙(这个密匙存放在服务器上,用于进行验证)对这个字符串进行加密,形成一个新的字符串,这个字符串就是Signature。
    4.JWT实现认证的原理
    服务器在生成一个JWT之后会将这个JWT会以Authorization : Bearer JWT 键值对的形式存放在cookies里面发送到客户端机器,在客户端再次访问收到JWT保护的资源URL链接的时候,服务器会获取到cookies中存放的JWT信息,首先将Header进行反编码获取到加密的算法,在通过存放在服务器上的密匙对Header.Payload 这个字符串进行加密,比对JWT中的Signature和实际加密出来的结果是否一致,如果一致那么说明该JWT是合法有效的,认证成功,否则认证失败。

    shiro+jwt整合

    导包

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

    JwtToken

    @Data
    public class JwtToken implements AuthenticationToken {
    
        private String token;
    
        public JwtToken(String token) {
            this.token = token;
        }
    
        @Override
        public Object getPrincipal() {
            return null;
        }
    
        @Override
        public Object getCredentials() {
            return null;
        }
    }
    

    JwtUtil

    public class JwtUtil {
        private static final long EXPIRE_TIME = 30 *60*1000;
    
        /**
         * 校验token是否正确
         *
         * @param token  密钥
         * @param secret 用户的密码
         * @return 是否正确
         */
        public static boolean verify(String token, String username, String secret) {
            try {
                //根据密码生成JWT效验器
                Algorithm algorithm = Algorithm.HMAC256(secret);
                JWTVerifier verifier = JWT.require(algorithm)
                        .withClaim("username", username)
                        .build();
                //效验TOKEN
                DecodedJWT jwt = verifier.verify(token);
                return true;
            } catch (Exception exception) {
                return false;
            }
        }
    
        /**
         * 获得token中的信息无需secret解密也能获得
         *
         * @return token中包含的用户名
         */
        public static String getUsername(String token) {
            try {
                DecodedJWT jwt = JWT.decode(token);
                return jwt.getClaim("username").asString();
            } catch (JWTDecodeException e) {
                return null;
            }
        }
    
        /**
         * 生成签名,5min后过期
         *
         * @param username 用户名
         * @param secret   用户的密码
         * @return 加密的token
         */
        public static String sign(String username, String secret) {
            Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
            Algorithm algorithm = Algorithm.HMAC256(secret);
            // 附带username信息
            return JWT.create()
                    .withClaim("username", username)
                    .withExpiresAt(date)
                    .sign(algorithm);
        }
    }
    

    shiro配置

    之前在springboot+shiro中,配置了一个CustomRealm用于用户名密码登录时的验证

    @Bean
        public CustomRealm customRealm(HashedCredentialsMatcher hashedCredentialsMatcher) {
            CustomRealm customRealm=new CustomRealm();
            customRealm.setCredentialsMatcher(hashedCredentialsMatcher);
            return  customRealm;
        }
    

    现在再创建一个JwtRealm用于 jwt token的验证

        @Bean
        public JwtRealm jwtRealm() {
            JwtRealm jwtRealm=new JwtRealm();
            jwtRealm.setCredentialsMatcher(new JwTCredentialsMatcher());
            return  jwtRealm;
        }
    

    其中的JwTCredentialsMatcher是需要自己实现的,跟controller登录不一样,shiro并没有实现JWT的Matcherm,代码如下:

    @Slf4j
    public class JwTCredentialsMatcher implements CredentialsMatcher {
        /**
         * Matcher中直接调用工具包中的verify方法即可
         */
        @Override
        public boolean doCredentialsMatch(AuthenticationToken authenticationToken, AuthenticationInfo authenticationInfo) {
            String token = ((JwtToken)authenticationToken).getToken();
            Object stored = authenticationInfo.getCredentials();
            String salt = stored.toString();
            String username=authenticationInfo.getPrincipals().toString();
            try {
                Algorithm algorithm = Algorithm.HMAC256(salt);
                JWTVerifier verifier = JWT.require(algorithm)
                        .withClaim("username", username)
                        .build();
                verifier.verify(token);
                return true;
            } catch (Exception e) {
                log.info("Token Error:{}", e.getMessage());
            }
            return false;
        }
    }
    

    JwtRealm

    public class JwtRealm extends AuthorizingRealm {
        @Autowired
        private UserMapper userMapper;
    
        /**
         * 必须重写此方法,不然Shiro会报错
         */
        @Override
        public boolean supports(AuthenticationToken token) {
            return token instanceof JwtToken;
        }
        /**
         * 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
         */
        @Override
        protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
            return new SimpleAuthorizationInfo();
        }
        /**
         * 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。
         */
        @Override
        protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
            String token = ((JwtToken)auth).getToken();
            // 解密获得username,用于和数据库进行对比
            String username = JwtUtil.getUsername(token);
            User userBean = userMapper.selectbyUsername(username);
            return new SimpleAuthenticationInfo(username, userBean.getPassword(), getName());
        }
    
    }
    

    这里的doGetAuthorizationInfo直接返回一个SimpleAuthorizationInfo;因为之前的CustomerRealm中验证通过后就不会调用这个方法了,直接跳过。

    过滤器

    之前整合shiro时使用的是shiro 默认的权限拦截 Filter,而因为 JWT 的整合,我们需要自定义自己的过滤器 JWTFilter,

    @Slf4j
    public class JwtFilter extends AccessControlFilter {
        @Override
        protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
            try {
                executeLogin(request, response);
                return true;
            } catch (Exception e) {
                return false;
            }
        }
        protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
            HttpServletRequest httpServletRequest = (HttpServletRequest) request;
            String token = httpServletRequest.getHeader("Authorization");
            System.out.println("----executeLogin------");
            if(token==null)
            {
                log.info("token为空");
                throw new Exception("token 为空");
            }
            else {
                JwtToken jwtToken = new JwtToken(token);
                // 提交给realm进行登入,如果错误他会抛出异常并被捕获
                getSubject(request, response).login(jwtToken);
                return true;
            }
        }
        @Override
        protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
            HttpServletResponse httpResponse = WebUtils.toHttp(response);
            httpResponse.setCharacterEncoding("UTF-8");
            httpResponse.setContentType("application/json;charset=utf-8");
            httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            httpResponse.setHeader("error","验证失败");
            return false;
        }
    }
    

    我这里是继承AccessControlFilter 过滤器,重写了其中的isAccessAllowed()和onAccessDenied()方法。

    以下是AccessControlFilter 源码:

    public abstract class AccessControlFilter extends PathMatchingFilter {
    
        public static final String DEFAULT_LOGIN_URL = "/login.jsp";
    
        public static final String GET_METHOD = "GET";
    
        public static final String POST_METHOD = "POST";
    
        private String loginUrl = DEFAULT_LOGIN_URL;
    
        public String getLoginUrl() {
            return loginUrl;
        }
        void setLoginUrl(String loginUrl) {
            this.loginUrl = loginUrl;
        }
        protected Subject getSubject(ServletRequest request, ServletResponse response) {
            return SecurityUtils.getSubject();
        }
        protected abstract boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception;
    
        protected boolean onAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
            return onAccessDenied(request, response);
        }
    
        protected abstract boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception;
    
        public boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
            return isAccessAllowed(request, response, mappedValue) || onAccessDenied(request, response, mappedValue);
        }
    
        protected boolean isLoginRequest(ServletRequest request, ServletResponse response) {
            return pathsMatch(getLoginUrl(), request);
        }
    
        protected void saveRequestAndRedirectToLogin(ServletRequest request, ServletResponse response) throws IOException {
            saveRequest(request);
            redirectToLogin(request, response);
        }
        protected void saveRequest(ServletRequest request) {
            WebUtils.saveRequest(request);
        }
        protected void redirectToLogin(ServletRequest request, ServletResponse response) throws IOException {
            String loginUrl = getLoginUrl();
            WebUtils.issueRedirect(request, response, loginUrl);
        }
    
    }
    

    调试的时候发现,首先进入的是onPreHandle函数,如果isAccessAllowed返回false,即认证失败,则会进入onAccessDenied方法中,如果不重写的话,就会redirect到之前shiroconfig中配置的loginUrl。这样可能就会有跨域的问题,就要重新设置header头。

    配置好自定义的过滤器和jwtrealm之后,shiroconfig代码如下:

    @Configuration
    public class ShiroConfig {
        @Bean
        public RestShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
            RestShiroFilterFactoryBean shiroFilterFactoryBean = new RestShiroFilterFactoryBean();
            // 必须设置 SecurityManager
            shiroFilterFactoryBean.setSecurityManager(securityManager);
            // setLoginUrl 如果不设置值,默认会自动寻找Web工程根目录下的"/login.jsp"页面 或 "/login" 映射
            shiroFilterFactoryBean.setLoginUrl("/notLogin");
            // 设置无权限时跳转的 url;
            shiroFilterFactoryBean.setUnauthorizedUrl("/notRole");
            // 设置拦截器
            Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
            //filterChainDefinitionMap.put("/logout","anon");
            filterChainDefinitionMap.put("/guest/**","anon");
            filterChainDefinitionMap.put("/test/test3","authc");
            filterChainDefinitionMap.put("/test/**==POST","anon");
            filterChainDefinitionMap.put("/login","anon");
            // 添加自己的过滤器并且取名为jwt
            Map<String, Filter> filterMap = new HashMap<String, Filter>(1);
            filterMap.put("jwt", new JwtFilter());
            //filterMap.put("role",new AnyRolesAuthorizationFilter());
            shiroFilterFactoryBean.setFilters(filterMap);
            filterChainDefinitionMap.put("/**", "jwt");
    
            shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
            System.out.println("Shiro拦截器工厂类注入成功");
            return shiroFilterFactoryBean;
        }
    
        /**
         * 注入 securityManager
         */
        @Bean
        public SecurityManager securityManager() {
            DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
            // 设置realm.
            //securityManager.setRealm(customRealm(hashedCredentialsMatcher()));
            securityManager.setRealms(Arrays.asList(customRealm(hashedCredentialsMatcher()),jwtRealm()));
            System.out.println("securityManager生成成功");
            return securityManager;
        }
    
        @Bean
        public Authenticator authenticator() {
            ModularRealmAuthenticator authenticator = new ModularRealmAuthenticator();
            //设置两个Realm,一个用于用户登录验证和访问权限获取;一个用于jwt token的认证
            authenticator.setRealms(Arrays.asList(customRealm(hashedCredentialsMatcher()),jwtRealm()));
            //设置多个realm认证策略,一个成功即跳过其它的
            authenticator.setAuthenticationStrategy(new FirstSuccessfulStrategy());
            return authenticator;
        }
    
        @Bean
        public HashedCredentialsMatcher hashedCredentialsMatcher() {
            HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
            hashedCredentialsMatcher.setHashAlgorithmName("SHA-256");//散列算法:MD2、MD5、SHA-1、SHA-256、SHA-384、SHA-512等。
            hashedCredentialsMatcher.setHashIterations(1);//散列的次数,默认1次, 设置两次相当于 md5(md5(""));
            System.out.println("hasedCredentialMather:"+hashedCredentialsMatcher);
            return hashedCredentialsMatcher;
        }
    
        /**
         * 自定义身份认证 realm;
         * <p>
         * 必须写这个类,并加上 @Bean 注解,目的是注入 CustomRealm,
         * 否则会影响 CustomRealm类 中其他类的依赖注入
         */
        @Bean
        public CustomRealm customRealm(HashedCredentialsMatcher hashedCredentialsMatcher) {
            CustomRealm customRealm=new CustomRealm();
            customRealm.setCredentialsMatcher(hashedCredentialsMatcher);
            System.out.println("customrealm生成成功");
            System.out.println(customRealm);
            return  customRealm;
        }
        @Bean
        public JwtRealm jwtRealm() {
            JwtRealm jwtRealm=new JwtRealm();
            jwtRealm.setCredentialsMatcher(new JwTCredentialsMatcher());
            return  jwtRealm;
        }
    
        /**
         * 禁用session, 不保存用户登录状态。保证每次请求都重新认证。
         * 需要注意的是,如果用户代码里调用Subject.getSession()还是可以用session,如果要完全禁用,要配合下面的noSessionCreation的Filter来实现
         */
        @Bean
        protected SessionStorageEvaluator sessionStorageEvaluator(){
            DefaultWebSessionStorageEvaluator sessionStorageEvaluator = new DefaultWebSessionStorageEvaluator();
            sessionStorageEvaluator.setSessionStorageEnabled(false);
            return sessionStorageEvaluator;
        }
    }
    

    自定义权限过滤器

    在实际的项目中,对同一个url多个角色都有访问权限很常见,shiro默认的RoleFilter没有提供支持,比如上面的配置,如果我们配置成下面这样,那用户必须同时具备admin和manager权限才能访问,显然这个是不合理的。

    filterChainDefinitionMap.put("/test/test3","roles["user","admin"]");
    

    所以自己实现一个role filter,只要任何一个角色符合条件就通过,只需要重写AuthorizationFilter中两个方法就可以了:

    public class AnyRolesAuthorizationFilter  extends AuthorizationFilter {
    
        @Override
        protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object mappedValue) throws Exception {
            Subject subject = getSubject(servletRequest, servletResponse);
            String[] rolesArray = (String[]) mappedValue;
            if (rolesArray == null || rolesArray.length == 0) { //没有角色限制,有权限访问
                return true;
            }
            for (String role : rolesArray) {
                if (subject.hasRole(role)) //若当前用户是rolesArray中的任何一个,则有权限访问
                    return true;
            }
            return false;
        }
        /**
         * 权限校验失败,错误处理
         */
        @Override
        protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {
            HttpServletResponse httpResponse = WebUtils.toHttp(response);
            httpResponse.setCharacterEncoding("UTF-8");
            httpResponse.setContentType("application/json;charset=utf-8");
            httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            httpResponse.setHeader("anyrole","anyrole");
            //httpResponse.setStatus(HttpStatus.SC_UNAUTHORIZED);
            return false;
        }
    
    }
    

    有了自定义的过滤器后,就可以这样写了。

     filterChainDefinitionMap.put("/user/**","jwt,roles[user]");
    Map<String, Filter> filterMap = new HashMap<String, Filter>(1);
            filterMap.put("jwt", new JwtFilter());
            filterChainDefinitionMap.put("/user/**","jwt,role[user,admin]");
            shiroFilterFactoryBean.setFilters(filterMap);
            filterChainDefinitionMap.put("/**", "jwt");
    

    表示需要登录认证,而且只要有user或者admin中的任意一个权限即可。

    总结

    大致流程为:前端发送请求,RestPathMatchingFilterChainResolver中判断匹配到哪一个过滤器。然后调用此过滤器,在过滤器中执行subject.login(token)进行验证,此时会跳转到realm中进行doGetAuthenticationInfo()身份认证和doGetAuthorizationInfo()权限认证,要是SimpleAuthenticationInfo调用Matcher方法抛出了异常,即subject.login(token)抛出了,则说明身份验证不通过。isAccessAllowed()返回false,进入onAccessDenied()中。如果SimpleAuthenticationInfo没有抛出异常,如果不需要权限认证,则请求成功,如果需要权限认证,就再调用doGetAuthorizationInfo()进行权限验证。

    例子:
    前端请求/user/1 (假设header中带有jwttoken)

    filterChainDefinitionMap.put("/user/**","jwt,role[user,admin]");
    

    RestPathMatchingFilterChainResolver匹配到"/user/**",先进入jwt过滤器中,调用isAccessAllowed(),里面执行
    subject.login(token)进行验证,在jwtrealm中的doGetAuthenticationInfo(),验证通过。则再进入role过滤器中,继续验证。

    在做项目的时候发现了一个bug:

    就是当使用 filterChainDefinitionMap.put("/users/ * *==GET","jwt,role[admin]");进行权限校验时,有bug

    rotected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
    
            if (this.appliedPaths == null || this.appliedPaths.isEmpty()) {
                if (log.isTraceEnabled()) {
                    log.trace("appliedPaths property is null or empty.  This Filter will passthrough immediately.");
                }
                return true;
            }
    
            for (String path : this.appliedPaths.keySet()) {
                // If the path does match, then pass on to the subclass implementation for specific checks
                //(first match 'wins'):
                if (pathsMatch(path, request)) {
                    log.trace("Current requestURI matches pattern '{}'.  Determining filter chain execution...", path);
                    Object config = this.appliedPaths.get(path);
                    return isFilterChainContinued(request, response, path, config);
                }
            }
    
            //no path matched, allow the request to go through:
            return true;
        }
    
            filterChainDefinitionMap.put("/user==POST", "anon");
            filterChainDefinitionMap.put("/user/*==GET","jwt");
            filterChainDefinitionMap.put("/user/*==PUT", "role[user,superadmin]");//user/superadmin
            filterChainDefinitionMap.put("/users==GET","jwt,role[admin]");
            filterChainDefinitionMap.put("/**", "jwt");
    

    查看源码可知,只有当path(即 filterChainDefinitionMap中所添加的url)和request中的url匹配时,才会进入权限校验,而此时path 为 “/users==GET",而request中是url为 /users,因此不会匹配到,就会返回true(即说明此url不需要进行权限校验);

    相关文章

      网友评论

          本文标题:springboot+shiro+jwt

          本文链接:https://www.haomeiwen.com/subject/gmptbqtx.html