安全框架Spring-Security基本使用

9/27/2021 Spring-SecurityShiroSa-Token登录认证SSO单点登录OAuth第三方登录

# 1. 安全框架概述

# 1.1 Spring Security简介

Spring Security 基于Spring 框架,提供了一套web应用安全性的完整解决方案。权限管理系统一般包含两大核心模块:认证模块(Authentication)和鉴权模块(Authorization)。

  • 认证:认证模块负责验证用户身份的合法性,生成认证令牌,并保存到服务端会话中。

  • 鉴权:鉴权模块负责从服务端会话内获取用户身份信息,与访问的资源进行权限比对。

项目地址:https://github.com/spring-projects/spring-security (opens new window)

官方文档:https://docs.spring.io/spring-security/reference/ (opens new window)

官方给出的Spring Security的核心架构图如下:

Spring-Security核心架构

核心架构解读:

  • AuthenticationManager:负责认证管理,解析用户登录信息,读取用户、角色、权限信息进行认证,认证结果被回填到Authentication,保存在SecurityContext。

  • AccessDecisionManager:负责鉴权投票表决,汇总投票器的结果,实现一票通过(默认)、多票通过、一票否决策略。

  • SecurityInterceptor:负责权限拦截,包括Web URL拦截和方法调用拦截。通过ConfigAttributes获取资源的描述信息,借助于AccessDecisionManager进行鉴权拦截。

  • SecurityContext:安全上下文,保存认证结果。提供了全局上下文、线程继承上下文、线程独立上下文(默认)三种策略。

  • Authentication:认证信息,保存用户的身份标示、权限列表、证书、认证通过标记等信息。

  • SecuredResource:被安全管控的资源,如Web URL、用户、角色、自定义领域对象等。

  • ConfigAttributes:资源属性配置,描述安全管控资源信息,为SecurityInterceptor提供拦截逻辑的输入。

# 1.2 Shiro简介

Shiro是一个功能强大且易于使用的 Java 安全框架,可执行身份验证、授权、加密和会话管理。借助 Shiro 易于理解的 API,您可以快速轻松地保护任何应用程序——从最小的移动应用程序到最大的 Web 和企业应用程序。

项目地址:https://github.com/apache/shiro (opens new window)

官方文档:https://shiro.apache.org/documentation.html (opens new window)

Shiro特性

Shiro 的目标是 Shiro 开发团队所说的“应用程序安全的四大基石”——身份验证、授权、会话管理和密码学:

  • 身份验证:这是证明用户是他们所说的身份的行为。
  • 授权:访问控制的过程,即确定“谁”可以访问“什么”。
  • 会话管理:管理特定于用户的会话,即使在非 Web 或 EJB 应用程序中也是如此。
  • 密码学:使用密码算法确保数据安全,同时仍然易于使用。

# 1.3 Sa-Token简介

Sa-Token是一个轻量级Java权限认证框架,主要解决:登录认证、权限认证、Session会话、单点登录、OAuth2.0、微服务网关鉴权 等一系列权限相关问题。

项目地址:https://github.com/dromara/Sa-Token (opens new window)

官方文档:http://sa-token.dev33.cn/doc/index.html#/README (opens new window)

Sa-Token认证流程图

框架集成简单、开箱即用、API设计清爽,通过Sa-Token,你将以一种极其简单的方式实现系统的权限认证部分。

  • 登录认证 —— 单端登录、多端登录、同端互斥登录、七天内免登录
  • 权限认证 —— 权限认证、角色认证、会话二级认证
  • Session会话 —— 全端共享Session、单端独享Session、自定义Session
  • 踢人下线 —— 根据账号id踢人下线、根据Token值踢人下线
  • 账号封禁 —— 指定天数封禁、永久封禁、设定解封时间
  • 持久层扩展 —— 可集成Redis、Memcached等专业缓存中间件,重启数据不丢失
  • 分布式会话 —— 提供jwt集成、共享数据中心两种分布式会话方案
  • 微服务网关鉴权 —— 适配Gateway、ShenYu、Zuul等常见网关的路由拦截认证
  • 单点登录 —— 内置三种单点登录模式:无论是否跨域、是否共享Redis,都可以搞定
  • OAuth2.0认证 —— 基于RFC-6749标准编写,OAuth2.0标准流程的授权认证,支持openid模式
  • 二级认证 —— 在已登录的基础上再次认证,保证安全性
  • 独立Redis —— 将权限缓存与业务缓存分离
  • 临时Token验证 —— 解决短时间的Token授权问题
  • 模拟他人账号 —— 实时操作任意用户状态数据
  • 临时身份切换 —— 将会话身份临时切换为其它账号
  • 前后台分离 —— APP、小程序等不支持Cookie的终端
  • 同端互斥登录 —— 像QQ一样手机电脑同时在线,但是两个手机上互斥登录
  • 多账号认证体系 —— 比如一个商城项目的user表和admin表分开鉴权
  • 花式token生成 —— 内置六种Token风格,还可:自定义Token生成策略、自定义Token前缀
  • 注解式鉴权 —— 优雅的将鉴权与业务代码分离
  • 路由拦截式鉴权 —— 根据路由拦截鉴权,可适配restful模式
  • 自动续签 —— 提供两种Token过期策略,灵活搭配使用,还可自动续签
  • 会话治理 —— 提供方便灵活的会话查询接口
  • 记住我模式 —— 适配[记住我]模式,重启浏览器免验证
  • 密码加密 —— 提供密码加密模块,可快速MD5、SHA1、SHA256、AES、RSA加密
  • 全局侦听器 —— 在用户登录、注销、被踢下线等关键性操作时进行一些AOP操作
  • 开箱即用 —— 提供SpringMVC、WebFlux等常见web框架starter集成包,真正的开箱即用

# 2. 安全框架之间的对比

Sa-Token还比较新,不建议在公司项目的生产环境中使用,暂不在对比之列。

目前主流的安全框架是Spring Security和Shiro,二者在核心功能上几乎差不多,但从使用的角度各有优缺点。

  • 如果是想实现一个简单的web应用,Shiro更加的轻量级,学习成本也更低。
  • 如果是想开发一个分布式的、微服务的、或者与Spring Cloud系列框架深度集成的项目,还是建议使用Spring Security。

# 2.1 用户量

Shiro的使用量一直高于Spring Security。但是从趋势上来看,Spring Security是在一直上升的,Shiro的使用量同比、环比都进入了下滑期。

# 2.2 使用的方便程度

通常来说,Shiro入门更容易,使用起来也更简单,这也是造成Shiro的使用量一直高于Spring Security的主要原因,但其实二者之间的难度差异并没有那么大。

  • 在没有Spring Boot之前,Spring Security的大部分配置要通过XML实现,配置还是还是非常复杂的。但是有了 Spring Boot之后,这一情况已经得到显著改善。
  • Spring Security之所以看上去比Shiro更复杂,其实是因为它引入了一些不常用的概念与规则。2/8法则在Spring Security里面体现的特别明显,如果只学Spring Security最重要的那20%,这20%的复杂度和Shiro基本是一致的。也就是说,不重要的那80%,恰恰是Spring Security比Shiro的“复杂度”。

# 2.3 社区支持

Spring Security依托于Spring庞大的社区支持。Shiro属于Apache社区,因为它的广泛使用,文档也非常的全面。二者从社区支持来看,几乎不相上下。

但是从社区发展的角度看,Spring Security明显更占优势,随着Spring Cloud、Spring Boot、Spring Social的长足进步,这种优势会越来越大。因为Spring Security未来在与Spring系列框架集成的时候会有更好的融合性,前瞻性、兼容性。

# 2.4 功能丰富性

Spring Security因为它的复杂,所以从功能的丰富性的角度更胜一筹。

  • Spring Security默认含有对OAuth2.0的支持,与Spring Social一起使用完成社交媒体登录也比较方便。Shiro在这方面只能靠自己写代码实现。
  • Spring Security在网络安全的方面下的功夫更多。

# 3. Spring Security本质

SpringSecurity的本质就是一个过滤器链,内部包含了提供各种功能的过滤器,基本案例中的过滤器链如下图所示:

Spring-Security过滤器链

上图中仅展示了部分核心过滤器,非核心过滤器没有展示。

  • UsernamePasswordAuthenticationFilter:负责处理我们在登录页面填写了用户名密码后的登录请求。基本案例的认证工作主要由它负责。
  • ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException。
  • FilterSecurityInterceptor:负责权限校验的过滤器。

可以通过Debug查看SpingSecurity过滤器链中有哪些过滤器以及它们的先后顺序。

Sping-Security过滤器链的先后顺序

# 4. Spring Security认证流程

# 4.1 认证流程概述

下图所展示的就是整个SpringSecurity的认证流程。

Spring-Security认证流程

认证流程中的核心类

  • Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息
  • AuthenticationManager接口:定义了认证Authentication的方法
  • UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法
  • UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中
  • UsernamePasswordAuthenticationFilter实现类:实现了我们最常用的基于用户名和密码的认证逻辑,封装Authentication对象
  • DaoAuthenticationProvider实现类:是AuthenticationManager中管理的其中一个Provider,因为是要访问数据库,所以叫DaoAuthenticationProvider

# 4.2 认证流程验证

Step1:准备演示工程

在IDEA里创建SpringBoot工程,然后加入Spring web、SpringSecurity依赖,以Debug模式启动,并在下图中打入断点,然后通过浏览器提交用户名密码。

Spring-Security认证流程验证-1

Step2:默认的登录是用UsernamepasswordAuthencitionFilter过滤器去拦截

通过Authentication接口的实现子类UsernamePasswordAuthenticationToken

封装Authentication对象,这里只有用户名和密码,还没有权限

Spring-Security认证流程验证-2

Step3:调用AuthenticationManager的authenticate方法进行认证

因为AuthenticationManager中管理了很多Provider,所以调用的就是那一些Provider的authenticate方法进行认证

Spring-Security认证流程验证-3

Step4:调用DaoAuthenticationProvider的authenticate方法进行认证

Spring-Security认证流程验证-4

DaoAuthenticationProvider是AuthenticationProvider接口的其中一个实现类

DaoAuthenticationProvider

Step5:调用DaoAuthenticationProvider的loadUserByUsername查询用户信息

获得DaoAuthenticationProvider的UserDetailService对象,再调用此对象的loadUserByUsername方法查询用户信息

Spring-Security认证流程验证-5

认证流程小结:

上面五步就是SpringSecurity的基本认证流程,其实从基本认证流程中不难看出,我们可以重写或者替换UsernamePasswordAuthenticationFilter过滤器,用以添加我们需要的业务处理逻辑,并且可以实现UserDetailsService接口,加入Spring Data JPA或者Mybatis用来访问数据库中的用户信息。

# 5. Spring Security常见问题

# 5.1 通过body接收数据

需求描述:项目过安全检测的时候,不允许在参数中探测到username、password等敏感信息,即便它是密文的,因此打算将整个入参都加密成一个无规则字符串,通过body直接传输。

问题描述:在使用 Spring Security 的时候,发现用 body 传参的话,后台不能获取到数据,查看UsernamePasswordAuthenticationFilter 源码发现,是直接从 Request 获取的,而不是从 RequestBody 中获取的。

    //获取密码
    protected String obtainPassword(HttpServletRequest request) {
         return request.getParameter(passwordParameter);
    }
    //获取用户名
    protected String obtainUsername(HttpServletRequest request) {
         return request.getParameter(usernameParameter);
    }
1
2
3
4
5
6
7
8

解决办法:重写 UsernamePasswordAuthenticationFilter 类,配置自定义过滤器

新建 UserAuthenticationFilter.java

import com.alibaba.fastjson.JSONObject;
import com.yoyo.admin.framework.util.AesUtility;
import org.apache.commons.io.IOUtils;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.util.StringUtils;

import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

/**
 * 自定义过滤器,重写 UsernamePasswordAuthenticationFilter,从body获取参数
 */
public class UserAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private ThreadLocal<Map<String,String>> threadLocal = new ThreadLocal<>();

    @Override
    protected String obtainPassword(HttpServletRequest request) {
        String password = this.getBodyParams(request).get(SPRING_SECURITY_FORM_PASSWORD_KEY);
        if(!StringUtils.isEmpty(password)){
            return password;
        }
        return super.obtainPassword(request);
    }

    @Override
    protected String obtainUsername(HttpServletRequest request) {
        String username = this.getBodyParams(request).get(SPRING_SECURITY_FORM_USERNAME_KEY);
        if(!StringUtils.isEmpty(username)){
            return username;
        }
        return super.obtainUsername(request);
    }

    /**
     * 获取body参数  body中的参数只能获取一次
     * @param request
     * @return
     */
    private Map<String,String> getBodyParams(HttpServletRequest request){
        Map<String,String> bodyParams =  threadLocal.get();
        if(bodyParams == null){
            bodyParams = new HashMap<>();
        }
        try (InputStream is = request.getInputStream()) {
            String bodyString = IOUtils.toString(is, String.valueOf(StandardCharsets.UTF_8));
 		   // 对接收到的 bodyString进行解密(此代码略)
            Map<String, Object> tempMap = JSONObject.parseObject(bodyString);
            bodyParams.put("password",tempMap.get("password").toString());
            bodyParams.put("username",tempMap.get("username").toString());
        } catch (IOException e) {
        }
        threadLocal.set(bodyParams);

        return bodyParams;
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

修改 WebSecurityConfig.java

改动一:在 protected void configure(HttpSecurity httpSecurity) throws Exception 里配置自定义过滤器。

        // 配置自定义过滤器,增加 post json 支持
        httpSecurity.addFilterAt(UserAuthenticationFilterBean(), UsernamePasswordAuthenticationFilter.class);
1
2

改动二:添加如下方法。

    private UserAuthenticationFilter UserAuthenticationFilterBean() throws Exception {
        UserAuthenticationFilter userAuthenticationFilter = new UserAuthenticationFilter();
        userAuthenticationFilter.setAuthenticationManager(super.authenticationManager());
        userAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
        userAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
        return userAuthenticationFilter;
    }
1
2
3
4
5
6
7

注:authenticationSuccessHandler、authenticationFailureHandler分别是登录成功、失败的处理类,此处略。

# 5.2 多次登录失败后账户锁定

# 5.2.1 实现原理

一般来说实现这个需求,我们需要针对每一个用户记录登录失败的次数nLock和锁定账户的到期时间releaseTime。具体你是把这2个信息存储在mysql、还是文件中、还是Redis中等等,完全取决于你对你所处的应用架构适用性的判断。具体的实现逻辑无非就是:

  • 登陆失败之后,从存储中将nLock取出来加1。
  • 如果nLock大于登陆失败阈值(比如3次),则将nLock=0,然后设置releaseTime为当前时间加上锁定周期。通过setAccountNonLocked(false)告知Spring Security该登录账户被锁定。
  • 如果nLock小于等于1,则将nLock再次存起来。
  • 在一个合适的时机,将锁定状态重置为setAccountNonLocked(true)。

这是一种非常典型的实现方式,可以使用开源的ratelimitj来实现。它的功能主要是为API访问进行限流,也就是说可以通过制定规则限制API接口的访问频率。那恰好登录验证接口也是API的一种啊,我们正好也需要限制它在一定的时间内的访问次数。

# 5.2.2 具体实现

首先需要将ratelimitj通过maven引入到项目里来。这里使用的是内存存储的版本,还有redis存储的版本,可以根据自己的实际情况选用。

        <dependency>
            <groupId>es.moki.ratelimitj</groupId>
            <artifactId>ratelimitj-inmemory</artifactId>
            <version>0.4.1</version>
        </dependency>
1
2
3
4
5

CheckLockUtility.java

import es.moki.ratelimitj.core.limiter.request.RequestLimitRule;
import es.moki.ratelimitj.core.limiter.request.RequestRateLimiter;
import es.moki.ratelimitj.inmemory.request.InMemorySlidingWindowRequestRateLimiter;

import java.util.Collections;
import java.util.Set;
import java.util.concurrent.TimeUnit;

/**
 * 多次登录失败账号锁定工具类
 */
public class AccountLockUtility {

    // 规则定义:10分钟之内3次错误机会,达到就触发账号锁定
    private static Set<RequestLimitRule> rules = Collections.singleton(RequestLimitRule.of(10, TimeUnit.MINUTES,3));
    private static RequestRateLimiter limiter = new InMemorySlidingWindowRequestRateLimiter(rules);

    // 计数器加1,并判断该用户是否已经到了触发了锁定规则
    public static boolean checkLock(String userId) {
        return limiter.overLimitWhenIncremented(userId);
    }

    // 重置锁定
    public static void resetLock(String userId){
        limiter.resetLimit(userId);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

在AuthenticationSuccessHandler

        //从request.getSession中获取登录用户
        String userId = request.getSession().getId();
        boolean reachLimit = AccountLockUtility.checkLock(userId);
        if(reachLimit) {
            try {
                ... // 返回“多次登录失败,账户已被锁定,请10分钟后重试”的错误消息
            } catch (Exception e) {
                e.printStackTrace();
            }
        }else{
            AccountLockUtility.resetLock(userId); // 重置锁定
            ... // 编写登录成功的业务逻辑
        }
1
2
3
4
5
6
7
8
9
10
11
12
13

AuthenticationFailureHandler里分别编写账户锁定校验。

        //从request.getSession中获取登录用户
        String userId = request.getSession().getId();
        boolean reachLimit = AccountLockUtility.checkLock(userId);
        if(reachLimit) {
            try {
                ... // 返回“多次登录失败,账户已被锁定,请10分钟后重试”的错误消息
            } catch (Exception e) {
                e.printStackTrace();
            }
        }else{
            ... // 编写登录失败的业务逻辑
        }
1
2
3
4
5
6
7
8
9
10
11
12

# 5.3 登录登出接口添加Swagger

情景描述:有些场景我们需要手动将接口添加到Swagger中,比如非SpringMVC注解暴露接口(如定义在filter中),无法通过这种注解方式生成api接口文档。SpringSecurity的用户名密码登录接口,就是在filter中进行了拦截,因此在Swagger中看不到该登录接口,这样在平时的开发测试中,非常不方便。

解决思路:通过实现swagger提供的插件ApiListingScannerPlugin,可以手动将接口添加到swagger文档里。

代码实现:SpringSecurityApis.java

import com.fasterxml.classmate.TypeResolver;
import org.apache.commons.compress.utils.Sets;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import springfox.documentation.builders.OperationBuilder;
import springfox.documentation.builders.ParameterBuilder;
import springfox.documentation.builders.ResponseMessageBuilder;
import springfox.documentation.schema.ModelRef;
import springfox.documentation.service.ApiDescription;
import springfox.documentation.service.Operation;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spi.service.ApiListingScannerPlugin;
import springfox.documentation.spi.service.contexts.DocumentationContext;
import springfox.documentation.spring.web.readers.operation.CachingOperationNameGenerator;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;


/**
 * 由于 Spring Security 的登录、登出接口是通过Filter实现,导致 Swagger 无法获取其信息。
 * 这里手动将登录、登出接口注册到Swagger中,在Swagger-UI才能展示,方便调用。
 */
@Component
public class SpringSecurityApis implements ApiListingScannerPlugin {

    /**
     * Implement this method to manually add ApiDescriptions
     * 实现此方法可手动添加ApiDescriptions
     *
     * @param context - Documentation context that can be used infer documentation context
     * @return List of {@link ApiDescription}
     * @see ApiDescription
     */
    @Override
    public List<ApiDescription> apply(DocumentationContext context) {

        // 额外添加登录接口的文档
        Operation loginOperation = new OperationBuilder(new CachingOperationNameGenerator())
                .method(HttpMethod.POST)
                .summary("系统登录")
                // 接收参数格式
                .consumes(Sets.newHashSet(MediaType.APPLICATION_JSON_VALUE))
                // 返回参数格式
                .produces(Sets.newHashSet(MediaType.APPLICATION_JSON_VALUE))
                .tags(Sets.newHashSet("系统管理"))
                .uniqueId("login")
                .parameters(Collections.singletonList(
                        new ParameterBuilder()
                                .description("用户名与密码使用AES加密后得到的字符串")
                                .type(new TypeResolver().resolve(String.class))
                                .name("aes_str")
                                .parameterType("body")
                                .parameterAccess("access")
                                .required(true)
                                .modelRef(new ModelRef("text"))
                                .build()
                ))
                .responseMessages(Collections.singleton(
                        new ResponseMessageBuilder().code(200).message("请求成功")
                                .responseModel(new ModelRef(
                                        "xyz.gits.boot.common.core.response.RestResponse")
                                ).build()))
                .build();

        ApiDescription loginApiDescription = new ApiDescription("auth", "/auth/login", "登录接口",
                Collections.singletonList(loginOperation), false);

        // 额外添加退出登录接口的文档
        Operation logoutOperation = new OperationBuilder(new CachingOperationNameGenerator())
                .method(HttpMethod.POST)
                .summary("系统退出登录")
                // 接收参数格式
                .consumes(Sets.newHashSet(MediaType.APPLICATION_JSON_VALUE))
                // 返回参数格式
                .produces(Sets.newHashSet(MediaType.APPLICATION_JSON_VALUE))
                .parameters(Collections.emptyList())
                .uniqueId("logout")
                .tags(Sets.newHashSet("系统管理"))
                .responseMessages(Collections.singleton(
                        new ResponseMessageBuilder().code(200).message("请求成功")
                                .responseModel(new ModelRef(
                                        "xyz.gits.boot.common.core.response.RestResponse")
                                ).build()))
                .build();

        ApiDescription logoutApiDescription = new ApiDescription("auth", "/auth/logout", "退出登录接口",
                Collections.singletonList(logoutOperation), false);

        return Arrays.asList(loginApiDescription, logoutApiDescription);

    }

    /**
     * 是否使用此插件
     *
     * @param documentationType swagger文档类型
     * @return true 启用
     */
    @Override
    public boolean supports(DocumentationType documentationType) {
        return DocumentationType.SWAGGER_2.equals(documentationType);
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107

# 6. 整合Shiro权限系统

Step1:在pom.xml里添加如下依赖:

    <dependency>
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-spring-boot-web-starter</artifactId>
        <version>1.4.0</version>
    </dependency>
1
2
3
4
5

Step2:创建 Realm

import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;

public class MyRealm extends AuthorizingRealm {
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return null;
    }
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        String username = (String) token.getPrincipal();
        if (!"admin".equals(username)) {
            throw new UnknownAccountException("账户不存在!");
        }
        return new SimpleAuthenticationInfo(username, "sa", getName());
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

Step3:配置 Shiro 基本信息

在 application.properties 中配置 Shiro 的基本信息

## Shiro配置
# 是否允许将sessionId 放到 cookie 中
shiro.sessionManager.sessionIdCookieEnabled=true
# 是否允许将 sessionId 放到 Url 地址拦中
shiro.sessionManager.sessionIdUrlRewritingEnabled=true
# 访问未获授权的页面时,默认的跳转路径
shiro.unauthorizedUrl=/unauthorizedurl
# 开启shiro
shiro.web.enabled=true
# 登录成功的跳转页面
shiro.successUrl=/index
# 登录页面
shiro.loginUrl=/login
1
2
3
4
5
6
7
8
9
10
11
12
13

Step4:配置ShiroConfig

import org.apache.shiro.spring.web.config.DefaultShiroFilterChainDefinition;
import org.apache.shiro.spring.web.config.ShiroFilterChainDefinition;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ShiroConfig {
    @Bean
    MyRealm myRealm() {
        return new MyRealm();
    }
    @Bean
    DefaultWebSecurityManager securityManager() {
        DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
        manager.setRealm(myRealm());
        return manager;
    }
    @Bean
    ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition definition = new DefaultShiroFilterChainDefinition();
        definition.addPathDefinition("/doLogin", "anon");
        definition.addPathDefinition("/**", "authc");
        return definition;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

# 7. 整合Sa-Token权限系统

# 7.1 整合Sa-Token

Step1:在 pom.xml 中添加依赖

<!-- Sa-Token 权限认证, 在线文档:http://sa-token.dev33.cn/ -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot-starter</artifactId>
    <version>1.26.0</version>
</dependency>
1
2
3
4
5
6

Step2:设置配置文件

你可以零配置启动项目,但同时你也可以在application.yml中增加如下配置,定制性使用框架。

## Sa-Token配置
sa-token: 
    # token名称 (同时也是cookie名称)
    token-name: satoken
    # token有效期,单位s 默认30天, -1代表永不过期 
    timeout: 2592000
    # token临时有效期 (指定时间内无操作就视为token过期) 单位: 秒
    activity-timeout: -1
    # 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录) 
    is-concurrent: true
    # 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token) 
    is-share: false
    # token风格
    token-style: uuid
    # 是否输出操作日志 
    is-log: false
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

Step3:创建启动类

@SpringBootApplication
public class SaTokenDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(SaTokenDemoApplication.class, args);
        System.out.println("启动成功:Sa-Token配置如下:" + SaManager.getConfig());
    }
}
1
2
3
4
5
6
7

Step4:创建测试Controller

@RestController
@RequestMapping("/user/")
public class UserController {

    // 测试登录,浏览器访问: http://localhost:8080/user/doLogin?username=zhang&password=123456
    @RequestMapping("doLogin")
    public String doLogin(String username, String password) {
        // 此处仅作模拟示例,真实项目需要从数据库中查询数据进行比对 
        if("zhang".equals(username) && "123456".equals(password)) {
            StpUtil.login(10001);
            return "登录成功";
        }
        return "登录失败";
    }

    // 查询登录状态,浏览器访问: http://localhost:8080/user/isLogin
    @RequestMapping("isLogin")
    public String isLogin() {
        return "当前会话是否登录:" + StpUtil.isLogin();
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 7.2 登录认证API

// 标记当前会话登录的账号id 
StpUtil.login(Object id);   

// 当前会话注销登录
StpUtil.logout();

// 获取当前会话是否已经登录,返回true=已登录,false=未登录
StpUtil.isLogin();

// 检验当前会话是否已经登录, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.checkLogin()

// 获取指定token对应的账号id,如果未登录,则返回 null
StpUtil.getLoginIdByToken(String tokenValue);

// 获取当前`StpLogic`的token名称
StpUtil.getTokenName();

// 获取当前会话的token值
StpUtil.getTokenValue();

// 获取当前会话的token信息参数
StpUtil.getTokenInfo();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

说明:TokenInfo参数详解

{
    "code": 200,
    "msg": "ok",
    "data": {
        "tokenName": "satoken",           // token名称
        "tokenValue": "e67b99f1-3d7a-4a8d-bb2f-e888a0805633",      // token值
        "isLogin": true,                  // 此token是否已经登录
        "loginId": "10001",               // 此token对应的LoginId,未登录时为null
        "loginType": "login",              // 账号类型标识
        "tokenTimeout": 2591977,          // token剩余有效期 (单位: 秒)
        "sessionTimeout": 2591977,        // User-Session剩余有效时间 (单位: 秒)
        "tokenSessionTimeout": -2,        // Token-Session剩余有效时间 (单位: 秒)
        "tokenActivityTimeout": -1,       // token剩余无操作有效时间 (单位: 秒)
        "loginDevice": "default-device"   // 登录设备标识
    },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 8. 四种常规的登录认证方式

以下介绍4种常规的登录认证方式,每个方案各自的使用场景如下:

  • Cookie + Session 历史悠久,适合于简单的后端架构,需开发人员自己处理好安全问题。
  • Token 方案对后端压力小,适合大型分布式的后端架构,但已分发出去的 Token ,如果想收回权限,就不是很方便了。
  • SSO 单点登录,适用于中大型企业,想要统一内部所有产品的登录方式。
  • OAuth 第三方登录,简单易用,对用户和开发者都友好,但第三方平台很多,需要选择合适自己的第三方登录平台。

HTTP 是一种无状态的协议,客户端每次发送请求时,首先要和服务器端建立一个连接,在请求完成后又会断开这个连接。这种方式可以节省传输时占用的连接资源,但同时也存在一个问题:每次请求都是独立的,服务器端无法判断本次请求和上一次请求是否来自同一个用户,进而也就无法判断用户的登录状态。为了解决 HTTP 无状态的问题,Lou Montulli 在 1994 年的时候,推出了 Cookie。

Cookie 是服务器端发送给客户端的一段特殊信息,这些信息以文本的方式存放在客户端,客户端每次向服务器端发送请求时都会带上这些特殊信息。

当访问一个页面的时候,服务器在下行http报文中,命令浏览器存储一个字符串;浏览器再访问同一个域的时候,将把这个字符串携带到上行http请求中。第一次访问一个服务器,不可能携带Cookie 。必须是服务器得到这次请求,在下行响应报头中,携带Cookie 信息,此后每一次浏览器往这个服务器发出的请求,都会携带这个Cookie 。有了 Cookie 之后,服务器端就能够获取到客户端传递过来的信息了,如果需要对信息进行验证,还需要通过 Session。

客户端请求服务端,服务端会为这次请求开辟一块内存空间,这个便是 Session 对象。

有了 Cookie 和 Session 之后,我们就可以进行登录认证了。

session在计算机网络应用中被称为“会话控制”。客户端浏览器访问网站的时候,服务器会向客户浏览器发送一个每个用户特有的会话编号sessionID,让他进入到cookie里,服务器同时也把sessionID和对应的用户信息、用户操作记录在服务器上,这些记录就是session。客户端浏览器再次访问时,会发送cookie给服务器,其中就包含sessionID。服务器从cookie里找到sessionID,再根据sessionID找到以前记录的用户信息就可以知道他之前操控些、访问过哪里。

Cookie + Session 的登录方式是最经典的一种登录方式,现在仍然有大量的企业在使用。

用户首次登录时:

Cookie+Session实现流程-1
  1. 用户访问 a.com/pageA,并输入密码登录。
  2. 服务器验证密码无误后,会创建 SessionId,并将它保存起来。
  3. 服务器端响应这个 HTTP 请求,并通过 Set-Cookie 头信息,将 SessionId 写入 Cookie 中。
  4. 浏览器会根据Set-Cookie中的信息,自动将SessionId存储至cookie中。

服务器端的 SessionId 可能存放在很多地方,例如:内存、文件、数据库等。

第一次登录完成之后,后续的访问就可以直接使用 Cookie 进行身份验证了:

Cookie+Session实现流程-2
  1. 用户访问 a.com/pageB 页面时,会自动带上第一次登录时写入的 Cookie。
  2. 服务器端比对 Cookie 中的 SessionId 和保存在服务器端的 SessionId 是否一致。
  3. 如果一致,则身份验证成功。

虽然我们使用 Cookie + Session 的方式完成了登录验证,但仍然存在一些问题:

  • 由于服务器端需要对接大量的客户端,也就需要存放大量的 SessionId,这样会导致服务器压力过大。
  • 如果服务器端是一个集群,为了同步登录态,需要将 SessionId 同步到每一台机器上,无形中增加了服务器端维护成本。
  • 由于 SessionId 存放在 Cookie 中,所以无法避免 CSRF 攻击。

# 8.2 Token 登录

为了解决 Session + Cookie 机制暴露出的诸多问题,我们可以使用 Token 的登录方式。

Token是服务端生成的一串字符串,以作为客户端请求的一个令牌。当第一次登录后,服务器会生成一个 Token 并返回给客户端,客户端后续访问时,只需带上这个 Token 即可完成身份认证。

# 8.2.1 Token 机制实现流程

用户首次登录时:

Token机制实现流程-1
  1. 用户输入账号密码,并点击登录。
  2. 服务器端验证账号密码无误,创建 Token。
  3. 服务器端将 Token 返回给客户端,由客户端自由保存。

后续页面访问时:

Token机制实现流程-2
  1. 用户访问 a.com/pageB 时,带上第一次登录时获取的 Token。
  2. 服务器端验证 Token ,有效则身份验证成功。

# 8.2.2 Token 机制的特点

根据上面的案例,我们可以分析出 Token 的优缺点:

  • 服务器端不需要存放 Token,所以不会对服务器端造成压力,即使是服务器集群,也不需要增加维护成本。
  • Token 可以存放在前端任何地方,可以不用保存在 Cookie 中,提升了页面的安全性。
  • Token 下发之后,只要在生效时间之内,就一直有效,如果服务器端想收回此 Token 的权限,并不容易。

# 8.2.3 Token 的生成方式

最常见的 Token 生成方式是使用 JWT(Json Web Token),它是一种简洁的,自包含的方法用于通信双方之间以 JSON 对象的形式安全的传递信息。但上文说到,使用 Token 后,服务器端并不会存储 Token,那怎么判断客户端发过来的 Token 是合法有效的呢?

答案其实就在 Token 字符串中,其实 Token 并不是一串杂乱无章的字符串,而是通过多种算法拼接组合而成的字符串,我们来具体分析一下。

JWT 算法主要分为 3 个部分:header(头信息),playload(消息体),signature(签名)。

header 部分指定了该 JWT 使用的签名算法:

header = '{"alg":"HS256","typ":"JWT"}' // `HS256` 表示使用了 HMAC-SHA256 来生成签名。
1

playload 部分表明了 JWT 的意图:

payload = '{"loggedInAs":"admin","iat":1422779638}'  //iat 表示令牌生成的时间
1

signature 部分为 JWT 的签名,主要为了让 JWT 不能被随意篡改,签名的方法分为两个步骤:

  1. 输入 base64url 编码的 header 部分 、base64url 编码的 playload 部分,输出 unsignedToken。
  2. 输入服务器端私钥、unsignedToken,输出 signature 签名。
const base64Header = encodeBase64(header)
const base64Payload = encodeBase64(payload)
const unsignedToken = `${base64Header}.${base64Payload}`
const key = '服务器私钥'

signature = HMAC(key, unsignedToken)
最后的 Token 计算如下:
1
2
3
4
5
6
7
const base64Header = encodeBase64(header)
const base64Payload = encodeBase64(payload)
const base64Signature = encodeBase64(signature)

token = `${base64Header}.${base64Payload}.${base64Signature}` 
1
2
3
4
5

服务器在判断 Token 时:

const [base64Header, base64Payload, base64Signature] = token.split('.')

const signature1 = decodeBase64(base64Signature)
const unsignedToken = `${base64Header}.${base64Payload}`
const signature2 = HMAC('服务器私钥', unsignedToken)

if(signature1 === signature2) {
  return '签名验证成功,token 没有被篡改'
}

const payload =  decodeBase64(base64Payload)
if(new Date() - payload.iat < 'token 有效期'){
  return 'token 有效'
} 
1
2
3
4
5
6
7
8
9
10
11
12
13
14

有了 Token 之后,登录方式已经变得非常高效。

# 8.3 SSO 单点登录

单点登录指的是在公司内部搭建一个公共的认证中心,公司下的所有产品的登录都可以在认证中心里完成,一个产品在认证中心登录后,再去访问另一个产品,可以不用再次登录,即可获取登录状态。

# 8.3.1 SSO 机制实现流程

用户首次访问时,需要在认证中心登录:

SSO机制实现流程-1
  1. 用户访问网站 a.com 下的 pageA 页面。
  2. 由于没有登录,则会重定向到认证中心,并带上回调地址 www.sso.com?return_uri=a.com/pageA,以便登录后直接进入对应页面。
  3. 用户在认证中心输入账号密码,提交登录。
  4. 认证中心验证账号密码有效,然后重定向 a.com?ticket=123 带上授权码 ticket,并将认证中心 sso.com 的登录态写入 Cookie。
  5. a.com 服务器中,拿着 ticket 向认证中心确认,授权码 ticket 真实有效。
  6. 验证成功后,服务器将登录信息写入 Cookie(此时客户端有 2 个 Cookie 分别存有 a.comsso.com 的登录态)。

认证中心登录完成之后,继续访问 a.com 下的其他页面:

SSO机制实现流程-2

这个时候,由于 a.com 存在已登录的 Cookie 信息,所以服务器端直接认证成功。

如果认证中心登录完成之后,访问 b.com 下的页面:

SSO机制实现流程-3

这个时候,由于认证中心存在之前登录过的 Cookie,所以也不用再次输入账号密码,直接返回第 4 步,下发 ticket 给 b.com 即可。

# 8.3.2 SSO 单点登录退出

目前我们已经完成了单点登录,在同一套认证中心的管理下,多个产品可以共享登录态。现在我们需要考虑退出了,即:在一个产品中退出了登录,怎么让其他的产品也都退出登录?原理其实不难,可以回过头来看第 5 步,每一个产品在向认证中心验证 ticket 时,其实可以顺带将自己的退出登录 api 发送到认证中心。

当某个产品 c.com 退出登录时:

  1. 清空 c.com 中的登录态 Cookie。
  2. 请求认证中心 sso.com 中的退出 api。
  3. 认证中心遍历下发过 ticket 的所有产品,并调用对应的退出 api,完成退出。

# 8.4 OAuth 第三方登录

在上文中,我们使用单点登录完成了多产品的登录态共享,但都是建立在一套统一的认证中心下,对于一些小型企业,未免太麻烦,有没有一种登录能够做到开箱即用?其实是有的,很多大厂都会提供自己的第三方登录服务,比如QQ登录、微信登录、Google登录、Github登录...

这里以微信开放平台的接入流程为例:

OAuth机制实现流程
  1. 首先,a.com 的运营者需要在微信开放平台注册账号,并向微信申请使用微信登录功能。
  2. 申请成功后,得到申请的 appid、appsecret。
  3. 用户在 a.com 上选择使用微信登录。
  4. 这时会跳转微信的 OAuth 授权登录,并带上 a.com 的回调地址。
  5. 用户输入微信账号和密码,登录成功后,需要选择具体的授权范围,如:授权用户的头像、昵称等。
  6. 授权之后,微信会根据拉起 a.com?code=123 ,这时带上了一个临时票据 code。
  7. 获取 code 之后, a.com 会拿着 code 、appid、appsecret,向微信服务器申请 token,验证成功后,微信会下发一个 token。
  8. 有了 token 之后, a.com 就可以凭借 token 拿到对应的微信用户头像,用户昵称等信息了。
  9. a.com 提示用户登录成功,并将登录状态写入 Cookie,以作为后续访问的凭证。

# 9. 参考资料

[1] spring-security简介并与shiro对比 from kancloud (opens new window)

[2] Sa-Token--Java权限认证框架 from Github (opens new window)

[3] Spring Boot 整合 Shiro ,两种方式全总结! from 掘金 (opens new window)

[4] spring security 使用 application/json 接收数据 from CSDN (opens new window)

[5] Spring Security之多次登录失败后账户锁定功能的实现 from CSDN (opens new window)

[6] Swagger2进阶:集成统一认证和SpringSecurity的登录接口 from 稀土掘金 (opens new window)

[7] SpringSecurity认证流程 from fish-aroma (opens new window)

[8] swagger添加权限验证,swagger安全控制 from CSDN (opens new window)

[15] 4种常规的登录认证方式 from segmentfault (opens new window)

Last Updated: 10/6/2024, 4:56:30 PM