springboot+shiro+jwt

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整合

导包

       
        
            com.auth0
            java-jwt
            3.4.0
        

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 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 filterMap = new HashMap(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;
     * 

* 必须写这个类,并加上 @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 filterMap = new HashMap(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");
springboot+shiro+jwt_第1张图片

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

你可能感兴趣的:(springboot+shiro+jwt)