Spring Boot 安全框架 Shiro 入门

一、前言

在所有的开发的系统中,都必须做认证(authentication)和授权(authorization),以保证系统的安全性。考虑到很多读者对认证和授权有点分不清楚。

authentication [ɔ,θɛntɪ'keʃən] 认证
authorization [,ɔθərɪ'zeʃən] 授权

1.1 以坐飞机举例子:

  • 【认证】你要登机,你需要出示你的 passport 和 ticket,passport 是为了证明你张三确实是你张三,这就是 authentication。
  • 【授权】而机票是为了证明你张三确实买了票可以上飞机,这就是 authorization。

1.2 以论坛举例子:

  • 【认证】你要登录论坛,输入用户名张三,密码 1234,密码正确,证明你张三确实是张三,这就是 authentication。
  • 【授权】再一 check 用户张三是个版主,所以有权限加精删别人帖,这就是 authorization 。
    所以简单来说:认证解决“你是谁”的问题,授权解决“你能做什么”的问题。另外,在推荐阅读下《认证、授权、鉴权和权限控制》 文章,更加详细明确

在 Java 生态中,目前有 Spring Security 和 Apache Shiro 两个安全框架,可以完成认证和授权的功能。本文,我们再来学习下 Apache Shiro 。其官方对自己介绍如下:
Apache Shiro™ 是一个功能强大且易于使用的 Java 安全框架,它可以提供身份验证、授权、加密和会话管理的功能。
通过 Shiro 易于理解的 API ,你可以快速、轻松地保护任何应用程序 —— 从最小的移动端应用程序到大型的的 Web 和企业级应用程序。

二、 快速入门

2.1 引入依赖



    
        org.springframework.boot
        spring-boot-starter-parent
        2.3.0.RELEASE
         
    
    4.0.0

    Apache-shiro

    
        
        
            org.springframework.boot
            spring-boot-starter-web
        

        
        
            org.apache.shiro
            shiro-spring-boot-starter
            1.5.3
        
    


shiro-spring-boot-starter依赖对 Shiro 的自动化配置基本没啥用,需要下面的这个类ShiroConfig自己来主动实现对 Shiro 的配置。

2.2 ShiroConfig

实现 Shiro 的自定义配置。代码如下:

package com.erbadagang.springboot.shiro.config;

import org.apache.shiro.realm.Realm;
import org.apache.shiro.realm.SimpleAccountRealm;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.LinkedHashMap;
import java.util.Map;

@Configuration
public class ShiroConfig {

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

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

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

        // 设置 SecurityManager
        filterFactoryBean.setSecurityManager(this.securityManager());

        // 设置 URL 们
        filterFactoryBean.setLoginUrl("/login"); // 登陆 URL
        filterFactoryBean.setSuccessUrl("/login_success"); // 登陆成功 URL
        filterFactoryBean.setUnauthorizedUrl("/unauthorized"); // 无权限 URL

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

        return filterFactoryBean;
    }

    private Map filterChainDefinitionMap() {
        Map filterMap = new LinkedHashMap<>(); // 注意要使用有序的 LinkedHashMap ,顺序匹配
        filterMap.put("/test/echo", "anon"); // 允许匿名访问
        filterMap.put("/test/admin", "roles[ADMIN]"); // 需要 ADMIN 角色
        filterMap.put("/test/normal", "roles[NORMAL]"); // 需要 NORMAL 角色
        filterMap.put("/logout", "logout"); // 退出
        filterMap.put("/**", "authc"); // 默认剩余的 URL ,需要经过认证
        return filterMap;
    }

}

一共有三个 Bean 的配置,我们逐个来看看。

2.2.1 Realm

我们先来看看 Realm 的定义。“身份验证”(认证)和“授权”,这个就是 Realm 的职责。

Realm 整体的类图如下:
Realm 类图
  • Realm 接口,主要定义了“认证”方法。代码如下:
// Realm.java

    AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException;
  • AuthorizingRealm 抽象类,主要额外定义了授权方法。代码如下:
// AuthorizingRealm.java
    protected abstract AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals);
  • AuthorizingRealm 同时实现了 Authorizer 接口,提供判断经过认证过的 Subject 是否具有指定的角色、权限的方法。
  • 从图中我们可以看出,Shiro 提供了多种 AuthorizingRealm 的实现类,提供从不同的数据源获取数据。不过一般在项目中,我们会自定义实现 AuthorizingRealm ,从自己定义的表结构中读取用户、角色、权限等数据。虽然说,Shiro 提供了 JdbcRealm 可以访问数据库,但是它的表结构是固定的,所说我们才要自定义定义实现 AuthorizingRealm

本示例中,在 #realm() 方法,我们创建了 SimpleAccountRealm Bean 对象。代码如上所示:

  • SimpleAccountRealm 是使用内存作为数据源,我们可以手动往里面添加用户、角色、权限等数据。毕竟作为一个示例,不想引入数据库,增加复杂性。不过我们在后续文章中,我们会看到我们使用自定义的 AuthorizingRealm 实现类。
  • 在该方法里,我们添加了「admin/admin」和「normal/normal」两个用户,分别对应 ADMIN 和 NORMAL 角色。

2.2.2 SecurityManager

我们再来看看 SecurityManager 的定义,SecurityManager 是 Shiro 架构的核心,配合内部安全组件共同组成安全伞。
本示例中,在 #securityManager() 方法,我们创建了 DefaultWebSecurityManager Bean 对象。代码如下:

// ShiroConfig.java

@Bean
public DefaultWebSecurityManager securityManager() {
 // 创建 DefaultWebSecurityManager 对象
 DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
 // 设置其使用的 Realm
 securityManager.setRealm(this.realm());
 return securityManager;
}
  • 不用特别去纠结 SecurityManager ,创建好 DefaultWebSecurityManager Bean 就完事了~等后续我们入门完 Shiro 之后,胖友可以在慢慢细细去研究。

2.2.3 ShiroFilter

通过 AbstractShiroFilter 过滤器,实现对请求的拦截,从而实现 Shiro 的功能。AbstractShiroFilter 整体的类图如下:

AbstractShiroFilter 类图

本示例中,在 #shiroFilterFactoryBean() 方法,我们创建了 ShiroFilterFactoryBean Bean 对象。代码如下:

// ShiroConfig.java

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

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

 // <3> 设置 URL 们
 filterFactoryBean.setLoginUrl("/login"); // 登录 URL
 filterFactoryBean.setSuccessUrl("/login_success"); // 登录成功 URL
 filterFactoryBean.setUnauthorizedUrl("/unauthorized"); // 无权限 URL

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

 return filterFactoryBean;
}
  • <1> 处,创建 ShiroFilterFactoryBean 对象,用于创建 SpringShiroFilter 过滤器。

  • <2> 处,设置其 SecurityManager 属性。

  • <3> 处,设置各种 URL 。

    • #setLoginUrl(String loginUrl) 方法,设置登录 URL 。在 Shiro 中,约定 GET loginUrl 为登录页面,POST loginUrl 为登录请求。
    • #setSuccessUrl(String successUrl) 方法,设置登录成功 URL 。在登录成功时,会重定向到该 URL 上。
    • #etUnauthorizedUrl(String unauthorizedUrl) 方法,设置无权限的 URL 。在请求校验权限不通过时,会重定向到该 URL 上。
    • 上述的 URL 对应的接口,都需要我们自己来实现。具体可见「2.3 SecurityController」小节。
  • <4> 处,调用 #setFilterChainDefinitionMap(Map filterChainDefinitionMap) 方法,设置 URL 的权限配置。

在看 #filterChainDefinitionMap() 方法的具体 URL 的权限配置之前,我们先来了解下 Shiro 内置的过滤器们。在 Shiro DefaultFilter 枚举类中,枚举了这些过滤器,以及其配置名。整理表格如下:

Filter Name Class
anon org.apache.shiro.web.filter.authc.AnonymousFilter
authc org.apache.shiro.web.filter.authc.FormAuthenticationFilter
authcBasic org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter
logout org.apache.shiro.web.filter.authc.LogoutFilter
noSessionCreation org.apache.shiro.web.filter.session.NoSessionCreationFilter
perms org.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter
port org.apache.shiro.web.filter.authz.PortFilter
rest org.apache.shiro.web.filter.authz.HttpMethodPermissionFilter
roles org.apache.shiro.web.filter.authz.RolesAuthorizationFilter
ssl org.apache.shiro.web.filter.authz.SslFilter
user org.apache.shiro.web.filter.authc.UserFilter

比较常用的过来器有:

  • anon :AnonymousFilter :允许匿名访问,即无需登录。
  • authc :FormAuthenticationFilter :需要经过认证的用户,才可以访问。如果是匿名用户,则根据 URL 不同,会有不同的处理:
    • 如果拦截的 URL 是 GET loginUrl 登录页面,则进行该请求,跳转到登录页面。
    • 如果拦截的 URL 是 POST loginUrl 登录请求,则基于请求表单的 usernamepassword 进行认证。认证通过后,默认重定向到 GET loginSuccessUrl 地址。
    • 如果拦截的 URL 是其它 URL 时,则记录该 URL 到 Session 中。在用户登录成功后,重定向到该 URL 上。
  • logout :LogoutFilter :拦截的 URL ,执行退出操作。退出完成后,重定向GET loginUrl 登录页面。
  • roles :RolesAuthorizationFilter :拥有指定角色的用户可访问。
  • perms :PermissionsAuthorizationFilter :拥有指定权限的用户可以访问。

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

    private Map filterChainDefinitionMap() {
        Map filterMap = new LinkedHashMap<>(); // 注意要使用有序的 LinkedHashMap ,顺序匹配
        filterMap.put("/test/echo", "anon"); // 允许匿名访问
        filterMap.put("/test/admin", "roles[ADMIN]"); // 需要 ADMIN 角色
        filterMap.put("/test/normal", "roles[NORMAL]"); // 需要 NORMAL 角色
        filterMap.put("/logout", "logout"); // 退出
        filterMap.put("/**", "authc"); // 默认剩余的 URL ,需要经过认证
        return filterMap;
    }
  • /test/echo :我们设置为 anon ,允许匿名访问。
  • /test/admin/test/normal :我们设置为 roles[...] ,需要指定角色的用户可以访问。其中 ... 处为需要添加的角色名。
  • /logout :我们设置为 logout ,实现退出操作。
  • /** :剩余的 URL ,我们设置为 authc ,需要登录的用户才可以访问。同时,对于 loginUrl 需要执行登录相关的拦截。

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

至此,我们已经完成了 Shiro 的自定义配置。虽然篇幅有点长,但是可以等我们跑完整个示例之后,再自己回过头来看看,会发现还是比较清晰明了的。

2.3 SecurityController

提供登录、登录成功等接口。代码如下:

package com.erbadagang.springboot.shiro.controller;

import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.ExpiredCredentialsException;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.LockedAccountException;
import org.apache.shiro.authc.UnknownAccountException;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.authc.FormAuthenticationFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.servlet.http.HttpServletRequest;

@Controller
@RequestMapping("/")
public class SecurityController {

    private Logger logger = LoggerFactory.getLogger(getClass());

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

    @ResponseBody
    @PostMapping("/login")
    public String login(HttpServletRequest request) {
        // 判断是否已经登陆
        Subject subject = SecurityUtils.getSubject();
        if (subject.getPrincipal() != null) {
            return "你已经登陆账号:" + subject.getPrincipal();
        }

        // 获得登陆失败的原因
        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;
    }

    @ResponseBody
    @GetMapping("/login_success")
    public String loginSuccess() {
        return "登陆成功";
    }

    @ResponseBody
    @GetMapping("/unauthorized")
    public String unauthorized() {
        return "你没有权限";
    }

}

2.3.1 登录页面

GET /login 地址,跳转登录页面。代码如下:

// SecurityController.java

@GetMapping("/login")
public String loginPage() {
 return "login.html";
}
  • 返回 resources/static/login.html 静态页面。代码如下:

  
  
   
   登录页面
  
  
   
用户名:
密码:
  • 一个简单的登录的表单,POST 提交登录请求到 /login 地址上。

2.3.2 登录请求

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

  • FormAuthenticationFilter 解析请求的 usernamepassword 参数,创建 UsernamePasswordToken 对象。
  • 然后,调用 SecurityManager 的 #login(Subject subject, AuthenticationToken authenticationToken) 方法,执行登录操作,进行“身份验证”(认证)。
  • 在这内部中,调用 Realm 的 #getAuthenticationInfo(AuthenticationToken token) 方法,进行认证。此时,根据认证的是否成功,会有不同的处理:
    • 如果认证通过,则 FormAuthenticationFilter 会将请求重定向GET loginSuccess 地址上。
    • 【重要】如果认证失败,则会将认证失败的原因设置到请求的 attributes 中,后续该请求会继续请求到 POST login 地址上。这样,在 POST loginUrl 地址上,我们可以从 attributes 中获取到失败的原因,提示给用户。

所以,POST loginUrl 的目的,实际是为了处理认真失败的情况。也因此,POST login 地址,实现代码如下:

// SecurityController.java

@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;
}
  • <1> 处,对于已经登录成功的用户,如果我们再次请求 POST loginUrl 地址,依然会直接跳转到该地址上。此处,我们是提供用户已经的登录。可能会希望重新进行一次登录的逻辑,那么就需要重写 FormAuthenticationFilter 过滤器。
  • <2> 处,从请求的 attributes 中,获取 FormAuthenticationFilter.DEFAULT_ERROR_KEY_ATTRIBUTE_NAME 对应的值,即登录失败的原因。从代码中,我们可以看出,失败原因为异常的全类名,我们需要进行翻译成人类可读的提示。

2.3.3 登录成功

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

// SecurityController.java

@ResponseBody
@GetMapping("/login_success")
public String loginSuccess() {
 return "登录成功";
}
  • 如果是 AJAX 请求的情况下,我们可以返回 JSON 字符串。例如说,用户、角色、权限等等信息。
  • 如果非 AJAX 请求的情况下,重定向到登录成功的页面。例如说,管理后台的 HOME 页面。

2.3.4 未授权

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

// SecurityController.java

@ResponseBody
@GetMapping("/unauthorized")
public String unauthorized() {
 return "你没有权限";
}
  • 如果是 AJAX 请求的情况下,我们可以返回 JSON 字符串。例如说,你没有权限。
  • 如果非 AJAX 请求的情况下,重定向到登录成功的页面。例如说,未授权的页面。

2.4 TestController

在 [controller]包路径下,创建 TestController 类,提供测试 API 接口。代码如下:

// TestController.java

@RestController
@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 角色。

胖友可以按照如上的说明,进行各种测试。例如说,登录「user/user」用户后,去访问 /test/admin 接口,会返回无权限的提示~

2.5 Application

创建 Application.java 类,配置 @SpringBootApplication 注解即可。代码如下:

// Application.java

@SpringBootApplication
public class Application {

 public static void main(String[] args) {
 SpringApplication.run(Application.class, args);
 }

}

至此,我们已经完成了 Shiro 的入门。可以自己多多测试一下。

三、Shiro注解

在 Shiro 中,提供了如下五个注解,可以直接添加在 SpringMVC 的 URL 对应的方法上,实现权限配置。下面,我们来分别看看。

3.1 @RequiresGuest

@RequiresGuest 注解,和 anon 等价。

3.2 @RequiresAuthentication

@RequiresAuthentication 注解,和 authc 等价。

3.3 @RequiresUser

@RequiresUser 注解,和 user 等价,要求必须登录。

3.4 @RequiresRoles

@RequiresRoles 注解,和 roles 等价。代码如下:

// RequiresRoles.java

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

 /**
 * A single String role name or multiple comma-delimited role names required in order for the method
 * invocation to be allowed.
 */
 String[] value();

 /**
 * The logical operation for the permission check in case multiple roles are specified. AND is the default
 * @since 1.1.0
 * 当有多个角色时,AND 表示要拥有全部角色,OR 表示拥有任一角色即可
 */
 Logical logical() default Logical.AND; 
}

使用示例如下:

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

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

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

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

3.5 @RequiresPermissions

@RequiresPermissions 注解,和 perms 等价。代码如下:

// RequiresPermissions.java

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

 /**
 * The permission string which will be passed to {@link org.apache.shiro.subject.Subject#isPermitted(String)}
 * to determine if the user is allowed to invoke the code protected by this annotation.
 */
 String[] value();

 /**
 * The logical operation for the permission checks in case multiple roles are specified. AND is the default
 * @since 1.1.0
 * 当有多个权限时,AND 表示要拥有全部权限,OR 表示拥有任一权限即可
 */
 Logical logical() default Logical.AND; 

}

使用示例如下:

// 拥有 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 异常。此时,我们可以基于 Spring MVC 提供的 @RestControllerAdvice + @ExceptionHandler 注解,实现全局异常的处理。不了解的胖友,可以看看另外一篇文章的全局异常处理小节。

底线


本文源代码使用 Apache License 2.0开源许可协议,这里是本文源码Gitee地址,可通过命令git clone+地址下载代码到本地,也可直接点击链接通过浏览器方式查看源代码。

你可能感兴趣的:(Spring Boot 安全框架 Shiro 入门)