# 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的核心架构图如下:
核心架构解读:
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 开发团队所说的“应用程序安全的四大基石”——身份验证、授权、会话管理和密码学:
- 身份验证:这是证明用户是他们所说的身份的行为。
- 授权:访问控制的过程,即确定“谁”可以访问“什么”。
- 会话管理:管理特定于用户的会话,即使在非 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)
框架集成简单、开箱即用、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的本质就是一个过滤器链,内部包含了提供各种功能的过滤器,基本案例中的过滤器链如下图所示:
上图中仅展示了部分核心过滤器,非核心过滤器没有展示。
- UsernamePasswordAuthenticationFilter:负责处理我们在登录页面填写了用户名密码后的登录请求。基本案例的认证工作主要由它负责。
- ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException。
- FilterSecurityInterceptor:负责权限校验的过滤器。
可以通过Debug查看SpingSecurity过滤器链中有哪些过滤器以及它们的先后顺序。
# 4. Spring Security认证流程
# 4.1 认证流程概述
下图所展示的就是整个SpringSecurity的认证流程。
认证流程中的核心类
- Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息
- AuthenticationManager接口:定义了认证Authentication的方法
- UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法
- UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中
- UsernamePasswordAuthenticationFilter实现类:实现了我们最常用的基于用户名和密码的认证逻辑,封装Authentication对象
- DaoAuthenticationProvider实现类:是AuthenticationManager中管理的其中一个Provider,因为是要访问数据库,所以叫DaoAuthenticationProvider
# 4.2 认证流程验证
Step1:准备演示工程
在IDEA里创建SpringBoot工程,然后加入Spring web、SpringSecurity依赖,以Debug模式启动,并在下图中打入断点,然后通过浏览器提交用户名密码。
Step2:默认的登录是用UsernamepasswordAuthencitionFilter过滤器去拦截
通过Authentication接口的实现子类UsernamePasswordAuthenticationToken
封装Authentication对象,这里只有用户名和密码,还没有权限
Step3:调用AuthenticationManager的authenticate方法进行认证
因为AuthenticationManager中管理了很多Provider,所以调用的就是那一些Provider的authenticate方法进行认证
Step4:调用DaoAuthenticationProvider的authenticate方法进行认证
DaoAuthenticationProvider是AuthenticationProvider接口的其中一个实现类
Step5:调用DaoAuthenticationProvider的loadUserByUsername查询用户信息
获得DaoAuthenticationProvider的UserDetailService对象,再调用此对象的loadUserByUsername方法查询用户信息
认证流程小结:
上面五步就是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);
}
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;
}
}
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);
2
改动二:添加如下方法。
private UserAuthenticationFilter UserAuthenticationFilterBean() throws Exception {
UserAuthenticationFilter userAuthenticationFilter = new UserAuthenticationFilter();
userAuthenticationFilter.setAuthenticationManager(super.authenticationManager());
userAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
userAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
return userAuthenticationFilter;
}
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>
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);
}
}
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); // 重置锁定
... // 编写登录成功的业务逻辑
}
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{
... // 编写登录失败的业务逻辑
}
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);
}
}
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>
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());
}
}
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
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;
}
}
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>
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
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());
}
}
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();
}
}
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();
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" // 登录设备标识
},
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 8. 四种常规的登录认证方式
以下介绍4种常规的登录认证方式,每个方案各自的使用场景如下:
- Cookie + Session 历史悠久,适合于简单的后端架构,需开发人员自己处理好安全问题。
- Token 方案对后端压力小,适合大型分布式的后端架构,但已分发出去的 Token ,如果想收回权限,就不是很方便了。
- SSO 单点登录,适用于中大型企业,想要统一内部所有产品的登录方式。
- OAuth 第三方登录,简单易用,对用户和开发者都友好,但第三方平台很多,需要选择合适自己的第三方登录平台。
# 8.1 Cookie + Session 登录
HTTP 是一种无状态的协议,客户端每次发送请求时,首先要和服务器端建立一个连接,在请求完成后又会断开这个连接。这种方式可以节省传输时占用的连接资源,但同时也存在一个问题:每次请求都是独立的,服务器端无法判断本次请求和上一次请求是否来自同一个用户,进而也就无法判断用户的登录状态。为了解决 HTTP 无状态的问题,Lou Montulli 在 1994 年的时候,推出了 Cookie。
Cookie 是服务器端发送给客户端的一段特殊信息,这些信息以文本的方式存放在客户端,客户端每次向服务器端发送请求时都会带上这些特殊信息。
当访问一个页面的时候,服务器在下行http报文中,命令浏览器存储一个字符串;浏览器再访问同一个域的时候,将把这个字符串携带到上行http请求中。第一次访问一个服务器,不可能携带Cookie 。必须是服务器得到这次请求,在下行响应报头中,携带Cookie 信息,此后每一次浏览器往这个服务器发出的请求,都会携带这个Cookie 。有了 Cookie 之后,服务器端就能够获取到客户端传递过来的信息了,如果需要对信息进行验证,还需要通过 Session。
客户端请求服务端,服务端会为这次请求开辟一块内存空间,这个便是 Session 对象。
有了 Cookie 和 Session 之后,我们就可以进行登录认证了。
# 8.1.1 Cookie + Session 实现流程
session在计算机网络应用中被称为“会话控制”。客户端浏览器访问网站的时候,服务器会向客户浏览器发送一个每个用户特有的会话编号sessionID,让他进入到cookie里,服务器同时也把sessionID和对应的用户信息、用户操作记录在服务器上,这些记录就是session。客户端浏览器再次访问时,会发送cookie给服务器,其中就包含sessionID。服务器从cookie里找到sessionID,再根据sessionID找到以前记录的用户信息就可以知道他之前操控些、访问过哪里。
Cookie + Session 的登录方式是最经典的一种登录方式,现在仍然有大量的企业在使用。
用户首次登录时:
- 用户访问
a.com/pageA
,并输入密码登录。 - 服务器验证密码无误后,会创建 SessionId,并将它保存起来。
- 服务器端响应这个 HTTP 请求,并通过 Set-Cookie 头信息,将 SessionId 写入 Cookie 中。
- 浏览器会根据Set-Cookie中的信息,自动将SessionId存储至cookie中。
服务器端的 SessionId 可能存放在很多地方,例如:内存、文件、数据库等。
第一次登录完成之后,后续的访问就可以直接使用 Cookie 进行身份验证了:
- 用户访问
a.com/pageB
页面时,会自动带上第一次登录时写入的 Cookie。 - 服务器端比对 Cookie 中的 SessionId 和保存在服务器端的 SessionId 是否一致。
- 如果一致,则身份验证成功。
# 8.1.2 Cookie + Session 存在的问题
虽然我们使用 Cookie + Session 的方式完成了登录验证,但仍然存在一些问题:
- 由于服务器端需要对接大量的客户端,也就需要存放大量的 SessionId,这样会导致服务器压力过大。
- 如果服务器端是一个集群,为了同步登录态,需要将 SessionId 同步到每一台机器上,无形中增加了服务器端维护成本。
- 由于 SessionId 存放在 Cookie 中,所以无法避免 CSRF 攻击。
# 8.2 Token 登录
为了解决 Session + Cookie 机制暴露出的诸多问题,我们可以使用 Token 的登录方式。
Token是服务端生成的一串字符串,以作为客户端请求的一个令牌。当第一次登录后,服务器会生成一个 Token 并返回给客户端,客户端后续访问时,只需带上这个 Token 即可完成身份认证。
# 8.2.1 Token 机制实现流程
用户首次登录时:
- 用户输入账号密码,并点击登录。
- 服务器端验证账号密码无误,创建 Token。
- 服务器端将 Token 返回给客户端,由客户端自由保存。
后续页面访问时:
- 用户访问
a.com/pageB
时,带上第一次登录时获取的 Token。 - 服务器端验证 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 来生成签名。
playload 部分表明了 JWT 的意图:
payload = '{"loggedInAs":"admin","iat":1422779638}' //iat 表示令牌生成的时间
signature 部分为 JWT 的签名,主要为了让 JWT 不能被随意篡改,签名的方法分为两个步骤:
- 输入
base64url
编码的 header 部分 、base64url
编码的 playload 部分,输出 unsignedToken。 - 输入服务器端私钥、unsignedToken,输出 signature 签名。
const base64Header = encodeBase64(header)
const base64Payload = encodeBase64(payload)
const unsignedToken = `${base64Header}.${base64Payload}`
const key = '服务器私钥'
signature = HMAC(key, unsignedToken)
最后的 Token 计算如下:
2
3
4
5
6
7
const base64Header = encodeBase64(header)
const base64Payload = encodeBase64(payload)
const base64Signature = encodeBase64(signature)
token = `${base64Header}.${base64Payload}.${base64Signature}`
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 有效'
}
2
3
4
5
6
7
8
9
10
11
12
13
14
有了 Token 之后,登录方式已经变得非常高效。
# 8.3 SSO 单点登录
单点登录指的是在公司内部搭建一个公共的认证中心,公司下的所有产品的登录都可以在认证中心里完成,一个产品在认证中心登录后,再去访问另一个产品,可以不用再次登录,即可获取登录状态。
# 8.3.1 SSO 机制实现流程
用户首次访问时,需要在认证中心登录:
- 用户访问网站
a.com
下的 pageA 页面。 - 由于没有登录,则会重定向到认证中心,并带上回调地址
www.sso.com?return_uri=a.com/pageA
,以便登录后直接进入对应页面。 - 用户在认证中心输入账号密码,提交登录。
- 认证中心验证账号密码有效,然后重定向
a.com?ticket=123
带上授权码 ticket,并将认证中心sso.com
的登录态写入 Cookie。 - 在
a.com
服务器中,拿着 ticket 向认证中心确认,授权码 ticket 真实有效。 - 验证成功后,服务器将登录信息写入 Cookie(此时客户端有 2 个 Cookie 分别存有
a.com
和sso.com
的登录态)。
认证中心登录完成之后,继续访问 a.com
下的其他页面:
这个时候,由于 a.com
存在已登录的 Cookie 信息,所以服务器端直接认证成功。
如果认证中心登录完成之后,访问 b.com
下的页面:
这个时候,由于认证中心存在之前登录过的 Cookie,所以也不用再次输入账号密码,直接返回第 4 步,下发 ticket 给 b.com
即可。
# 8.3.2 SSO 单点登录退出
目前我们已经完成了单点登录,在同一套认证中心的管理下,多个产品可以共享登录态。现在我们需要考虑退出了,即:在一个产品中退出了登录,怎么让其他的产品也都退出登录?原理其实不难,可以回过头来看第 5 步,每一个产品在向认证中心验证 ticket 时,其实可以顺带将自己的退出登录 api 发送到认证中心。
当某个产品 c.com
退出登录时:
- 清空
c.com
中的登录态 Cookie。 - 请求认证中心
sso.com
中的退出 api。 - 认证中心遍历下发过 ticket 的所有产品,并调用对应的退出 api,完成退出。
# 8.4 OAuth 第三方登录
在上文中,我们使用单点登录完成了多产品的登录态共享,但都是建立在一套统一的认证中心下,对于一些小型企业,未免太麻烦,有没有一种登录能够做到开箱即用?其实是有的,很多大厂都会提供自己的第三方登录服务,比如QQ登录、微信登录、Google登录、Github登录...
这里以微信开放平台的接入流程为例:
- 首先,
a.com
的运营者需要在微信开放平台注册账号,并向微信申请使用微信登录功能。 - 申请成功后,得到申请的 appid、appsecret。
- 用户在
a.com
上选择使用微信登录。 - 这时会跳转微信的 OAuth 授权登录,并带上
a.com
的回调地址。 - 用户输入微信账号和密码,登录成功后,需要选择具体的授权范围,如:授权用户的头像、昵称等。
- 授权之后,微信会根据拉起
a.com?code=123
,这时带上了一个临时票据 code。 - 获取 code 之后,
a.com
会拿着 code 、appid、appsecret,向微信服务器申请 token,验证成功后,微信会下发一个 token。 - 有了 token 之后,
a.com
就可以凭借 token 拿到对应的微信用户头像,用户昵称等信息了。 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)