1.概述

基本上,在所有的开发的系统中,都必须做认证(authentication)和授权(authorization),以保证系统的安全性。
简单来说:认证解决“你是谁”的问题,授权解决“你能做什么”的问题。
在Java生态中,目前两大安全框架是Spring Security和Apache Shiro,可以用来完成认证和授权的功能。

关于Shiro:
Apache Shiro 是一个功能强大且易于使用的 Java 安全框架,它可以提供身份验证、授权、加密和会话管理的功能。
通过 Shiro 易于理解的 API ,你可以快速、轻松地保护任何应用程序 —— 从最小的移动端应用程序到大型的的 Web 和企业级应用程序。

2.快速入门

2.1 引入依赖

<!--实现对Spring MVC的自动化配置-->
      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-web</artifactId>
      </dependency>

      <!--实现对shiro的自动化配置-->
      <dependency>
          <groupId>org.apache.shiro</groupId>
          <artifactId>shiro-spring-boot-starter</artifactId>
          <version>1.4.2</version>
      </dependency>

2.2 ShiroConfig

创建com.gavin.shiro.config.ShiroConfig配置类,用来实现Shiro的自定义配置,代码如下:

@Configuration
public class ShiroConfig {
    @Bean
    public Realm realm(){

    }
    @Bean
    public DefaultWebSecurityManager securityManager(){

    }
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(){

    }
}

下面重点分析三个Bean的配置。

2.2.1 Realm

Realm是可以访问程序特定的安全数据如用户、角色、权限等的一个组件,Realm可以将这些程序特定的安全数据转换成一种Shiro可以理解的形式。简单说:Realm的职责就是进行身份认证授权

Realm整体的类图如下:
在这里插入图片描述
Realm接口,主要定义了认证的方法,代码如下:

public interface Realm {
    String getName();

    boolean supports(AuthenticationToken var1);

    AuthenticationInfo getAuthenticationInfo(AuthenticationToken var1) throws AuthenticationException;
}

AuthorizingRealm抽象类,额外定义了授权方法,代码如下:

protected abstract AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection var1);

同时它又实现了Authorizer接口,提供判断经过认证过的Subject是否具有指定的角色、权限方法。

从Realm类图结构可以看出,Shiro提供了多种AuthorizingRealm的实现类,提供从不同的数据源获取数据,不过在一般的项目中,我们会自定义实现Authorizing Realm来从自己定义的表结构中读取用户、角色、权限等数据。虽然Shiro提供了JdbcRealm可以访问数据库,但是它的表结构是固定的,所以我们才选择自定义实现AuthorizingRealm。

在本示例中,在com.gavin.shiro.config.ShiroConfig#realm方法中,我们创建了SimpleAccountRealm对象,代码如下:

@Bean
public Realm realm(){
    //创建SimpleAccountRealm对象
    SimpleAccountRealm realm = new SimpleAccountRealm();
    //添加两个用户,参数分别是username、password、roles
    realm.addAccount("admin","admin","ADMIN");
    realm.addAccount("normal","normal","NORMAL");
    return realm;
}

在该方法中,我们添加了两个用户,分别对应ADMIN和NORMAL角色。

2.2.2 SecurityManager

SecurityManager是Shiro架构的核心,配合内部安全组件共同组成安全伞。
在本示例中,在com.gavin.shiro.config.ShiroConfig#securityManager方法中,我们创建了DefaultWebSecurityManager对象,代码如下:

@Bean
 public DefaultWebSecurityManager securityManager(){
     //创建DefaultWebSecurityManager对象
     DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
     //设置其使用的Realm
     securityManager.setRealm(this.realm());
     return securityManager;
 }

2.2.3 ShiroFilter

通过AbstractShiroFilter过滤器,实现对请求的拦截,从而实现Shiro的功能,AbstractShiroFilter整体的类图如下:
在这里插入图片描述
在本示例中,在com.gavin.shiro.config.ShiroConfig#shiroFilterFactoryBean方法中,我们创建了ShiroFilterFactoryBean对象,代码如下:

@Bean
  public ShiroFilterFactoryBean shiroFilterFactoryBean(){
      //<1>创建ShiroFilterFactoryBean对象,用于创建ShiroFilter过滤器
      ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean();

      //<2>设置其SecurityManager属性
      filterFactoryBean.setSecurityManager(this.securityManager());

      //<3>设置URL
      //登录URL
      filterFactoryBean.setLoginUrl("/login");
      //登录成功URL
      filterFactoryBean.setSuccessUrl("/login_success");
      //无权限URL,在请求校验权限不通过时,会重定向到该URL上
      filterFactoryBean.setUnauthorizedUrl("/unauthorized");

      //<4>设置URL的权限配置
      filterFactoryBean.setFilterChainDefinitionMap(this.filterChainDefinitionMap());

      return filterFactoryBean;
  }

在介绍filterChainDefinitionMap方法的具体URL的权限配置之前,我们先来了解一下Shiro内置的过滤器,在DefaultFilter枚举类中,枚举了这些过滤器,以及其配置名:
在这里插入图片描述
比较常用的过滤器有:

  • anon:AnonymousFilter,允许匿名访问,无需登录;

  • authc:FormAuthenticationFilter,需要经过认证的用户才可以访问,如果是匿名用户,则根据URL不同,会有不同的处理:
    1、如果拦截的URL是GET loginUrl登录页面,则进行该请求,跳转到登录页面;
    2、如果拦截的URL是POST loginUrl登录请求,则基于请求表单的username、password进行认证,认证通过后,默认重定向到GET loginSuccessUrl地址;
    3、如果拦截的URL是其他URL时,则记录该URL到Session中,在用户登录成功后,重定向到该URL上。

  • logout:LogoutFilter,拦截的URL,执行退出登录,退出完成后,重定向到GET loginUrl登录页面。

  • roles:RolesAuthorizationFilter,拥有指定角色的用户可访问;

  • perms:PermissionsAuthorizationFilter,拥有指定权限的用户可以访问。

    下面,让我们回过头来看看#filterChainDefinitionMap()方法的具体URL的权限配置,代码如下:

  /** URL的权限配置*/
	private Map<String, String> filterChainDefinitionMap() {
    //使用有序的LinkedHashMap,用来实现顺序匹配
    LinkedHashMap<String,String> filterMap = new LinkedHashMap<>();
    //允许匿名访问
    filterMap.put("/test/echo","anon");
    //需要ADMIN角色才能访问
    filterMap.put("/test/admin","roles[ADMIN]");
    //需要NORMAL角色才能访问
    filterMap.put("/test/normal","roles[NORMAL]");
    //注销登录
    filterMap.put("/logout","logout");
    //默认剩余的URL,都要经过认证后才能访问
    filterMap.put("/**","authc");
    return filterMap;
}

这里补充一点,请求在ShiroFilter拦截之后,会根据该请求的情况,匹配到配置中内置的所有Shio Filter,逐个进行处理,也就是说Shiro Filter内部有一个由内置的Shiro Filter组成的过滤器链。

2.3 SecurityController

创建一个SecurityController类,用来提供登录,登录成功等接口,代码如下:

com.gavin.shiro.controller.SecurityController

@Controller
@RequestMapping("/")
public class SecurityController {
    private Logger logger = LoggerFactory.getLogger(getClass());

    @GetMapping("/login")
    public String loginPage(){

    }

    @ResponseBody
    @PostMapping("/login")
    public String login(HttpServletRequest request){

    }

    @ResponseBody
    @GetMapping("/login_success")
    public String loginSuccess(){

    }

    @ResponseBody
    @GetMapping("/unauthorized")
    public String unauthorized(){

    }
}

2.3.1 登录页面

GET /login地址,来到登录页面,代码如下:

@GetMapping("/login")
  public String loginPage(){
      return "login.html";
  }

login.html静态页面代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录页面</title>
</head>
<body>
    <form action="/login" method="post">
        用户名:<input type="text" name="username"/><br/>
        密码:<input type="password" name="password"/><br/>
        <input type="submit" value="登录"/>
    </form>
</body>
</html>

POST提交登录请求到/login地址。

2.3.2 登录请求

对于登录请求,会被我们配置的FormAuthenticationFilter过滤器进行拦截,进行用户的身份认证,过程如下:

  • FormAuthenticationFilter解析请求的usernamepassword参数,创建UsernamePasswordToken对象;

  • 然后,调用SecurityManager的login(Subject subject, AuthenticationToken authenticationToken)方法,执行登录操作,进行“身份验证”(认证);

  • 在这内部其实是调用Realm的个体Authentication Info(AuthenticationToken token)方法进行认证,此时根据认证是否成功,会有不同的处理方式:
    1、如果认证通过,则FormAuthenticationFilter会将请求重定向到Get loginSuccess地址上;
    2、如果认证失败,则会将认证失败的原因设置到请求的attributes中,后续该请求会继续请求到POST login地址上,这样,在POST loginUrl地址上,我们可以从attributes中获取到失败的原因提示给用户。

    所以,POST loginUrl的目的实际上是为了处理认证失败的情况,其实现的代码如下:

@ResponseBody
   @PostMapping("/login")
   public String login(HttpServletRequest request){

       // <1> 判断是否已经登录
       Subject subject = SecurityUtils.getSubject();
       if(subject.getPrincipal() != null){
           return "你已经登录,无需重复登录"+subject.getPrincipal();
       }

       // <2> 获得登录失败的原因
       String shiroLoginFailure = (String) request.getAttribute(FormAuthenticationFilter.DEFAULT_ERROR_KEY_ATTRIBUTE_NAME);

       // 翻译成人类看得懂的提示
       String msg = "";
       if(UnknownAccountException.class.getName().equals(shiroLoginFailure)){
           msg = "账号不存在";
       }else if(IncorrectCredentialsException.class.getName().equals(shiroLoginFailure)){
           msg = "密码不正确";
       }else if(LockedAccountException.class.getName().equals(shiroLoginFailure)){
           msg = "账号被锁定";
       }else if(ExpiredCredentialsException.class.getName().equals(shiroLoginFailure)){
           msg = "账号已过期";
       }else{
           msg = "未知错误,请联系管理员";
           logger.error("[login][未知错误:{}]",shiroLoginFailure);
       }
       return "登录失败,原因:" + msg;
   }

2.3.3 登录成功

GET login_success地址,登录成功后响应,代码如下:

@ResponseBody
   @GetMapping("/login_success")
   public String loginSuccess(){
       return "恭喜你,登录成功!!";
   }

如果是AJAX请求,我们可以返回json数据;
如果是非AJAX请求,我们可以重定向到登录成功的页面,比如管理后台的home页面。

2.3.4 未授权

GET unauthorized地址,未授权响应,代码如下:

@ResponseBody
   @GetMapping("/unauthorized")
   public String unauthorized(){
       return "对不起,您没有权限进行操作";
   }

如果是AJAX请求,我们可以返回json数据;
如果是非AJAX请求,我们可以重定向到登录页面。

2.4 TestController

测试API接口:

@Controller
@RequestMapping("/test")
public class TestController {

    @GetMapping("/demo")
    public String demo(){
        return "示例返回";
    }

    @GetMapping("/home")
    public String home(){
        return "首页";
    }
    
    @GetMapping("/admin")
    public String admin(){
        return "我是管理员";
    }

    @GetMapping("/normal")
    public String normal(){
        return "我是普通用户";
    }
}
  • 对于 /test/demo 接口,直接访问,无需登陆。
  • 对于 /test/home 接口,无法直接访问,需要进行登陆。
  • 对于 /test/admin 接口,需要登陆「admin/admin」用户,因为需要 ADMIN 角色。
  • 对于 /test/normal 接口,需要登陆「user/user」用户,因为需要 USER 角色。

3.Shiro注解

在Shiro中,提供了如下5个注解,可以直接添加在SpringMVC的URL对应的方法上,实现权限配置:

3.1 @RequiresGuest

等同于anon

3.2 @RequiresAuthentication

等同于authc

3.3 @RequiresUser

等同于user,要求必须登录

3.4 @RequiresRoles

和roles等价,代码如下:

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresRoles {

    String[] value();
	//当有多个角色时,AND表示要拥有全部角色,OR表示拥有任意角色即可
    Logical logical() default Logical.AND; 
}

示例代码如下:

// 属于 NORMAL 角色
@RequiresRoles("NORMAL")

// 要同时拥有 ADMIN 和 NORMAL 角色
@RequiresRoles({"ADMIN", "NORMAL"})

// 拥有 ADMIN 或 NORMAL 任一角色即可(OR)
@RequiresRoles(value = {"ADMIN", "NORMAL"}, logical = Logical.OR)

如果验证角色不通过,就会抛出AuthorizationException异常,此时我们可以基于Spring MVC提供的@RestControllerAdvice+@ExceptionHandler注解,实现全局异常的处理。
不了解可以看看《芋道 Spring Boot SpringMVC 入门》的「5. 全局异常处理」小节。

3.5 @RequiresPermissions

等价于perms,示例代码如下:

// 拥有 user:add 权限
@RequiresPermissions("user:add")

// 要同时拥有 user:add 和 user:update 权限
@RequiresPermissions({"user:add", "user:update"})

// 拥有 user:add 和 user:update 任一权限即可
@RequiresPermissions(value = {"user:add", "user:update"}, logical = Logical.OR)

同样如果验证权限不通过,则会抛出AuthorizationException异常。

3.6 使用示例

新建DemoController类,提供示例API接口,代码如下:

@RestController
@RequestMapping("/demo")
public class DemoController {
    @RequiresGuest
    @GetMapping("/echo")
    public String demo(){
        return "示例返回";
    }

    @GetMapping("/home")
    public String home(){
        return "首页";
    }

    @RequiresRoles("ADMIN")
    @GetMapping("/admin")
    public String admin(){
        return "我是管理员";
    }

    @RequiresRoles("NORMAL")
    @GetMapping("/normal")
    public String normal(){
        return "我是普通用户";
    }
}

参考文献:
https://mp.weixin.qq.com/s/NmwqOM5rSDlmvs-Bi4P2Ag
github源码地址:点这里