手牵手带你整合Shiro+JWT实现认证功能

需要对 shiro 和 jwt 有一定的了解。

Shiro和JWT的区别

整合之前需要清楚Shiro和JWT的区别。

  • 首先Shiro是一套安全认证框架,已经有了对token的相关封装。而JWT只是一种生成token的机制,需要我们自己编写相关的生成逻辑。

  • 其次Shiro可以对权限进行管理,JWT在权限这部分也只能封装到token中,需要我们自己实现处理逻辑。

  • 最后 Shiro是基于session保持会话 的,也就是说是有状态的。而JWT则是无状态的(服务端不保存session,而是生成token发送给客户端进行保存,之后的所有的请求都需要携带token,再对token进行解析判断)。

    • 虽然Shiro也有相关的token(比如UsernamePasswordToken类),但是只是Shiro在服务端对用户信息进行判断的方式而已,并不是JWT所生成的可发送给客户端的字符串token。也就是说Shiro的token并不能响应给客户端。

    综上,所以如果是要构建无状态的项目,还需要权限等其他安全操作,就可以对着两者进行整合使用。

开始整合(认证功能)

首先说明下数据库中用户的重要认证信息:用户名、用户凭证、盐。其中用户凭证是用户输入的密码进行下面三件套后生成的:加盐 + MD5 + 1024次哈希

1、引入依赖
<!--Shiro-->
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-starter</artifactId>
    <version>1.5.3</version>
</dependency>
<!--JWT-->
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.4.0</version>
</dependency>

引入依赖后,接下来就是需要进行配置了,Shiro和JWT的整合所需要的配置和工具类有很多,主要分为下面两部分:

  • 和JWT相关:JwtUtil、JwtToken
  • 和shiro相关:ShiroConfig、JWTFilter、MyRealm
2、JwtUtil

Jwt的自定义工具类,主要功能如下:

  • 生成符合Jwt机制的token字符串

  • 可以对token字符串进行校验

  • 获取token中的用户信息

  • 判断token是否过期

    import com.auth0.jwt.JWT;
    import com.auth0.jwt.JWTVerifier;
    import com.auth0.jwt.algorithms.Algorithm;
    import com.auth0.jwt.exceptions.JWTDecodeException;
    import com.auth0.jwt.interfaces.DecodedJWT;
    import lombok.extern.slf4j.Slf4j;
    import java.util.Date;
    /**

    • @author: lhy

    • jwt工具,用来生成、校验token以及提取token中的信息
      */
      @Slf4j
      public class JwtUtil {
      //指定一个token过期时间(毫秒)
      private static final long EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000; //7天

      /**

      • 生成token
        */
        //注意这里的sercet不是密码,而是进行三件套(salt+MD5+1024Hash)处理密码后得到的凭证
        //这里为什么要这么做,在controller中进行说明
        public static String getJwtToken(String username, String secret) {
        Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
        Algorithm algorithm = Algorithm.HMAC256(secret); //使用密钥进行哈希
        // 附带username信息的token
        return JWT.create()
        .withClaim(“username”, username)
        .withExpiresAt(date) //过期时间
        .sign(algorithm); //签名算法
        }

      /**

      • 校验token是否正确
        */
        public static boolean verifyToken(String token, String username, String secret) {
        try {
        //根据密钥生成JWT效验器
        Algorithm algorithm = Algorithm.HMAC256(secret);
        JWTVerifier verifier = JWT.require(algorithm)
        .withClaim(“username”, username)
        .build();
        //效验TOKEN(其实也就是比较两个token是否相同)
        DecodedJWT jwt = verifier.verify(token);
        return true;
        } catch (Exception exception) {
        return false;
        }
        }

      /**

      • 在token中获取到username信息
        */
        public static String getUsername(String token) {
        try {
        DecodedJWT jwt = JWT.decode(token);
        return jwt.getClaim(“username”).asString();
        } catch (JWTDecodeException e) {
        return null;
        }
        }

      /**

      • 判断是否过期
        */
        public static boolean isExpire(String token){
        DecodedJWT jwt = JWT.decode(token);
        return jwt.getExpiresAt().getTime() < System.currentTimeMillis() ;
        }
        }
3、JwtToken

上面我们通过JwtUtil的getJwtToken就可以生成Jwt规范的tokenA字符串,首先要清楚这个tokenA就是需要发送给客户端进行保存的token。而在前面的区别我们说到Shiro还需要token可以进行认证,可以采用Shiro自带的token去进行认证,也可以使用我们这个tokenA进行认证(在controller中说明),这里我们对这个tokenA再利用。

但是由于Shiro不能识别身为字符串的tokenA,所以需要对其进行一下封装,也就是实现下Shiro能够识别的token接口。

import org.apache.shiro.authc.AuthenticationToken;

/**
 * @author: lhy
 * 自定义的shiro接口token,可以通过这个类将string的token转型成AuthenticationToken,可供shiro使用
 * 注意:需要重写getPrincipal和getCredentials方法,因为是进行三件套处理的,没有特殊配置shiro无法通过这两个         方法获取到用户名和密码,需要直接返回token,之后交给JwtUtil去解析获取。(当然了,可以对realm进行配   
        置HashedCredentialsMatcher,这里就不这么处理了)
 */
public class JwtToken implements AuthenticationToken {
    private String token;

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

    @Override
    public Object getPrincipal() {
        return token;
    }
    @Override
    public Object getCredentials() {
        return token;
    }
}
6、MyRealm

realm可以说是shiro两大功能:认证和授权的入口。可以自定义realm,对认证的方式进行自定义处理。重心先放在认证方法上,只要调用了subject.login(token)方法,就会进入到realm的doGetAuthenticationInfo内。

注意我们上面说到,shiro使用的token和客户端保存的token都是jwt生成的。所以下面两种情况都会调用到subject.login方法进入到realm中:

  • 在认证时(登录controller中调用)
  • 认证通过后每次校验token正确性时(在JwtFilter中调用)

问题来了,登录认证时,在realm中的认证方法肯定需要去查数据库。由于共用了同个realm,认证通过后每次校验token也都进入了同一个方法,这就导致每次都需要去数据库查,不太合理。

  1. 通过subject.getPrincipal()获取信息而不是去查数据库?可惜没办法,因为使用的是自己的JwtToken,不是UsernamePasswordToken。获取不到正确的用户信息(为null),也可以理解为采用了无服务状态的方式,所以服务端是不会保存和用户有关的信息的。
  2. 那就换个角度,将登录成功后返回的token,同时保存一份到redis中,之后在JwtFilter中对token进行校验的时候,就从redis获取后判断是否相等即刻,就不用在进入realm了。(这里提供个思路,下面的处理还是进入到realm中去查数据库)
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;

/**
 * @author: lhy
 * 自定义Realm
 */
public class MyRealm extends AuthorizingRealm {
    @Autowired
    private UserService userService;

    /**
     * 限定这个realm只能处理JwtToken(不加的话会报错)
     */
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }

    /**
     * 授权(授权部分这里就省略了,先把重心放在认证上)
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        //获取到用户名,查询用户权限
        return null;
    }

    /**
     * 认证
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) {
        String token = (String) auth.getCredentials();  //JwtToken中重写了这个方法了
        String username = JwtUtil.getUsername(token);   // 获得username
        
        //用户不存在(这个在登录时不会进入,只有在token校验时才有可能进入)
        if(username == null)
            throw new UnknownAccountException();

        //根据用户名,查询数据库获取到正确的用户信息
        User user = userService.getUserInfoByName(username);

        //用户不存在(这个在登录时不会进入,只有在token校验时才有可能进入)
        if(user == null)
            throw new UnknownAccountException();

        //密码错误(这里获取到password,就是3件套处理后的保存到数据库中的凭证,作为密钥)
        if (! JwtUtil.verifyToken(token, username, user.getPassword())) { 
            throw new IncorrectCredentialsException();
        }
        //toke过期
        if(JwtUtil.isExpire(token)){
            throw new ExpiredCredentialsException();
        }

        return new SimpleAuthenticationInfo(user, token, getName());
    }
}
5、JWTFilter

这个的目的就是对客户端携带的token进行解析。当然了,这里进行了定义,之后还需要将这个过滤器交给Shiro,让Shiro去使用这个过滤器(在ShiroConfig中进行配置)。

import com.fasterxml.jackson.databind.ObjectMapper;
import com.klane.smartwatersystem.common.jwt.JwtToken;
import com.klane.smartwatersystem.common.ResultTemplate;
import com.klane.smartwatersystem.common.StatusCode;
import com.klane.smartwatersystem.common.jwt.JwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.ShiroException;
import org.apache.shiro.authc.*;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMethod;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author: lhy
 *  jwt过滤器,作为shiro的过滤器,对请求进行拦截并处理
    跨域配置不在这里配了,我在另外的配置类进行配置了,这里把重心放在验证上
 */
@Slf4j
@Component
public class JwtFilter extends BasicHttpAuthenticationFilter{

    /**
     * 进行token的验证
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        //在请求头中获取token
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader("Authorization"); //前端命名Authorization
        //token不存在
        if(token == null || "".equals(token)){
            ResultTemplate<Object> res = new ResultTemplate<>();
            res.setCode(StatusCode.UNLOGIN.getCode()).setMessage("无token,无权访问,请先登录");
            out(response, res);
            return false;
        }

        //token存在,进行验证
        JwtToken jwtToken = new JwtToken(token);
        try {
            SecurityUtils.getSubject().login(jwtToken);  //通过subject,提交给myRealm进行登录验证
            return true;    
        } catch (ExpiredCredentialsException e){
            ResultTemplate<Object> res = new ResultTemplate<>();
            res.setCode(StatusCode.TOKENEXPIRED.getCode()).setMessage("token过期,请重新登录");
            out(response,res);
            e.printStackTrace();
            return false;
        } catch (ShiroException e){  
            // 其他情况抛出的异常统一处理,由于先前是登录进去的了,所以都可以看成是token被伪造造成的
            ResultTemplate<Object> res = new ResultTemplate<>();
            res.etCode(StatusCode.FAKETOKEN.getCode()).setMessage("token被伪造,无效token");
            out(response,res);
            e.printStackTrace();
            return false;
        }
    }

    /**
     * json形式返回结果token验证失败信息,无需转发
     */
    private void out(ServletResponse response, ResultTemplate<Object> res) throws IOException {
        HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
        ObjectMapper mapper = new ObjectMapper();
        String jsonRes = mapper.writeValueAsString(res);
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json; charset=utf-8");
        httpServletResponse.getOutputStream().write(jsonRes.getBytes());
    }

    /**
     * 过滤器拦截请求的入口方法
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        try {
            return executeLogin(request, response);  //token验证
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * isAccessAllowed()方法返回false,即认证不通过时进入onAccessDenied方法
     */
//    @Override
//    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
//        return super.onAccessDenied(request, response);
//    }

    /**
     * token认证executeLogin成功后,进入此方法,可以进行token更新过期时间
     */
//    @Override
//    protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception {
			
//    }
}
6、ShiroConfig

shiro的配置类。只有配置好了这个,上面进行的所有配置才可以正确运转。

import com.klane.smartwatersystem.common.shiro.JwtFilter;
import com.klane.smartwatersystem.common.shiro.MyRealm;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;

import javax.servlet.Filter;
import java.util.HashMap;
import java.util.Map;

/**
 * @author: lhy
 * shiro的配置类
 */
@Configuration
@Slf4j
public class ShiroConfig {

    /**
     * 由Spring管理 Shiro的生命周期
     */
    @Bean
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }

    /**
     * 开启对 Shiro 注解的支持
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }

    @Bean
    @DependsOn("lifecycleBeanPostProcessor")
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        // 强制使用cglib,防止重复代理和可能引起代理出错的问题
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }

    //创建ShiroFilter(用于拦截所有请求,对受限资源进行Shiro的认证和授权判断)
    //Shiro提供了丰富的过滤器(anon等),不过在这里就需要加入我们自定义的JwtFilter了
    @Bean("shiroFilter")
    public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager defaultWebSecurityManager){
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        //设置安全管理器
        shiroFilterFactoryBean.setSecurityManager(defaultWebSecurityManager);

        // 添加自己的过滤器并且取名为jwt
        Map<String, Filter> filterMap = new HashMap<>();
        filterMap.put("jwt", new JwtFilter());
        shiroFilterFactoryBean.setFilters(filterMap);

        //配置系统的受限资源以及对应的过滤器
        Map<String, String> ruleMap = new HashMap<>();
        ruleMap.put("/user/login", "anon"); //登录路径、注册路径都需要放行不进行拦截
        ruleMap.put("/user/register", "anon");
        ruleMap.put("/**", "jwt");  // /**,一般放在最下,表示对所有资源起作用,使用JwtFilter
        shiroFilterFactoryBean.setFilterChainDefinitionMap(ruleMap);
        return shiroFilterFactoryBean;
    }


    //创建安全管理器(会自动设置到SecurityUtils中设置这个安全管理器)
    //SecurityUtils可以用来获取subject对象
    @Bean("securityManager")
    public DefaultWebSecurityManager getDefaultWebSecurityManager(MyRealm realm){
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();

        //给安全管理器设置realm
        securityManager.setRealm(realm);

        //关闭shiro的session(无状态的方式使用shiro)
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);

        return securityManager;
    }

    //创建自定义Realm,注入到spring容器中
    @Bean
    public MyRealm getRealm(){
        MyRealm realm = new MyRealm();
        //修改凭证校验匹配器(处理加密),只有使用了UsernamePasswordToken并且有对password进行加密的才需要
//        HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
//        hashedCredentialsMatcher.setHashAlgorithmName("MD5");
//        hashedCredentialsMatcher.setHashIterations(1024);
//
//        realm.setCredentialsMatcher(hashedCredentialsMatcher);
        return realm;
    }
}
7、登录controller

以上就是shiro和jwt的整合配置。接下来可以进行登录测试下。如果登录成功,则返回JWT生成的token

ResultTemplate类是我自定义的返回类型,StatusCode是我自定义的状态枚举类,这部分和整合没多大关系

@PostMapping("/login")
public ResultTemplate<String> register(@RequestBody User loginUser,
                                       HttpServletRequest request){
    ResultTemplate<String> res = new ResultTemplate<>();
    String username = loginUser.getUsername();
    String password = loginUser.getPassword();

    //根据用户名获取正确用户信息
    User user = userService.getUserInfoByName(username);
    if(user == null)
        return res.setCode(StatusCode.INVALIDUSER.getCode()).setMessage("无效用户,用户不存在");

    //盐 + 输入的密码(注意不是用户的正确密码) + 1024次散列,作为token生成的密钥
    String salt = user.getSalt();
    Md5Hash md5Hash = new Md5Hash(password, salt, 1024);

    //生成token字符串
    String token = JwtUtil.getJwtToken(username, md5Hash.toHex());   //toHex转换成16进制,32为字符
    JwtToken jwtToken = new JwtToken(token);        //转换成jwtToken(才可以被shiro识别)
    
/**可能有人会问这里为什么不直接把passsword作为密钥,或者使用一个固定的密钥。
* 是因为在后面在subject.login进入到realm中,进行认证的时候,肯定需要和password相关的进行匹配校验。
* 因为下面不是用UsernamePasswordToken,不能直接获取到传入的password,所以:
*    1. 如果使用固定密钥,那么无法实现和密码相关的校验。
*    2. 如果使用password,则有可能两个人的password相同导致误判。
* 所以这里就需要在token中就将password相关信息包含进去,这里选择作为密钥
*/

/**
* 如果是使用UsernamePasswordToken,那么在realm中就可以获取到username和password,查数据库就很容易判断。
* 但是由于返回给前端jwtToken不是UsernamePasswordToken,就还需要另外一个realm对jwtToken进行解析。
* 使用UsernamePasswordToken的,可以参考    
  https://blog.csdn.net/pengjunlee/article/details/95600843#pom.xml。我觉得很清晰
* 使用密码加密作为密钥的这种处理,同一个realm就可以实现了,各有利弊。当然了,也可以使用redis来处理。
*/

    //拿到Subject对象
    Subject subject = SecurityUtils.getSubject();

    //进行认证
    try {
        subject.login(jwtToken);
        res.setCode(StatusCode.SUCCESS.getCode()).setMessage("登录成功").setData(token);
    } catch (UnknownAccountException e){
        res.setCode(StatusCode.INVALIDUSER.getCode()).setMessage("无效用户,用户不存在");
        e.printStackTrace();
    } catch (IncorrectCredentialsException e){
        res.setCode(StatusCode.PASSWORDERROR.getCode()).setMessage("密码输入错误");
        e.printStackTrace();
    } catch (ExpiredCredentialsException  e){
        res.setCode(StatusCode.TOKENEXPIRED.getCode()).setMessage("token过期,请重新登录");
        e.printStackTrace();
    } finally {
        return res;
    }
}

结束

以上就是Shiro和JWT的整合,本人测试没问题,有什么问题和建议欢迎指出探讨。溜了吃饭了。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值