Spring Boot Shiro 入门

1. 概述

艿艿:本文是《芋道 Spring Boot 安全框架 Spring Security 入门》 的姊妹篇,所以开头就“重复”再来一遍,嘿嘿。

基本上,在所有的开发的系统中,都必须做认证(authentication)和授权(authorization),以保证系统的安全性。 考虑到很多胖友对认证和授权有点分不清楚,艿艿这里引用一个网上有趣的例子:

FROM 《认证 (authentication) 和授权 (authorization) 的区别》

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

打飞机举例子:

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

论坛举例子:

  • 【认证】你要登陆论坛,输入用户名张三,密码 1234,密码正确,证明你张三确实是张三,这就是 authentication。
  • 【授权】再一 check 用户张三是个版主,所以有权限加精删别人帖,这就是 authorization 。

所以简单来说:认证解决“你是谁”的问题,授权解决“你能做什么”的问题。另外,在推荐阅读下《认证、授权、鉴权和权限控制》 文章,更加详细明确

在 Java 生态中,目前有 Spring Security 和 Apache Shiro 两个安全框架,可以完成认证和授权的功能。本文,我们再来学习下 Apache Shiro 。其官方对自己介绍如下:

FROM 《Apache Shiro 官网》

Apache Shiro™ is a powerful and easy-to-use Java security framework that performs authentication, authorization, cryptography, and session management.
Apache Shiro™ 是一个功能强大且易于使用的 Java 安全框架,它可以提供身份验证、授权、加密和会话管理的功能。

With Shiro’s easy-to-understand API, you can quickly and easily secure any application – from the smallest mobile applications to the largest web and enterprise applications.
通过 Shiro 易于理解的 API ,你可以快速、轻松地保护任何应用程序 —— 从最小的移动端应用程序到大型的的 Web 和企业级应用程序。

更多关于 Shiro 的介绍,胖友可以自行阅读《Apache Shiro 1.2.x 参考手册》 ,虽然目前 Shiro 截止到目前已经发布到 1.4.2 版本,但是该手册依然很有参考价值。

2. 快速入门

示例代码对应仓库:lab-33-shiro-demo 。

在本小节中,我们来对 Shiro 进行快速的入门,实现一个最小化的使用示例。

2.1 引入依赖

在 pom.xml 文件中,引入相关依赖。



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

    lab-33-shiro-demo

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

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

不过实际上,shiro-spring-boot-starter 依赖对 Shiro 的自动化配置基本没啥用,需要自己来主动实现对 Shiro 的配置。

2.2 ShiroConfig

在 cn.iocoder.springboot.lab01.shirodemo.config 包下,创建 ShiroConfig 抽象类,实现 Shiro 的自定义配置。代码如下:

// ShiroConfig.java

@Configuration
public class ShiroConfig {

    @Bean
    public Realm realm() { /**省略代码**/ }

    @Bean
    public DefaultWebSecurityManager securityManager() { /**省略代码**/ }

    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean() { /**省略代码**/ }

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

2.2.1 Realm

我们先来看看 Realm 的定义。

《Apache Shiro 1.2.x 参考手册 —— Realms》

Realm 是可以访问程序特定的安全数据如用户、角色、权限等的一个组件。Realm 会将这些程序特定的安全数据转换成一种 Shiro 可以理解的形式,Shiro 就可以依次提供容易理解的 Subject 程序API而不管有多少数据源或者程序中你的数据如何组织。

Realm 通常和数据源是一对一的对应关系,如关系数据库,LDAP 目录,文件系统,或其他类似资源。因此,Realm 接口的实现使用数据源特定的API 来展示授权数据(角色,权限等),如JDBC,文件IO,Hibernate 或JPA,或其他数据访问API。

Realm 实质上就是一个特定安全的 DAO

因为这些数据源大多通常存储身份验证数据(如密码的凭证)以及授权数据(如角色或权限),每个 Shiro Realm 能够执行身份验证和授权操作。

  • 好长一段描述,抓重点,最后一句的“身份验证”(认证)和“授权”,这个就是 Realm 的职责。

Realm 整体的类图如下:

Spring Boot Shiro 入门_第1张图片

  • 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 对象。代码如下:

// ShiroConfig.java

@Bean
public Realm realm() {
    // 创建 SimpleAccountRealm 对象
    SimpleAccountRealm realm = new SimpleAccountRealm();
    // 添加两个用户。参数分别是 username、password、roles 。
    realm.addAccount("admin", "admin", "ADMIN");
    realm.addAccount("normal", "normal", "NORMAL");
    return realm;
}
  • SimpleAccountRealm 是使用内存作为数据源,我们可以手动往里面添加用户、角色、权限等数据。 毕竟作为一个示例,艿艿不想引入数据库,增加复杂性。不过我们在「3. 项目实战」中,我们会看到我们使用自定义的 AuthorizingRealm 实现类。
  • 在该方法里,我们添加了「admin/admin」和「normal/normal」两个用户,分别对应 ADMIN 和 NORMAL 角色。

2.2.2 SecurityManager

我们再来看看 SecurityManager 的定义。

《Apache Shiro 1.2.x 参考手册 —— Session Management》

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 整体的类图如下:Spring Boot Shiro 入门_第2张图片

本示例中,在 #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 的权限配置。代码如下:

// ShiroConfig.java

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. 快速入门」的示例之后,胖友再自己回过头来看看,会发现还是比较清晰明了的。

2.3 SecurityController

在 cn.iocoder.springboot.lab01.shirodemo.controller 包路径下,创建 SecurityController 类,提供登陆、登陆成功等接口。代码如下:

// SecurityController.java

@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() { /**省略代码**/ }

}
  • 一共有 4 个接口,我们逐个来看看。

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

在 cn.iocoder.springboot.lab01.shirodemo.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 的入门。美滋滋,胖友可以自己多多测试一下。

3. 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 注解,实现全局异常的处理。不了解的胖友,可以看看《芋道 Spring Boot SpringMVC 入门》的「5. 全局异常处理」小节。

3.6 使用示例

在 lab-33-shiro-demo 示例的基础上,我们进行修改,增加 Shiro 注解的使用。

在 cn.iocoder.springboot.lab01.shirodemo.controller 包路径下,创建 DemoController 类,提供示例 API 接口。代码如下:

// DemoController.java

@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 "我是普通用户";
    }

}
  • 每个 URL 的权限验证,和「3.2.2 TestController」是一一对应的。

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

4. 项目实战

在开源项目翻了一圈,找到一个相对合适项目 renren-fast 。主要以下几点原因:

  • 基于 Shiro 实现。
  • 基于 RBAC 权限模型,并且支持动态的权限配置。
  • 基于 OAuth2 授权认证。
  • 前后端分离。同时前端采用 Vue ,相对来说后端会 Vue 的比 React 的多。

考虑到方便自己添加注释,艿艿 Fork 出一个仓库, 地址是 https://github.com/YunaiV/renren-fast 。

下面,来跟着艿艿一起走读下 renren-fast 的权限相关功能。

4.1 表结构

基于 RBAC 权限模型,一共有 5 个表。

对 RBAC 权限模型不了解的胖友,可以看看《到底什么是RBAC权限模型?!》

嘻嘻,艿艿的大学毕业设计,做的就是统一认证中心,2011 年的时候,前后端分离。前端采用 ExtJS 框架,后端自己参考 Spring Security 造的权限框架的轮子,提供 SDK 接入统一认证中心,使用 HTTP 通信。

实体 说明
SysUserEntity sys_user 用户信息
SysRoleEntity sys_role 用户信息
SysUserRoleEntity sys_user_role 用户和角色关联
SysMenuEntity sys_menu 菜单权限
SysRoleMenuEntity sys_role_menu 角色和菜单关联

5 个表的关系比较简单:

  • 一个 SysUse ,可以拥有多个 SysRole ,通过 SysUserRole 存储关联。
  • 一个 SysRole ,可以拥有多个 SysMenu ,通过 SysRoleMenu 存储关联。

4.1.1 SysUserEntity

SysUserEntity ,用户实体类。代码如下:

// SysUserEntity.java

@Data
@TableName("sys_user")
public class SysUserEntity implements Serializable {
    
    private static final long serialVersionUID = 1L;

    /** 用户ID */
    @TableId
    private Long userId;

    @NotBlank(message = "用户名不能为空", groups = {AddGroup.class, UpdateGroup.class})
    private String username;

    @NotBlank(message = "密码不能为空", groups = AddGroup.class)
    private String password;

    /** 盐  */
    private String salt;

    @NotBlank(message = "邮箱不能为空", groups = {AddGroup.class, UpdateGroup.class})
    @Email(message = "邮箱格式不正确", groups = {AddGroup.class, UpdateGroup.class})
    private String email;

    /** 手机号 */
    private String mobile;

    /** 状态  0:禁用   1:正常 */
    private Integer status;

    /** 创建者ID */
    private Long createUserId;

    /** 创建时间 */
    private Date createTime;

    /** 角色ID列表 */
    @TableField(exist = false)
    private List roleIdList;

}
  • 添加 @TableField(exist = false) 注解的字段,非存储字段。后续的实体,补充重复赘述。
  • 每个字段比较简单,胖友自己根据注释理解下即可。
  • renren-fast 的 DAO 采用 MyBatis-Plus 访问数据库。感兴趣的胖友,可以看看《芋道 Spring Boot MyBatis 入门》的「4. MyBatis-Plus」小节。

对应表的创建 SQL 如下:

-- 系统用户
CREATE TABLE `sys_user` (
  `user_id` bigint NOT NULL AUTO_INCREMENT,
  `username` varchar(50) NOT NULL COMMENT '用户名',
  `password` varchar(100) COMMENT '密码',
  `salt` varchar(20) COMMENT '盐',
  `email` varchar(100) COMMENT '邮箱',
  `mobile` varchar(100) COMMENT '手机号',
  `status` tinyint COMMENT '状态  0:禁用   1:正常',
  `create_user_id` bigint(20) COMMENT '创建者ID',
  `create_time` datetime COMMENT '创建时间',
  PRIMARY KEY (`user_id`),
  UNIQUE INDEX (`username`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='系统用户';

4.1.2 SysRoleEntity

SysRole ,角色实体类。代码如下:

// SysRoleEntity.java

@Data
@TableName("sys_role")
public class SysRoleEntity implements Serializable {
    
	private static final long serialVersionUID = 1L;

	/** 角色ID */
	@TableId
	private Long roleId;

	@NotBlank(message = "角色名称不能为空")
	private String roleName;

	/** 备注 */
	private String remark;

	/** 创建者ID  */
	private Long createUserId;

	/** 创建时间 */
	private Date createTime;

	@TableField(exist=false)
	private List menuIdList;
    
}
  • 每个字段比较简单,胖友自己根据注释理解下即可。

对应表的创建 SQL 如下:

CREATE TABLE `sys_role` (
  `role_id` bigint NOT NULL AUTO_INCREMENT,
  `role_name` varchar(100) COMMENT '角色名称',
  `remark` varchar(100) COMMENT '备注',
  `create_user_id` bigint(20) COMMENT '创建者ID',
  `create_time` datetime COMMENT '创建时间',
  PRIMARY KEY (`role_id`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='角色';

4.1.3 SysUserRoleEntity

SysUserRoleEntity ,用户和角色关联实体类。代码如下:

// SysUserRoleEntity.java

public class SysUserRoleEntity {

    /** 用户ID */
    private Long userId;

    /** 角色ID */
    private Long roleId;
    
    // ...省略 set/get 方法
    
}
  • 每个字段比较简单,胖友自己根据注释理解下即可。

对应表的创建 SQL 如下:

-- 用户与角色对应关系
CREATE TABLE `sys_user_role` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `user_id` bigint COMMENT '用户ID',
  `role_id` bigint COMMENT '角色ID',
  PRIMARY KEY (`id`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='用户与角色对应关系';

4.1.4 SysMenuEntity

SysMenuEntity ,菜单权限实体类。代码如下:

// SysMenuEntity.java

@Data
@TableName("sys_menu")
public class SysMenuEntity implements Serializable {

    private static final long serialVersionUID = 1L;

    /** 菜单ID */
    @TableId
    private Long menuId;

    /** 父菜单ID,一级菜单为0 */
    private Long parentId;

    /** 父菜单名称 */
    @TableField(exist = false)
    private String parentName;

    /** 菜单名称 */
    private String name;

    /** 菜单URL */
    private String url;

    /** 授权(多个用逗号分隔,如:user:list,user:create) */
    private String perms;

    /** 类型     0:目录   1:菜单   2:按钮 */
    private Integer type;

    /** 菜单图标 */
    private String icon;

    /** 排序 */
    private Integer orderNum;

    /** ztree 属性 */
    @TableField(exist = false)
    private Boolean open;

    @TableField(exist = false)
    private List list;

}
  • 个人感觉,这个实体改成 SysResourceEntity 资源,更加合适,菜单仅仅是其中的一种。

  • 每个字段比较简单,胖友自己根据资源理解下即可。我们来重点看几个字段。

  • type 属性,定义了三种类型。其中,2 代表按钮,是为了做页面中的功能级的权限。

  • perms 属性,对应的权限标识字符串。一般格式为 ${大模块}:${小模块}:{操作} 。示例如下:

用户查询:system:user:query
用户新增:system:user:add
用户修改:system:user:edit
用户删除:system:user:remove
用户导出:system:user:export
用户导入:system:user:import
重置密码:system:user:resetPwd
    • 对于前端来说,每个按钮在展示时,可以判断用户是否有该按钮的权限。如果没有,则进行隐藏。当然,前端在首次进入系统的时候,会请求一次权限列表到本地进行缓存。
    • 对于后端来说,每个接口上会添加 Shiro @RequiresPermissions("system:user:query") 注解。在请求接口时,会校验用户是否有该 URL 对应的权限。如果没有,则会抛出权限验证失败的异常。
    • 一个 perms 属性,可以对应多个权限标识,使用逗号分隔。例如说:"system:user:query,system:user:add" 。

对应表的创建 SQL 如下:

-- 菜单
CREATE TABLE `sys_menu` (
  `menu_id` bigint NOT NULL AUTO_INCREMENT,
  `parent_id` bigint COMMENT '父菜单ID,一级菜单为0',
  `name` varchar(50) COMMENT '菜单名称',
  `url` varchar(200) COMMENT '菜单URL',
  `perms` varchar(500) COMMENT '授权(多个用逗号分隔,如:user:list,user:create)',
  `type` int COMMENT '类型   0:目录   1:菜单   2:按钮',
  `icon` varchar(50) COMMENT '菜单图标',
  `order_num` int COMMENT '排序',
  PRIMARY KEY (`menu_id`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='菜单管理';

4.1.5 SysRoleMenuEntity

SysRoleMenuEntity ,角色和菜单关联实体类。代码如下:

// SysRoleMenu.java

@Data
@TableName("sys_role_menu")
public class SysRoleMenuEntity implements Serializable {

	private static final long serialVersionUID = 1L;

	@TableId
	private Long id;

	/** 角色ID */
	private Long roleId;

	/** 菜单ID */
	private Long menuId;

}

对应表的创建 SQL 如下: 

-- 角色与菜单对应关系
CREATE TABLE `sys_role_menu` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `role_id` bigint COMMENT '角色ID',
  `menu_id` bigint COMMENT '菜单ID',
  PRIMARY KEY (`id`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='角色与菜单对应关系';

4.1.6 SysUserTokenEntity

SysUserTokenEntity ,用户 Token 实体类。代码如下:

// SysUserTokenEntity.java

@Data
@TableName("sys_user_token")
public class SysUserTokenEntity implements Serializable {

	private static final long serialVersionUID = 1L;

	//用户ID
	@TableId(type = IdType.INPUT)
	private Long userId;
	//token
	private String token;
	//过期时间
	private Date expireTime;
	//更新时间
	private Date updateTime;

}
  • 每个字段比较简单,胖友自己根据注释理解下即可。
  • 用户使用 username 和 password 登陆成功后,会生成 SysUserTokenEntity 记录到数据库中。后续的请求,使用 SysUserTokenEntity.token 作为身份标识。

对应表的创建 SQL 如下:

-- 系统用户Token
CREATE TABLE `sys_user_token` (
  `user_id` bigint(20) NOT NULL,
  `token` varchar(100) NOT NULL COMMENT 'token',
  `expire_time` datetime DEFAULT NULL COMMENT '过期时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`user_id`),
  UNIQUE KEY `token` (`token`)
) ENGINE=`InnoDB` DEFAULT CHARACTER SET utf8mb4 COMMENT='系统用户Token';

4.2 ShiroConfig

在 ShiroConfig 配置类,实现 Shiro 的自定义配置。代码如下:

// ShiroConfig.java

@Configuration
public class ShiroConfig {

    @Bean("securityManager")
    public SecurityManager securityManager(OAuth2Realm oAuth2Realm) { /**省略代码**/ }

    @Bean("shiroFilter")
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) { /**省略代码**/ }

    @Bean("lifecycleBeanPostProcessor")
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() { /**省略代码**/ }

    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { /**省略代码**/ }

}
  • 一共有四个 Bean 的配置,我们逐个来看看。

4.2.1 Realm

在 renren-fast 中,自定义 AuthorizingRealm 的实现类 OAuth2Realm ,读取我们自定义的数据库表结构,提供认证和授权功能。

因为 OAuth2Realm 的类上,已经添加了 @Component 注解,所以就不需要在 ShiroConfig 中进行 Bean 的配置。

关于 OAuth2Realm 的代码详细解析,我们见「4.4 OAuth2Filter」和「4.5 权限验证」 。

4.2.2 SecurityManager

#securityManager() 方法,我们创建了 DefaultWebSecurityManager Bean 对象。代码如下:

// ShiroConfig.java

@Bean("securityManager")
public SecurityManager securityManager(OAuth2Realm oAuth2Realm) {
    // 创建 DefaultWebSecurityManager 对象
    DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
    // 设置其使用的 Realm 为 OAuth2Realm
    securityManager.setRealm(oAuth2Realm);
    // 无需使用记住密码功能
    securityManager.setRememberMeManager(null);
    return securityManager;
}
  • 和「2.2.2 SecurityManager」基本一致。

4.2.3 ShiroFilter

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

// ShiroConfig.java

@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
    // 创建 ShiroFilterFactoryBean 对象,用于创建 ShiroFilter 过滤器
    ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();

    // 设置 SecurityManager
    shiroFilter.setSecurityManager(securityManager);

    // <1> 创建 OAuth2Filter 过滤器,并设置名字为 oauth2 。
    Map filters = new HashMap<>();
    filters.put("oauth2", new OAuth2Filter());
    shiroFilter.setFilters(filters);

    // <2> ...

    Map filterMap = new LinkedHashMap<>();
    filterMap.put("/webjars/**", "anon");
    filterMap.put("/druid/**", "anon");
    filterMap.put("/app/**", "anon");
    filterMap.put("/sys/login", "anon"); // <3> 登陆接口
    filterMap.put("/swagger/**", "anon");
    filterMap.put("/v2/api-docs", "anon");
    filterMap.put("/swagger-ui.html", "anon");
    filterMap.put("/swagger-resources/**", "anon");
    filterMap.put("/captcha.jpg", "anon");
    filterMap.put("/aaa.txt", "anon");
    filterMap.put("/**", "oauth2"); // <4> 默认剩余的 URL ,需要经过认证
    shiroFilter.setFilterChainDefinitionMap(filterMap);

    return shiroFilter;
}
  • 和「2.2.3 ShiroFilter」略有差别,我们逐个来说说。
  • <1> 处,创建 OAuth2Filter 过滤器,并设置名字为 "oauth2" 。该过滤器,用于对请求头带的 OAuth2 的 Token 进行认证。
  • <2> 处,我们无需设置各种 URL 。因为在前后端分离之后,我们可以结合前端一起,实现自定义的登陆流程。当然,如果继续使用 Shiro 定义的登陆流程,实际也是没问题的。
  • <3> 处,设置登陆接口 /sys/login 允许匿名访问,不然咱没法实现登陆逻辑哈。详细解析,见「4.3 登陆 API 接口」。
  • <4> 处,剩余的 URL ,我们设置为 oauth2 ,使用 OAuth2Filter 来基于请求头带的 OAuth2 的 Token 进行认证。如果认证不通过,则返回未认证的错误提示。详细解析,见「4.4 OAuth2Filter」。

下面,我们详细的来看看,各个配置的 Bean 的逻辑。

4.3 登陆 API 接口

SysLoginController#login(...)

在 SysLoginController 中,定义了 /login 接口,提供登陆功能。代码如下:

// SysLoginController.java

@Autowired
private SysUserService sysUserService;
@Autowired
private SysUserTokenService sysUserTokenService;
@Autowired
private SysCaptchaService sysCaptchaService;

@PostMapping("/sys/login")
public Map login(@RequestBody SysLoginForm form) {
    // <1> 验证图片验证码的正确性
    boolean captcha = sysCaptchaService.validate(form.getUuid(), form.getCaptcha());
    if (!captcha) {
        return R.error("验证码不正确");
    }

    // <2> 获得之地当用户名的 SysUserEntity
    SysUserEntity user = sysUserService.queryByUserName(form.getUsername());
    if (user == null || !user.getPassword().equals(new Sha256Hash(form.getPassword(), user.getSalt()).toHex())) { // 账号不存在、密码错误
        return R.error("账号或密码不正确");
    }
    if (user.getStatus() == 0) { // 账号锁定
        return R.error("账号已被锁定,请联系管理员");
    }

    // <3> 生成 Token ,并返回结果
    return sysUserTokenService.createToken(user.getUserId());
}
  • <1> 处,验证图片验证码的正确性。该验证码会存储在 MySQL 数据库中,通过 uuid 作为对应的标识。生成的逻辑,胖友自己看 SysLoginController 提供的 /captcha.jpg 接口。
  • <2> 处,调用 SysUserService 的 #queryByUserName(String username) 方法,获得指定用户名的 SysUserEntity ,然后进行校验。详细解析,见「4.3.1 加载用户信息」。
  • <3> 处,调用 SysUserTokenService 的 #createToken(long userId) 方法,给认证通过的用户,生成其对应的认证 Token 。这样,该用户的后续请求,就使用该 Token 作为身份标识进行认证。

4.3.1 加载用户信息

在 SysUserServiceImpl 中,实现 SysUserService 接口定义的 #queryByUserName(String username) 方法,获得指定用户名的 SysUserEntity 。代码如下:

// SysUserServiceImpl.java

@Override
public SysUserEntity queryByUserName(String username) {
    // baseMapper 由 MyBatis-Plus 提供
	return baseMapper.queryByUserName(username);
}

// SysUserDao.XML
  • 通过查询 sys_user 表,将 username 对应的 SysUser 查询出来。

4.3.2 创建认证 Token

在 SysUserTokenServiceImpl 中,实现 SysUserTokenService 接口定义的 #createToken(LoginUser loginUser) 方法,给认证通过的用户,生成其对应的认证 Token 。代码如下:

// SysUserTokenServiceImpl.java

// 12小时后过期
private final static int EXPIRE = 3600 * 12;

@Override
public R createToken(long userId) {
    // <1> 生成一个 token
    String token = TokenGenerator.generateValue();

    // <2> 当前时间
    Date now = new Date();
    // <2> 过期时间
    Date expireTime = new Date(now.getTime() + EXPIRE * 1000);

    // <3> 判断是否生成过 token
    SysUserTokenEntity tokenEntity = this.getById(userId);
    if (tokenEntity == null) { // 新增 SysUserTokenEntity
        tokenEntity = new SysUserTokenEntity();
        tokenEntity.setUserId(userId);
        tokenEntity.setToken(token);
        tokenEntity.setUpdateTime(now);
        tokenEntity.setExpireTime(expireTime);

        // 保存 token
        this.save(tokenEntity);
    } else { // 更新 SysUserTokenEntity
        tokenEntity.setToken(token);
        tokenEntity.setUpdateTime(now);
        tokenEntity.setExpireTime(expireTime);

        // 更新 token
        this.updateById(tokenEntity);
    }

    // <4> 返回 token 和过期时间
    return R.ok().put("token", token).put("expire", EXPIRE);
}
  • <1> 处,调用 TokenGenerator 的 #generateValue() 方法,生成一个 token 。其内部逻辑是生成 UUID 后,再进行一次 MD5 编码。感兴趣的胖友,自己去瞅瞅。
  • <2> 处,获得当前时间,并计算 token 的过期时间为 12 小时后。
  • <3> 处,根据该用户是否已经有存在的 SysUserTokenEntity ,进行插入或更新。在 renren-fast 项目中,一个 SysUserEntity 有且仅有一个对应的 SysUserTokenEntity 。如果胖友希望用户登陆后,老的 token 不要作废,则这里可以改成插入 SysUserTokenEntity 即可。
  • <4> 处,返回 token 和过期时间。

4.4 OAuth2Filter

在 OAuth2Filter 中,继承 Shiro AuthenticatingFilter 过滤器,实现了基于 Token 的认证。代码如下:

// OAuth2Filter.java

public class OAuth2Filter extends AuthenticatingFilter {

    @Override
    protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) { /**省略代码**/ }

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { /**省略代码**/ }

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { /**省略代码**/ }

    @Override
    protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) { /**省略代码**/ }

}
  • 通过继承 Shiro AuthenticatingFilter 过滤器,可以简化实现整个认证过程的代码。FormAuthenticationFilter 和 BasicHttpAuthenticationFilter 就是继承自 AuthenticatingFilter 。

下面,我们逐个看看 OAuth2Filter 的每一个方法的实现。

4.4.1 isAccessAllowed

#isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) 方法,判断是否允许访问。代码如下:

// OAuth2Filter.java

@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
    return ((HttpServletRequest) request).getMethod().equals(RequestMethod.OPTIONS.name());
}
  • 在这里,只允许 OPTIONS 类型的请求可以直接允许访问。
  • 在返回 false 时,就可以进入「4.4.3 onAccessDenied」的流程,根据请求带的 Token 进行认证。如果认证通过,说明可以访问。

4.4.2 createToken

#createToken(ServletRequest request, ServletResponse response) 方法,创建认真使用的 AuthenticationToken 。代码如下:

// OAuth2Filter.java

@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) {
    // <1> 获取请求中的 token
    String token = getRequestToken((HttpServletRequest) request);
    // 如果不存在,则返回 null
    if (StringUtils.isBlank(token)) {
        return null;
    }

    // <2> 创建 OAuth2Token 对象
    return new OAuth2Token(token);
}
  • <1> 处,调用 #getRequestToken(HttpServletRequest httpRequest) 方法,获得请求中的 token 。代码如下: 
// OAuth2Filter.java

private String getRequestToken(HttpServletRequest httpRequest) {
    // 优先,从 header 中获取 token
    String token = httpRequest.getHeader("token");

    // 次之,如果 header 中不存在 token ,则从参数中获取 token
    if (StringUtils.isBlank(token)) {
        token = httpRequest.getParameter("token");
    }

    return token;
}
  • <2> 处,创建自定义的 OAuth2Token 。

4.4.3 onAccessDenied

#onAccessDenied(ServletRequest request, ServletResponse response) 方法,根据请求带的 Token 进行认证。如果认证通过,说明可以访问。代码如下:

// OAuth2Filter.java
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
    // <1> 获取请求中的 token 。如果 token 不存在,直接返回 401 ,认证不通过
    String token = getRequestToken((HttpServletRequest) request);
    if (StringUtils.isBlank(token)) {
        // 设置响应 Header
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
        httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());

        // 返回认证不通过
        String json = new Gson().toJson(R.error(HttpStatus.SC_UNAUTHORIZED, "invalid token"));
        httpResponse.getWriter().print(json);

        // 返回 false
        return false;
    }

    // <2> 执行登陆逻辑,实际执行的是基于 Token 进行认证。
    return executeLogin(request, response);
}
  • <1> 处,获取请求中的 token 。如果 token 不存在,直接返回 401 ,认证不通过的 JSON 提示。

  • <2> 处,调用父类 AuthenticatingFilter 的 #executeLogin(request, response) 方法,执行登陆逻辑。实际上在方法内部,调用 OAuth2Realm 的 #doGetAuthenticationInfo(AuthenticationToken token) 方法,执行基于 Token 进行认证。代码如下:

// OAuth2Realm.java

@Autowired
private ShiroService shiroService;
    
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
    String accessToken = (String) token.getPrincipal();
    // <1> 根据 accessToken ,查询用户信息
    SysUserTokenEntity tokenEntity = shiroService.queryByToken(accessToken);
    // token 失效
    if (tokenEntity == null || tokenEntity.getExpireTime().getTime() < System.currentTimeMillis()) {
        throw new IncorrectCredentialsException("token失效,请重新登录");
    }

    // <2> 查询用户信息
    SysUserEntity user = shiroService.queryUser(tokenEntity.getUserId());
    // 账号锁定
    if (user.getStatus() == 0) {
        throw new LockedAccountException("账号已被锁定,请联系管理员");
    }

    // <3> 创建 SimpleAuthenticationInfo 对象
    return new SimpleAuthenticationInfo(user, accessToken, getName());
}
  • <1> 处,调用 ShiroService 的 #queryByToken(String token) 方法,查询 Token (在 OAuth2 中,Token 为访问令牌 accessToken )对应的 SysUserTokenEntity 。如果不存在或者已过期,抛出 IncorrectCredentialsException 异常。代码如下: 
// ShiroServiceImpl.java
@Autowired
private SysUserTokenDao sysUserTokenDao;

@Override
public SysUserTokenEntity queryByToken(String token) {
    return sysUserTokenDao.queryByToken(token);
}

// SysUserTokenDao.XML
  • <2>处,调用 ShiroService 的 `#queryUser(Long userId)`  方法,查询用户编号对应的 SysUserEntity 。如果已禁用,抛出 LockedAccountException 异常。代码如下: 
// ShiroServiceImpl.java
@Autowired
private SysUserDao sysUserDao;
    
@Override
public SysUserEntity queryUser(Long userId) {
    return sysUserDao.selectById(userId);
}
  • <3>` 处,创建 Shiro [SimpleAuthenticationInfo]
    (https://github.com/apache/shiro/blob/master/core/src/main/java/org/apache/shiro/authc/SimpleAuthenticationInfo.java) 对象,为当前用户的认证
    信息。 
    

至此,我们完成了基于 Token 进行认证的代码,胖友可以自己在理一理,顺一瞬。

4.4.4 onLoginFailure

#onLoginFailure(ServletRequest request, ServletResponse response) 方法,处理「4.4.3 onAccessDenied」 中,认证失败的时候,返回 401 ,认证不通过的 JSON 提示。代码如下:

// OAuth2Filter.java

@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
    // 设置响应 Header
    HttpServletResponse httpResponse = (HttpServletResponse) response;
    httpResponse.setContentType("application/json;charset=utf-8");
    httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
    httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());
    try {
        // 处理登录失败的异常
        Throwable throwable = e.getCause() == null ? e : e.getCause();
        R r = R.error(HttpStatus.SC_UNAUTHORIZED, throwable.getMessage());

        // 返回认证不通过
        String json = new Gson().toJson(r);
        httpResponse.getWriter().print(json);
    } catch (IOException ignored) {
    }

    // 返回 false
    return false;
}
  • 代码实现上,和「4.4.3 onAccessDenied」的请求 Token 不存在时的逻辑是一样的。

4.5 权限验证

在 renren-fast 中,使用「3. Shiro 注解」,实现每个 URL 的自定义权限。例如:

// SysConfigController.java

@GetMapping("/list")
@RequiresPermissions("sys:config:list")
public R list(@RequestParam Map params) { /**省略代码**/ }

 因为要验证权限,所以会调用到 OAuth2Realm 的 #doGetAuthorizationInfo(PrincipalCollection principals) 方法,进行鉴权,获得用户拥有的权限。代码如下:

// OAuth2Realm.java

@Autowired
private ShiroService shiroService;
    
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
    // <1> 获得 SysUserEntity 对象
    SysUserEntity user = (SysUserEntity) principals.getPrimaryPrincipal();
    Long userId = user.getUserId();

    // <2> 用户权限列表
    Set permsSet = shiroService.getUserPermissions(userId);

    // <3> 创建 SimpleAuthorizationInfo 对象
    SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
    info.setStringPermissions(permsSet);
    return info;
}
  • <1> 处,获得 SysUserEntity 对象。该对象就是我们在 OAuth2Realm 的 #doGetAuthenticationInfo(AuthenticationToken token) 方法中,所认证获得的。

  • <2> 处,调用 ShiroService 的 getUserPermissions(long userId) 方法,获得该用户拥有的权限数组。代码如下:

// OAuth2Realm.java

@Autowired
private SysMenuDao sysMenuDao;
@Autowired
private SysUserDao sysUserDao;

@Override
public Set getUserPermissions(long userId) {
    List permsList;
    // <1.1> 系统管理员,拥有最高权限
    if (userId == Constant.SUPER_ADMIN) {
        // 如果是管理员,则查询所有 SysMenuEntity 数组
        List menuList = sysMenuDao.selectList(null);
        permsList = new ArrayList<>(menuList.size());
        for (SysMenuEntity menu : menuList) {
            permsList.add(menu.getPerms());
        }
    // <1.2>
    } else {
        // 如果是普通用户,则查询其拥有的 SysMenuEntity 数组
        permsList = sysUserDao.queryAllPerms(userId);
    }
    // <2> 用户权限列表
    Set permsSet = new HashSet<>();
    for (String perms : permsList) {
        if (StringUtils.isBlank(perms)) {
            continue;
        }
        // 使用逗号分隔,每一个 perms
        permsSet.addAll(Arrays.asList(perms.trim().split(",")));
    }
    return permsSet;
}
  • <1.1> 处,如果是管理员( id == Constant.SUPER_ADMIN == 1 )时,调用 SysMenuDao 的 #selectList(Wrapper queryWrapper) 方法,查询所有 SysMenuEntity 数组,从而实现管理员拥有全部权限。

  • <1.2> 处,如果是普通用户,调用 SysMenuDao 的 #queryAllPerms(Long userId) 方法,查询该用户拥有的 SysMenuEntity 数组。代码如下:

// SysMenuDao.java

      • 通过 sys_user_role 连接 sys_role_menu 和 sys_menu 表,实现查询用户的所有权限。
    • <2> 处,返回用户权限列表。因为一个 SysMenuEntity.perms 可能对应多个权限,使用逗号分隔,所以这里需要做处理。

  • <3> 处,创建 Shiro SimpleAuthorizationInfo 对象,为当前用户的授权信息。

  • 另外,如果胖友想要使用 Shiro 的 @RequiresRoles 注解,需要读取用户拥有的角色。因为 renren-fast 目前暂时未使用该注解,所以并没有实现该逻辑。

4.6 获得权限 API 接口

在 SysMenuController 中,定义了 /sys/menu/nav 接口,获得当前登陆用户的菜单权限。代码如下:

// SysMenuController.java

@Autowired
private SysMenuService sysMenuService;
@Autowired
private ShiroService shiroService;

/**
 * 导航菜单
 */
@GetMapping("/nav")
public R nav() {
    // <1> 获得用户的菜单数组
    List menuList = sysMenuService.getUserMenuList(getUserId());
    // <2> 获得用户的权限集合
    Set permissions = shiroService.getUserPermissions(getUserId());
    // <3> 返回
    return R.ok().put("menuList", menuList)
            .put("permissions", permissions);
}
  • <1> 处,调用 SysMenuService 的 #getUserMenuList(Long userId) 方法,获得用户的菜单数组。代码如下: 
// SysMenuServiceImpl.java

@Autowired
private SysUserService sysUserService;
    
@Override
public List getUserMenuList(Long userId) {
    // 系统管理员,拥有最高权限
    if (userId == Constant.SUPER_ADMIN) {
        return getAllMenuList(null);
    }

    // 用户菜单列表
    List menuIdList = sysUserService.queryAllMenuId(userId);
    return getAllMenuList(menuIdList);
}

/**
 * 获取所有菜单列表
 */
private List getAllMenuList(List menuIdList) {
    // 查询根菜单列表
    List menuList = queryListParentId(0L, menuIdList);
    // 递归获取子菜单
    getMenuTreeList(menuList, menuIdList);
    return menuList;
}

/**
 * 递归
 */
private List getMenuTreeList(List menuList, List menuIdList) {
    List subMenuList = new ArrayList();
    for (SysMenuEntity entity : menuList) {
        // 目录
        if (entity.getType() == Constant.MenuType.CATALOG.getValue()) {
            entity.setList(getMenuTreeList(queryListParentId(entity.getMenuId(), menuIdList), menuIdList)); // 
        }
        subMenuList.add(entity);
    }
    return subMenuList;
}
    • 这块代码写的比较糟糕,在  处存在递归查询,在菜单量大的时候,会导致性能较差。可以考虑将用户拥有的菜单一次性查询出来,然后在内存中拼接树形结构。
  • <2> 处,调用 ShiroService 的 getUserPermissions(long userId) 方法,获得该用户拥有的权限数组。

  • <3> 处,返回用户拥有的菜单和权限。

4.7 退出 API 接口

在 SysLoginController 中,定义了 /logout 接口,提供退出功能。代码如下:

// SysLoginController.java

@Autowired
private SysUserTokenService sysUserTokenService;

@PostMapping("/sys/logout")
public R logout() {
    sysUserTokenService.logout(getUserId());
    return R.ok();
}
  • 调用 SysUserTokenServiceImpl 的 #logout(long userId) 方法,实现用户的退出。代码如下: 
// SysUserTokenServiceImpl.java

@Override
public void logout(long userId) {
    // 生成一个token
    String token = TokenGenerator.generateValue();

    // 修改token
    SysUserTokenEntity tokenEntity = new SysUserTokenEntity();
    tokenEntity.setUserId(userId);
    tokenEntity.setToken(token);
    this.updateById(tokenEntity);
}
    • 通过创建一个新的 token 值,修改该用户的 SysUserTokenEntity ,从而使用户当前的 Token 失效。
    • 有点尴尬的实现~胖友可以给 SysUserTokenEntity 增加一个标记删除的字段,或者修改过期时间。

4.8 权限管理

如下的 Controller ,提供了 renren-fast 的权限管理功能,比较简单,胖友自己去瞅瞅即可。

  • 用户管理 SysUserController :用户是系统操作者,该功能主要完成系统用户配置。
  • 角色管理 SysRoleController :角色菜单权限分配、设置角色按机构进行数据范围权限划分。
  • 菜单管理 SysMenuController :配置系统菜单,操作权限,按钮权限标识等。

4.9 小小的建议

至此,我们完成了对 renren-fast 权限相关功能的源码进行解读,希望对胖友有一定的胖友。如果胖友项目中需要权限相关的功能,建议不要直接拷贝 renren-fast 的代码,而是按照自己的理解,一点点“重新”实现一遍。在这个过程中,我们会有更加深刻的理解,甚至会有自己的一些小创新。

另外,RuoYi 也是一个基于 Shiro 实现权限管理的开源项目。胖友也可以去借鉴学习下。

 

这里额外在推荐一些 Shiro 不错的内容:

  • 《Shiro 实现原理与源码解析系统 —— 精品合集》
  • 《如何设计权限管理模块(附表结构)?》
  • 《Spring Boot + Vue + Shiro 实现前后端分离、权限控制》
  • 《学习如何使用 Shiro,从架构谈起,到框架集成!》
  • 《SpringBoot + Shiro + Redis 共享 Session 实例》
  • 《SpringBoot 整合 Shiro 实现动态权限加载更新+ Session 共享 + 单点登录》

不过艿艿实际项目中,并未采用 Spring Security 或是 Shiro 安全框架,而是自己团队开发了一个相对轻量级的组件。主要考虑,目前前后端分离之后,Shiro 内置的很多功能,已经不太需要,在加上拓展一些功能不是非常方便,有点“曲折”,所以才选择自己开发。

你可能感兴趣的:(Spring,Boot)