SpringMVC整合

Shiro在Web环境下还有一些实用的注解和功能,这篇笔记我们在一个SSM工程中整合Shiro,编写一个通过查询数据库加载认证、鉴权信息以及登出系统的例子,以此学习Shiro在Web环境中的用法。本篇例子中使用的框架和库包括:

  • JDK8/Tomcat9
  • Spring5/SpringMVC
  • Mybatis
  • Thymeleaf
  • Slf4j/Logback
  • HikariCP/MySQL
  • Lombok

注意:本篇笔记中我们使用传统的非前后端分离方式整合Shiro,采用前后端分离方式开发则还需要一些额外的配置,将在后续章节介绍。

加入Maven依赖

在Web环境中使用Shiro,我们除了需要shiro-core,还需要shiro-webshiro-spring,其中包括Shiro的过滤器以及和Spring整合的组件。

<dependency>
  <groupId>org.apache.shiro</groupId>
  <artifactId>shiro-core</artifactId>
  <version>1.3.2</version>
</dependency>
<dependency>
  <groupId>org.apache.shiro</groupId>
  <artifactId>shiro-web</artifactId>
  <version>1.3.2</version>
</dependency>
<dependency>
  <groupId>org.apache.shiro</groupId>
  <artifactId>shiro-spring</artifactId>
  <version>1.3.2</version>
</dependency>

警告:Shiro作为一个认证授权框架,一直是信息安全领域漏洞挖掘的关注重点,随着时间的推移上面例子中所使用的Shiro版本可能已经过时,在实际开发中我们务必选择一个没有安全漏洞的较新版本。

配置web.xml

Web环境下Shiro需要是通过Filter过滤器来起作用的。Shiro的过滤器需要在Spring配置文件中装配,因此在web.xml中我们使用DelegatingFilterProxy进行配置。

<!-- shiro filter -->
<filter>
  <filter-name>shiroFilter</filter-name>
  <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
  <init-param>
    <!-- 将生命周期交由Spring管理 -->
    <param-name>targetFilterLifecycle</param-name>
    <param-value>true</param-value>
  </init-param>
</filter>
<filter-mapping>
  <filter-name>shiroFilter</filter-name>
  <url-pattern>/*</url-pattern>
</filter-mapping>

注:filter-name要和Spring配置文件中装配的Bean对应。

Spring配置文件

<!-- securityManager配置 -->
<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
    <!-- 我们自定义的Realm -->
    <property name="realm" ref="systemRealm"/>
</bean>

<!-- 自定义Realm -->
<bean id="systemRealm" class="com.gacfox.demo.demoshiro.realm.SystemRealm"/>

<!-- 自定义异常处理 -->
<bean class="com.gacfox.demo.demoshiro.realm.SystemExceptionResolver"/>

<!-- Shiro过滤器配置 -->
<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
    <property name="securityManager" ref="securityManager"/>
    <!-- 登录页面地址 -->
    <property name="loginUrl" value="/auth/login"/>
    <!-- 拦截路径 -->
    <property name="filterChainDefinitions" value="/admin/**=authc"/>
</bean>

<!-- 开启Shiro注解 -->
<bean id="lifecycleBeanPostProcessor" class="org.apache.shiro.spring.LifecycleBeanPostProcessor"/>
<bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator"
        depends-on="lifecycleBeanPostProcessor">
    <property name="proxyTargetClass" value="true"/>
</bean>
<bean class="org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor">
    <property name="securityManager" ref="securityManager"/>
</bean>

配置文件中securityManager之前已经介绍过,它需要加载Realm配置来初始化,之前我们在普通Java工程是使用Shiro提供的工厂方法初始化的,这里只不过改为了使用Spring装配。

systemRealm使我们自定义的Realm。前面介绍过,Shiro中有一些内置的Realm,比如JdbcRealm,但我总觉得把它们用好的成本比自己写一个Realm还高,这里我们就使用自己的Realm。

SystemExceptionResolver是我们自定义的一个异常处理器,我们知道Shiro在认证和鉴权时,任何不符合条件的状况都是通过抛运行时异常来完成的,Shiro的过滤器抛出的异常需要组件来统一接收处理,该异常处理器主要就是用来控制Shiro过滤器抛出的未认证和未授权异常的。

Shiro过滤器也是一个Bean,其中需要指定登录地址(供未认证时跳转)以及拦截路径,/admin/**=authc表示/admin/**路径都要进行认证过滤,不符合条件的将不予显示,并跳转到登录页。

后两个Bean用来开启Shiro注解。

Realm

package com.gacfox.demo.demoshiro.realm;

import com.gacfox.demo.demoshiro.bean.Perm;
import com.gacfox.demo.demoshiro.bean.Role;
import com.gacfox.demo.demoshiro.bean.RolesAndPerms;
import com.gacfox.demo.demoshiro.bean.User;
import com.gacfox.demo.demoshiro.service.AuthService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;

/**
 * @author gacfox
 */
@Slf4j
public class SystemRealm extends AuthorizingRealm {
    @Autowired
    private AuthService authService;

    private static final String SALT = "qqqwwweee";

    /**
     * 鉴权信息查询
     *
     * @param principals 鉴权原则(可以理解为用户名)
     * @return 鉴权信息
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        Integer userId = (Integer) getAvailablePrincipal(principals);
        log.debug("鉴权用户ID -> " + userId);
        RolesAndPerms rolesAndPerms = authService.getAuthorizationInfoByUsername(userId);
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        for (Role role : rolesAndPerms.getRoleSet()) {
            String roleName = role.getRoleName();
            log.debug("鉴权角色 -> " + roleName);
            info.addRole(roleName);
        }
        for (Perm perm : rolesAndPerms.getPermSet()) {
            String permName = perm.getPermName();
            log.debug("鉴权权限 -> " + permName);
            info.addStringPermission(permName);
        }
        return info;
    }

    /**
     * 认证信息查询
     *
     * @param token 用户名密码
     * @return 认证信息
     * @throws AuthenticationException 认证失败
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        String username = (String) token.getPrincipal();
        String password = new String((char[]) token.getCredentials());

        log.debug("认证用户 -> " + username);
        log.debug("认证密码 -> " + password);

        // 认证
        User realUser = authService.getUserByUsername(username);
        if (realUser == null) {
            throw new UnknownAccountException();
        }
        String passEnc = DigestUtils.sha256Hex(password + SALT);
        if (!passEnc.equals(realUser.getPassword())) {
            throw new IncorrectCredentialsException();
        }
        // 认证成功
        log.debug("认证成功");
        return new SimpleAuthenticationInfo(realUser.getUserId(), password, getName());
    }
}

代码虽然多,但是很容易理解,注意AuthService,这个是后台的业务逻辑层接口,底层调用MyBatis实现,由于我们已经把该Realm在Spring配置文件中装配,因此这里能直接从Spring应用上下文中获取AuthService。上面例子中Realm的使用方法前面我们已经讲解过,这里就不多做介绍了。

ExceptionResolver

package com.gacfox.demo.demoshiro.realm;

import org.apache.shiro.authz.UnauthenticatedException;
import org.apache.shiro.authz.UnauthorizedException;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.handler.SimpleMappingExceptionResolver;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

/**
 * 自定义未认证和未授权异常,未认证跳转登录页,未授权报401
 *
 * @author gacfox
 */
public class SystemExceptionResolver extends SimpleMappingExceptionResolver {
    public SystemExceptionResolver() {
    }

    @Override
    protected ModelAndView doResolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof UnauthenticatedException) {
                response.sendRedirect(request.getContextPath() + "/auth/login");
            } else if (ex instanceof UnauthorizedException) {
                response.setStatus(401);
                PrintWriter writer = response.getWriter();
                writer.write("Unauthorized");
                writer.flush();
            } else {
                response.setStatus(403);
                PrintWriter writer = response.getWriter();
                writer.write("Unknown Error Request Forbidden");
                writer.flush();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return new ModelAndView();
    }
}

这个异常处理类主要对未认证和未授权异常进行拦截,我们直接继承了SimpleMappingExceptionResolver并重写了doResolveException,异常类型会通过ex这个参数传入。我们对异常类型进行判断,然后根据判断结果进行处理。

AuthController

package com.gacfox.demo.demoshiro.controller;

import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.UnknownAccountException;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.subject.Subject;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;

/**
 * @author gacfox
 */
@Controller
public class AuthController {
    @RequestMapping(value = "/auth/login", method = RequestMethod.GET)
    public String login() {
        return "auth/login";
    }

    @RequestMapping(value = "/auth/doLogin", method = RequestMethod.POST)
    public String doLogin(@RequestParam String username,
                          @RequestParam String password,
                          Model model) {
        Subject subject = SecurityUtils.getSubject();
        UsernamePasswordToken token = new UsernamePasswordToken(username, password);
        token.setRememberMe(true);
        boolean ok = true;
        try {
            subject.login(token);
        } catch (UnknownAccountException e) {
            ok = false;
            model.addAttribute("errMsg", "未知账号");
        } catch (IncorrectCredentialsException e) {
            ok = false;
            model.addAttribute("errMsg", "密码错误");
        } catch (AuthenticationException e) {
            ok = false;
            model.addAttribute("errMsg", "未知错误");
        }


        if (ok) {
            return "redirect:/admin/dashboard";
        } else {
            return "auth/login";
        }
    }

    @RequestMapping(value = "/auth/doLogout", method = RequestMethod.GET)
    public String doLogout() {
        Subject subject = SecurityUtils.getSubject();
        subject.logout();
        return "redirect:/auth/login";
    }

    @RequestMapping(value = "auth/unauthorized", method = RequestMethod.GET)
    public String unauthorized() {
        return "auth/unauthorized";
    }
}

该控制器实现获取登录页面,以及登录登出功能,其中我们使用的任何和授权相关的信息都是通过调用SecurityUtils实现的。Shiro会自动将登录状态和Session关联。

AdminController

package com.gacfox.demo.demoshiro.controller;

import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.apache.shiro.subject.Subject;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

/**
 * @author gacfox
 */
@Slf4j
@Controller
public class AdminController {
    @RequestMapping(value = "/admin/dashboard", method = RequestMethod.GET)
    public String index(Model model) {
        Subject subject = SecurityUtils.getSubject();

        // debug
        String[] permsCheck = new String[]{
                "doc:query:doc1",
                "doc:create:doc1",
                "doc:update:doc1",
                "doc:delete:doc1",
                "doc:query:doc2",
                "doc:create:doc2",
                "doc:update:doc2",
                "doc:delete:doc2"
        };
        boolean[] permsCheckResult = subject.isPermitted(permsCheck);
        log.debug("能否查询doc1 " + permsCheckResult[0]);
        log.debug("能否创建doc1 " + permsCheckResult[1]);
        log.debug("能否更新doc1 " + permsCheckResult[2]);
        log.debug("能否删除doc1 " + permsCheckResult[3]);
        log.debug("能否查询doc2 " + permsCheckResult[4]);
        log.debug("能否创建doc2 " + permsCheckResult[5]);
        log.debug("能否更新doc2 " + permsCheckResult[6]);
        log.debug("能否删除doc2 " + permsCheckResult[7]);

        return "admin/dashboard";
    }

    @ResponseBody
    @RequiresPermissions("doc:create:doc1")
    @RequestMapping(value = "/admin/createdoc1", method = RequestMethod.GET)
    public String createDoc1() {
        log.debug("createDoc1被调用");
        return null;
    }
}

AdminController中包含的页面接口有Shiro进行认证和鉴权拦截,第一个函数写法其实可以用来实现「根据权限设置菜单」这种页面需求,只要我们把权限信息传入表现层Thymeleaf模板,然后在页面上写出页面展示逻辑即可。如果是前后端分离项目,我们可以把权限信息作为JSON传给前端,前端根据权限信息组织菜单组件。

第二个函数写法通常用来拦截一个请求,认证该操作是否具有相应的权限。注意@RequiresPermissions这个注解,它指定/admin/createdoc1这个HTTP请求路径的访问客体,必须有doc:create:doc1这个权限,如果没有该权限,Shiro的Filter就会抛出异常,我们自定义的ExceptionResolver会拦截该异常,并作出相应的错误处理。

上面代码只是简单的输出一个调试信息,并没有真正做太复杂的页面,但是已经足够讲清楚Shiro的使用方法了。

总结

其实,写了这么多Shiro相关的内容,大部分工作量都集中在前期项目配置上,在团队中由专人配好Shiro框架,普通开发人员只要标注几个注解就能方便的实现权限控制,这样就极大的提高了开发效率,而且提升了项目的健壮性、可维护性和安全性,这就是使用Shiro的好处。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。