SpringBoot+Shiro+JWT实现授权

一、需求

1、前后端分离项目,前端Vue,后端SpringBoot,需要实现后端鉴权;

2、当前端发送请求之后,判断是否登录或用户是否具有相应权限;

3、使用Token实现,不使用Session。

二、实现思路

1、前端Vue的axios设置请求拦截器,在请求头中添加token;

2、后端自定义JWT拦截器,继承BasicHttpAuthenticationFilter,

        如果请求头中含有token,则进行认证鉴权操作;

        如果请求头中不含有token,禁止访问需要登录之后才能访问的资源;

3、ShiroFilterFactoryBean中自定义拦截url,排除不需要验证的界面(如login、404、401等),其余请求的url,一律拦截至我们自定义的JWTFilter;

4、因为使用JWT验证身份,不存在Session,若没有使用缓冲池,每次鉴权都需要执行登录操作,重新获取身份信息,鉴权前执行登录操作;

5、调用自定义Realm中的doGetAuthorizationInfo,从数据库中获取该用户所对应角色或权限,进行鉴权。

三、Shiro授权原理

授权流程

流程如下:

  1. 首先调用 Subject.isPermitted*/hasRole*接口,其会委托给 SecurityManager,而 SecurityManager 接着会委托给 Authorizer;
  2. Authorizer 是真正的授权者,如果我们调用如 isPermitted(“user:view”),其首先会通过 PermissionResolver 把字符串转换成相应的 Permission 实例;
  3. 在进行授权之前,其会调用相应的 Realm 获取 Subject 相应的角色/权限用于匹配传入的角色/权限;
  4. Authorizer 会判断 Realm 的角色/权限是否和传入的匹配,如果有多个 Realm,会委托给 ModularRealmAuthorizer 进行循环判断,如果匹配如 isPermitted*/hasRole* 会返回 true,否则返回 false 表示授权失败。

四、关键代码

首先是pom.xml

        <!--shiro和springboot整合包-->
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring-boot-starter</artifactId>
            <version>1.6.0</version>
        </dependency>
        <!--jwt-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>

 JWTtoken的加密、解析和check已经在我的其他博客写过

1、自定义JWTToken,继承自AuthenticationToke

        JWTToken差不多就是Shiro用户名密码的载体。因为我们是前后端分离,服务器无需保存用户状态,简单的实现下AuthenticationToken接口即可,因为项目使用了MD5加密,数据库存储的密码是加盐之后的密码,此处还要保存一下加盐之后的密码,将getCredentials返回为这个加盐之后的密码,因为授权还需要进行一次登录操作,此处又不能使用明文密码,所有只能将加密后的密码再使用相同的盐再加密,再将“加密之后的密码”按照相同的方法加密之后(此处有点绕嘴),再放入SimpleAuthenticationInfo中,在执行Subject.login()。(有更好的方法可以分享一下)

public class JWTToken implements AuthenticationToken {

    private String token;
    // 用户密码(数据库里存储的用户加密之后的密码)
    private String credentials;


    // 有参构造
    public JWTToken(String token) {
        this.token = token;
    }

    // 无参构造
    public JWTToken() {
    }

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

    @Override
    public Object getCredentials() {
        return this.credentials;
    }

    public void setCredentials(String credentials) {
        this.credentials = credentials;
    }
}

2、自定义JWT拦截器

        我们使用的是 shiro 默认的权限拦截 Filter,而因为JWT的整合,我们需要自定义自己的过滤器 JWTFilter,JWTFilter 继承了 BasicHttpAuthenticationFilter,并部分方法进行了重写。所有的请求都会先经过Filter,所以我们继承官方的BasicHttpAuthenticationFilter,并且重写鉴权的方法。代码执行的流程:

preHandle -> isAccessAllowed -> isLoginAttempt -> executeLogin

public class JWTFilter extends BasicHttpAuthenticationFilter {

    /**
     * 最后始终返回true
     * @param request
     * @param response
     * @param mappedValue
     * @return
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        System.out.println("JWTFilter启动");

        //指定请求不经过该过滤器
        if(((HttpServletRequest) request).getRequestURI().endsWith("login")){
            return true;
        }
        // 判断请求的请求头是否带token属性,也就是判断用户是否一定登录
        if(isLoginAttempt(request, response)){
            // 如果请求头中包含token,则执行executeLogin方法进行登入操作,检查token是否正确
            System.out.println("用户已经登录");
            try {
                return executeLogin(request, response);
            }catch (Exception e){
                e.printStackTrace();
            }
        }else {
            System.out.println("用户没有登录");
            try {
                Result result = new Result(Code.TOKEN_INVALID, null, "Token为空!");
                this.returnErrorMsg(response, result);
            }catch (IOException e){
                e.printStackTrace();
            }
        }

        return false;
    }

    /**
     * 判断用户是否想要登录
     *  在请求头中检查是否有token就行
     * @param request
     * @param response
     * @return
     */
    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        // System.out.println("判断用户是否想要登入");
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        return null != httpServletRequest.getHeader("token");
    }

    /**
     * 执行登录
     * @param request
     * @param response
     * @return
     * @throws Exception
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        System.out.println("执行登录");
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader("token");
        try{
            getSubject(request, response).login(new JWTToken(token));
            return true;
        }catch (Exception e){
            e.printStackTrace();
            Result result = new Result(Code.TOKEN_INVALID, null, "Token失效!");
            this.returnErrorMsg(response, result);
            return false;
        }
    }

    /**
     * 返回给前端自定义错误信息
     * @param response
     * @param result
     * @throws IOException
     */
    private void returnErrorMsg(ServletResponse response,Result result) throws IOException {
        //响应token为空
        response.setContentType("application/json;charset=UTF-8");
        response.setCharacterEncoding("UTF-8");
        //清空第一次流响应的内容
        response.resetBuffer();
        //转成json格式
        ObjectMapper object = new ObjectMapper();
        String asString = object.writeValueAsString(result);
        response.getWriter().println(asString);
    }

    /**
     * 因为前后端分离,需对跨域提供支持
     * @param request
     * @param response
     * @return
     * @throws Exception
     */
    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        // System.out.println("跨域支持");
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;

        httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
        httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
        // 跨域时会首先发送一个 option请求,这里我们给 option请求直接返回正常状态
        if(httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())){
            httpServletResponse.setStatus(HttpStatus.OK.value());
            return false;
        }
        return super.preHandle(request, response);
    }
}

上面代码的大致分析:

①、拦截所有的请求,先通过Interceptor解决跨域请求问题;

②、isAccessAllowed方法启动,启动之后,调用isLoginAttempt方法;

③、isLoginAttempt是判断用户是想要执行登录操作还是要授权登录,判断的依据就是请求头里面是否含有token;

④、如果请求头里面含有token,就说明用户已经登录,那就再执行一次登录,用于鉴权;如果没有登录,那就返回前端Result;

3、Shiro全局配置类中增加自定义jwtFilter过滤器,用来拦截并处理携带JWT token的请求

/**
 * Shiro配置类
 */
@Configuration
public class ShiroConfig {
 
    /*
     * 倒序配置
     *   1、先自定义过滤器 MyRealm
     *   2、创建第二个DefaultWebSecurityManager,将MyRealm注入
     *   3、装配第三个ShiroFilterFactoryBean,将DefaultWebSecurityManager注入,并注入认证及授权规则
     * */
 
    //3、装配ShiroFilterFactoryBean,并将 DefaultWebSecurityManager 注入到 ShiroFilterFactoryBean 中
    @Bean
    public ShiroFilterFactoryBean factoryBean(DefaultWebSecurityManager manager){
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
        factoryBean.setSecurityManager(manager);//将 DefaultWebSecurityManager 注入到 ShiroFilterFactoryBean 中
// 自定义拦截url
        Map<String, Filter> filterMap = new LinkedHashMap<>();
        filterMap.put("jwt", new JWTFilter());
        factoryBean.setFilters(filterMap);
        //添加默认过滤器
        //表示指定登录页面
        factoryBean.setLoginUrl("/login");
        //未授权页面
        //factoryBean.setUnauthorizedUrl("/unauthorized");
        //拦截器, 配置不会被拦截的链接 顺序判断
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        //所有匿名用户均可访问到Controller层的该方法下

        filterChainDefinitionMap.put("/login", "anon");
        filterChainDefinitionMap.put("/checkToken", "anon");

        //user表示配置记住我或认证通过可以访问的地址
        //filterChainDefinitionMap.put("/remember", "user");
        //filterChainDefinitionMap.put("/logout", "logout");

        //authc:所有url都必须认证通过才可以访问; anon:所有url都都可以匿名访问
        //filterChainDefinitionMap.put("/**", "authc");

        //所有url都必须认证通过jwt过滤器才可以访问
        filterChainDefinitionMap.put("/**", "jwt");
        factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        //注入认证及授权规则
        return factoryBean;
    }
 
    //2、创建DefaultWebSecurityManager ,并且将 MyRealm 注入到 DefaultWebSecurityManager bean 中
    @Bean
    public DefaultWebSecurityManager manager(MyRealm myRealm){
        DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
        manager.setRealm(myRealm);//将自定义的 MyRealm 注入到 DefaultWebSecurityManager bean 中
        /*
         *  关闭shiro自带的session
         *      用了jwt的访问认证,所以要把默认session支持关掉
         *      即不保存用户登录状态,保证每次请求都重新认证
         */
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        manager.setSubjectDAO(subjectDAO);
        return manager;
    }
 
    //1、自定义过滤器Realm
    @Bean
    public MyRealm myRealm(@Qualifier("hashedCredentialsMatcher") HashedCredentialsMatcher matcher){
        MyRealm myRealm = new MyRealm();
        // 密码匹配器
        myRealm.setCredentialsMatcher(matcher);
        return myRealm;
    }
 
    /**
     * 密码匹配器
     * @return HashedCredentialsMatcher
     */
    @Bean("hashedCredentialsMatcher")
    public HashedCredentialsMatcher hashedCredentialsMatcher(){
        HashedCredentialsMatcher matcher = new HashedCredentialsMatcher();
        // 设置哈希算法名称
        matcher.setHashAlgorithmName("MD5");
        // 设置哈希迭代次数
        matcher.setHashIterations(1024);
        // 设置存储凭证(true:十六进制编码,false:base64)
        matcher.setStoredCredentialsHexEncoded(true);
 
        return matcher;
    }

     /**
     * SpringShiroFilter首先注册到spring容器
     * 然后被包装成FilterRegistrationBean
     * 最后通过FilterRegistrationBean注册到servlet容器
     * @return
     */
    @Bean
    public FilterRegistrationBean delegatingFilterProxy() {
        FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
        DelegatingFilterProxy proxy = new DelegatingFilterProxy();
        proxy.setTargetFilterLifecycle(true);
        proxy.setTargetBeanName("factoryBean");
        filterRegistrationBean.setFilter(proxy);
        return filterRegistrationBean;
    }

    /**
     * 注解访问授权动态拦截,
     *  不然不会执行Realm中的doGetAuthenticationInfo
     * @param manager
     * @return
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor( SecurityManager manager){
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(manager);
        return authorizationAttributeSourceAdvisor;
    }
}

注意点1、关闭Shiro自带的session;

        因为用了jwt的访问认证,所以要把默认session支持关掉。即不保存用户登录状态,保证每次请求都重新认证。


DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
defaultWebSecurityManager.setSubjectDAO(subjectDAO);

4、配置Realm

/**
 * Shiro自定义Realm
 */
public class MyRealm extends AuthorizingRealm {
 
    @Autowired
    private UserService userService;
 
    /**
     * 授权
     * @param principalCollection
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        System.out.println("获取权限信息");
        //1.获取身份信息
        User pricipal = (User) principalCollection.getPrimaryPrincipal();

        //2.根据身份获取、角色权限等信息
        ArrayList<String> roles = new ArrayList<>();
        roles = ...自己Service获取Role的方法...;
        // System.out.println("用户所具有角色:"+roles);
        ArrayList<String> permissions = new ArrayList<>();
        permissions = ...自己Service获取Permissions的方法...;
        System.out.println("用户所具有权限:"+permissions);

        //3.将角色、权限添加到授权信息里面
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        info.addRoles(roles);
        info.addStringPermissions(permissions);
        return info;
    }
 
    /**
     * 认证
     * @param authenticationToken
     * @return
     * @throws AuthenticationException
     *
     * 客户端传来的 username 和 password 会自动封装到 token,先根据 username 进行查询,
     *      如果返回 null,则表示用户名错误,直接 return null 即可,Shiro 会自动抛出 UnknownAccountException 异常。
     *      如果返回不为 null,则表示用户名正确,再验证密码,直接返回  SimpleAuthenticationInfo 对象即可,
     *          如果密码验证成功,Shiro 认证通过,否则返回 IncorrectCredentialsException 异常。
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        //System.out.println("启动认证");
        String userId;
        // 如果这个token是来自JWTToken,说明此次登录目的是授权验证的登录
        if(authenticationToken instanceof JWTToken){
            JWTToken token = (JWTToken) authenticationToken;
            userId = ...自己解析token获取ID的方法...;
            User user = userService.getByUsername(token.getUsername());
            token.setCredentials(user.getPassword());
            if(user != null && user.getStatus().equals(1)){
                // 用数据库中已经加密的密码,再用数据库中对应的盐值加密一次
                String pwd2 = SaltUtil.encryption(user.getPassword(), user.getSalt());
                // 参数列表(实体信息,密码,盐值,realm名称)
                return new SimpleAuthenticationInfo(user,pwd2, ByteSource.Util.bytes(user.getSalt()),getName());
        }else{
            // 此操作是用户真正执行登录操作
            UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
            //获取存放到数据库中的实体类
            User user = userService.getByUsername(token.getUsername());
            //System.out.println(user);
            if(user != null && user.getStatus().equals(1)){
                // 参数列表(实体信息,密码,盐值,realm名称)
                return new SimpleAuthenticationInfo(user,user.getPassword(), ByteSource.Util.bytes(user.getSalt()),getName());
            }
        }
        
        return null;
    }
}

4、再需要鉴权的Controller上面,添加注释就可以啦

//需要user角色
@RequiresRoles("user")
 
//必须同时属于user和admin角色
@RequiresRoles({"user","admin"})
//用户具有index:menu权限才可访问
@RequiresPermissions("index:menu")

五、总结

        1、目前没有使用缓存,每次鉴权都要去数据库查Role、查Permissions;

        2、鉴权时段的登录操作,因为token中不能封装明文密码,又不能移除加密器,所以要对数据库中已经加密的密码,在此基础上再把这个密码进行一次加密,出现了不必要的资源浪费;

        3、虽然实现功能,但是实现过程复杂;

        最后,感谢“桃子屁屁”的大力支持。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值