Spring Cloud Security
Spring Security 是一个能够为基于 Spring 的企业应用系统提供声明式的安全访问控制解决方案的安全框架。由于它是 Spring 生态系统中的一员,因此他伴随着整个 Spring 生态系统不断修正、升级,在 Spring boot 项目中加入 Spring Security 更是十分简单,使用 Spring Security 减少了企业系统安全控制编写大量重复代码的工作。
创建演示工程
准备工作
数据库准备工作,创建权限5表
//账户表
CREATE TABLE `sys_account` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '账户ID',
`account_name` varchar(30) NOT NULL COMMENT '账号',
`password` varchar(75) NOT NULL COMMENT '密码',
`nick_name` varchar(30) DEFAULT NULL COMMENT '昵称',
`salt` varchar(40) NOT NULL COMMENT '盐值',
`email` varchar(50) DEFAULT NULL COMMENT '邮箱',
`phone` varchar(18) DEFAULT NULL COMMENT '联系电话',
`status` char(1) NOT NULL DEFAULT 'N' COMMENT '状态 (''N'': 正常, ''F'': 禁用, ''D'': 删除)',
`gmt_create` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`create_account_id` int(11) DEFAULT '0' COMMENT '创建者用户ID',
`gmt_modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`modified_account_id` int(11) DEFAULT '0' COMMENT '修改者用户ID',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uq_index_account_name` (`account_name`) USING BTREE COMMENT '唯一索引---用户名'
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;
//账户--角色表
CREATE TABLE `sys_account_role` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '账户--角色表',
`account_id` int(11) NOT NULL COMMENT '账户ID',
`role_id` int(11) NOT NULL COMMENT '角色ID',
`gmt_create` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uq_index_account_role` (`account_id`,`role_id`) USING BTREE COMMENT '一个用户对于一个角色只能存在一条对应关系'
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;
//角色表
CREATE TABLE `sys_role` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '角色主键ID',
`name` varchar(30) NOT NULL COMMENT '角色名称',
`describe` varchar(150) DEFAULT NULL COMMENT '角色描述信息',
`gmt_create` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`create_account_id` int(11) DEFAULT NULL COMMENT '创建者用户ID',
`gmt_modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`modified_account_id` int(11) DEFAULT NULL COMMENT '修改者用户ID',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uq_index_role_name` (`name`) USING BTREE COMMENT '角色名称唯一'
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;
//角色--资源权限表
CREATE TABLE `sys_role_menu` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '角色-菜单表主键ID',
`role_id` int(11) NOT NULL COMMENT '角色ID',
`menu_id` int(11) NOT NULL COMMENT '菜单ID',
`gmt_create` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uq_index_role_menu` (`role_id`,`menu_id`) USING BTREE COMMENT '一个角色对于一个权限只能存在一条对应关系'
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;
//资源权限表
CREATE TABLE `sys_menu` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '菜单主键ID',
`parent_id` int(11) DEFAULT '0' COMMENT '父菜单ID,一级菜单为0',
`name` varchar(30) NOT NULL COMMENT '菜单名称',
`url` varchar(100) DEFAULT NULL COMMENT '菜单URL',
`authorization` varchar(200) DEFAULT NULL COMMENT '授权(多个用逗号分隔,如:user:list,user:create)',
`type` char(1) NOT NULL COMMENT '类型 (‘C'': 目录, ''M'': 菜单, ''B'': 按钮)',
`icon` varchar(100) DEFAULT NULL COMMENT '菜单图标',
`order_num` int(3) NOT NULL COMMENT '排序',
`gmt_create` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`create_account_id` int(11) DEFAULT NULL COMMENT '创建者用户ID',
`gmt_modified` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`modified_account_id` int(11) DEFAULT NULL COMMENT '修改者用户ID',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;
导入基础数据
// 注意这里 zhangsan 的密码是 123456 lisi 的密码是 654321
INSERT INTO `sys_account` VALUES (1, 'zhangsan', '$2a$10$aHwVVj4lt7FliXp.dHeXSOAuRGWgM3oeM47fckdALL.VPhBlVFreK', '张三', '$2a$10$aHwVVj4lt7FliXp.dHeXSO', NULL, NULL, 'N', '2020-11-25 23:41:46', 0, '2020-11-25 23:42:41', 0);
INSERT INTO `sys_account` VALUES (2, 'lisi', '$2a$10$YZE7qSS/P1OeI.jkflya2.LmspeszvLc3YG/vpnjaTrz0OrBzUpRO', '李四', '$2a$10$YZE7qSS/P1OeI.jkflya2.', NULL, NULL, 'N', '2020-11-25 23:41:50', 0, '2020-11-25 23:42:43', 0);
INSERT INTO `sys_account_role` VALUES (1, 1, 1, '2020-11-26 23:20:37');
INSERT INTO `sys_account_role` VALUES (2, 2, 2, '2020-11-26 23:20:50');
INSERT INTO `sys_role` VALUES (1, '演示角色1', '演示 Spring Security', '2020-11-26 17:42:45', 1, '2020-11-26 23:20:23', 1);
INSERT INTO `sys_role` VALUES (2, '演示角2', '演示 Spring Security', '2020-11-26 17:42:45', 1, '2020-11-26 23:20:25', 1);
INSERT INTO `sys_role_menu` VALUES (1, 1, 1, '2020-11-26 23:21:16');
INSERT INTO `sys_role_menu` VALUES (2, 1, 2, '2020-11-26 23:21:25');
INSERT INTO `sys_role_menu` VALUES (3, 1, 3, '2020-11-26 23:21:35');
INSERT INTO `sys_role_menu` VALUES (4, 2, 1, '2020-11-26 23:23:02');
INSERT INTO `sys_role_menu` VALUES (5, 2, 2, '2020-11-26 23:23:06');
INSERT INTO `sys_menu` VALUES (1, 0, 'r1接口', NULL, 'r:r1', '2', 'fa fa-cog', 0, '2020-11-26 23:15:20', 1, '2020-11-26 23:17:10', 1);
INSERT INTO `sys_menu` VALUES (2, 0, 'r2接口', NULL, 'r:r2', '2', 'fa fa-cog', 1, '2020-11-26 23:15:20', 1, '2020-11-26 23:17:10', 1);
INSERT INTO `sys_menu` VALUES (3, 0, 'r3接口', NULL, 'r:r3', '2', 'fa fa-cog', 2, '2020-11-26 23:15:20', 1, '2020-11-26 23:17:10', 1);
以上我们关于数据库的准备工作就完成,创建了权限5表,导入了基础数据;其中
'zhangsan'
赋予了'r:r1'、'r:r2'、'r:r3'
三种权限,'lisi'
赋予了'r:r1'、'r:r2'
两种权限。
明确父工程依赖版本
新建 security-auth-service 模块
导入 pom 依赖
<!--security-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- druid-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
</dependency>
<!--mybatis-plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
添加 yml 配置
server:
port: 3018
servlet:
session:
# cookie:
# http-only: true
# secure: true
timeout: 3600s
spring:
application:
name: security-auth-service
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3600/cloud?allowMultiQueries=true&useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=GMT%2B8
username: root
password: root
# 数据源其他配置
initialSize: 5
minIdle: 5
maxActive: 20
maxWait: 60000
timeBetweenEvictionRunsMillis: 60000
minEvictableIdleTimeMillis: 300000
validationQuery: SELECT 1 FROM DUAL
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
poolPreparedStatements: true
# 配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙
filters: stat,wall
maxPoolPreparedStatementPerConnectionSize: 20
useGlobalDataSourceStat: true
connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=500
#mybatis-plus
mybatis-plus:
mapper-locations: classpath*:mapper/*.xml
#实体扫描,多个package用逗号或者分号分隔
typeAliasesPackage: com.akieay.cloud.oauth2.entity;
configuration:
#是否开启驼峰命名自动映射
map-underscore-to-camel-case: true
#全局性地开启或关闭所有映射器配置文件中已配置的任何缓存。
cache-enabled: false
#指定当结果集中值为 null 的时候是否调用映射对象的 setter(map 对象时为 put)方法
call-setters-on-nulls: true
#指定 MyBatis 所用日志的具体实现,未指定时将自动查找。
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-value: 1 # 逻辑已删除值(默认为 1)
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
logging:
level:
root: info
主启动
@SpringBootApplication
@MapperScan(value = "com.akieay.cloud.security.mapper")
public class SecurityAuthApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityAuthApplication.class, args);
}
}
创建 Security 配置类
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 密码加密算法 BCrypt 推荐使用
*
* @return
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 通过自定义的 UserDetailsService 来实现查询数据库用户数据
**/
@Bean
@Override
protected UserDetailsService userDetailsService() {
return new CustomUserDetailsService();
}
/**
* 描述:设置授权处理相关的具体类以及加密方式
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
// 设置不隐藏 未找到用户异常
provider.setHideUserNotFoundExceptions(true);
// 用户认证service - 查询数据库的逻辑
provider.setUserDetailsService(userDetailsService());
// 设置密码加密算法
provider.setPasswordEncoder(passwordEncoder());
auth.authenticationProvider(provider);
}
/**
* 注入AuthenticationManager管理器
**/
@Bean
@Override
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
/**
* 按权拦截机制
*
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().authorizeRequests()
.antMatchers("/login").permitAll()
.anyRequest().authenticated()
.and()
.formLogin().successForwardUrl("/login-success")
.and()
.sessionManagement()
.invalidSessionUrl("/login")
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
}
@Override
public void configure(WebSecurity web) throws Exception {
// 可以直接访问的静态数据
web.ignoring()
.antMatchers("/css/**")
.antMatchers("/404.html")
.antMatchers("/500.html")
.antMatchers("/html/**")
.antMatchers("/js/**");
}
}
创建
AccountInfo
并继承org.springframework.security.core.userdetails.User
,用来替代原有的 User 作为UserDetails
的实现类,作为存储用户信息的实体,以便添加我们自定义的信息。
/**
* @ClassName: AccountInfo
* @Author: akieay
* @Date: 2020/11/27 - 9:30
* @Description: 用户信息类 主要提供给 Security 框架验证用,并且将用户基本信息保存到其中
*/
public class AccountInfo extends User {
/**
* 账户ID
*/
private int accountId;
public AccountInfo(String username, String password, Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
}
public AccountInfo(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
}
public int getAccountId() {
return accountId;
}
public void setAccountId(int accountId) {
this.accountId = accountId;
}
}
实现
UserDetailsService
,自定义获取用户信息的逻辑
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Resource
private SysAccountMapper sysAccountMapper;
/**
* 根据用户名加载用户信息【包含权限信息】
* @param username
* @return
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysAccountEntity sysAccount = sysAccountMapper.getByAccountName(username);
if (null == sysAccount) {
return null;
}
List<String> permissions = sysAccountMapper.listPermissionByAccountId(sysAccount.getId());
String[] authorities = permissions.toArray(new String[permissions.toArray().length]);
AccountInfo accountInfo = new AccountInfo(sysAccount.getAccountName(), sysAccount.getPassword(), AuthorityUtils.createAuthorityList(authorities));
accountInfo.setAccountId(sysAccount.getId());
return accountInfo;
}
}
创建
SecurityUtils
工具类
/**
* @ClassName: SecurityUtils
* @Author: akieay
* @Date: 2020/11/27 - 1:09
* @Description:
*/
@Slf4j
public class SecurityUtils {
/**
* 描述根据账号密码进行调用 security 进行认证授权 主动调
* 用 AuthenticationManager 的 authenticate 方法实现
* 授权成功后将用户信息存入 SecurityContext 当中
* @param username 用户名
* @param password 密码
* @param authenticationManager 认证授权管理器,
* @see AuthenticationManager
* @return AccountInfo 用户信息
*/
public static AccountInfo login(String username, String password, AuthenticationManager authenticationManager) throws AuthenticationException {
//使用security框架自带的验证token生成器 也可以自定义。
UsernamePasswordAuthenticationToken token =new UsernamePasswordAuthenticationToken(username,password );
Authentication authenticate = authenticationManager.authenticate(token);
SecurityContextHolder.getContext().setAuthentication(authenticate);
AccountInfo accountInfo = (AccountInfo) authenticate.getPrincipal();
return accountInfo;
}
/**
* 获取当前登录的所有认证信息
* @return
*/
public static Authentication getAuthentication() {
SecurityContext context = SecurityContextHolder.getContext();
return context.getAuthentication();
}
/**
* 获取当前登录用户信息
* @return
*/
public static AccountInfo getAccountInfo() {
Authentication authentication = getAuthentication();
if (null != authentication) {
Object principal = authentication.getPrincipal();
if (null != principal) {
AccountInfo accountInfo = (AccountInfo) authentication.getPrincipal();
return accountInfo;
}
}
throw new RuntimeException("请先登录");
}
/**
* 获取当前登录用户ID
* @return
*/
public static int getAccountId() {
AccountInfo accountInfo = getAccountInfo();
return accountInfo.getAccountId();
}
/**
* 获取当前登录用户名
* @return
*/
public static String getAccountName() {
AccountInfo accountInfo = getAccountInfo();
return accountInfo.getUsername();
}
/**
* 生成BCryptPasswordEncoder密码
*
* @param password 密码
* @return 加密字符串
*/
public static String encryptPassword(String password) {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
return passwordEncoder.encode(password);
}
}
至于基础的 entity、mapper、service、controller 请自行实现这里就不给出了
SysAccountMapper
的 SQL
<!--根据账户名获取账户信息-->
<select id="getByAccountName" resultMap="sysAccountMap">
SELECT
id, account_name, password, nick_name, salt, email, phone, status, gmt_create, create_account_id,
gmt_modified, modified_account_id
FROM
sys_account
WHERE
account_name = #{accountName}
</select>
<!--根据用户ID 获取权限列表-->
<select id="listPermissionByAccountId" parameterType="int" resultType="string">
SELECT
DISTINCT D.authorization
FROM
sys_account_role A INNER JOIN sys_role B ON A.role_id = B.id
INNER JOIN sys_role_menu C ON B.id = C.role_id
INNER JOIN sys_menu D ON C.menu_id = D.id
WHERE
A.account_id = #{accountId} AND D.authorization IS NOT NULL AND D.authorization != ''
</select>
测试业务类
@RestController
public class TestController {
@GetMapping("/r/r1")
@PreAuthorize("hasAuthority('r:r1')")
public String testR1() {
return SecurityUtils.getAccountName() + "访问资源r1";
}
@GetMapping("/r/r2")
@PreAuthorize("hasAuthority('r:r2')")
public String testR2() {
return SecurityUtils.getAccountName() + "访问资源r2";
}
@GetMapping("/r/r3")
@PreAuthorize("hasAuthority('r:r1') and hasAuthority('r:r3')")
public String testR3() {
return SecurityUtils.getAccountName() + "访问资源r3";
}
@PostMapping("/login-success")
@PreAuthorize("hasAnyAuthority('r:r1', 'r:r2')")
public String loginSuccess() {
return SecurityUtils.getAccountName() + "登录成功";
}
}
以上测试类:testR1 方法访问需要
r:r1
权限,testR2 方法访问需要r:r2
权限,testR3 方法访问需要r:r1 与 r:r3
权限,loginSuccess 方法访问需要r:r1
或r:r2
权限
测试
访问:http://localhost:3018/login,分别使用
zhangsan
与lisi
登录并访问/r/r1
、/r/r2
、/r/r3
;可以看到 张三都能访问,而 lisi 无权访问/r/r3
访问会报 403【无权限】错误。注意:登录后想要切换账号,需要先调用http://localhost:3018/logout
退出登录。
以上为基本的 Security 的使用,下面将介绍具体的工作原理及其扩展。
工作原理
结构总览
Spring Security
所解决的问题就是安全访问控制,而安全访问控制功能其实就是对所有进入系统的请求进行拦截,校验每个请求是否能够访问它所期望的资源。可以通过Filter
或AOP
等技术实现这个功能,Spring Security
对Web
资源的保护是靠Filter
实现的,所以从这个Filter
来入手,逐步深入Spring Security
原理。当初始化
Spring Security
时,会创建一个名为SpringSecurityFilterChain
的Servlet
过滤器,类型为org.springframework.security.web.FilterChainProxy
,它实现了javax.servlet.Filter
,因此外部的请求会经过此类,下图是Spring Security
过滤器链结构图:
FilterChainProxy
是一个代理,真正起作用的是FilterChainProxy
中SecurityFilterChain
所包含的各个Filter
,同时这些Filter
作为Bean
被Spring
管理,它们是Spring Security
的核心,各有各的职责,但它们并不直接处理用户的认证,也不直接处理用户的授权,而是把它们交给了认证管理器(AuthenticationManager
)和决策管理器(AccessDecisionManager
)进行处理,下图是FilterChainProxy
相关类下UML 图示。
Spring Security
功能的实现主要是由一系列过滤器链相互配合完成。
主要过滤器:
- **SecurityContextPersistenceFilter:**这个
Filter
是整个拦截过程的入口和出口(也就是第一个和最后一个拦截器),会在请求开始时从配置好的SecurityContextRepository
中获取SecurityContext
,然后把它设置给SecurityContextHolder
。在请求完成后将SecurityContextHolder
持有的SecurityContext
再保存到配置好的SecurityContextRepository
,同时清除SecurityContextHolder
所持有的SecurityContext
;- **UsernamePasswordAuthenticationFilter:**用于处理来自表单提交的认证。该表单必须提供对应的用户名和密码,其内部还有登陆成功或失败后进行处理的
AuthenticationSuccessHandler
和AuthenticationFailureHandler
,这些都可以根据需求做相关修改;- **FilterSecurityInterceptor:**是用于保护
web
资源的,使用AccessDecisionManager
对当前用户进行授权访问;- **ExceptionTranslationFilter:**能够捕获来自
FilterChain
所有的异常,并进行处理。但是它只会处理两类异常:AuthenticationException
和AccessDeniedException
,其它异常它会继续抛出。
Security 认证流程
认证流程
- 一、用户提交用户名、密码被
SecurityFilterChain
中的UsernamePasswordAuthenticationFilter
过滤器获取到,封装为请求Authentication
,通常情况下是UsernamePasswordAuthenticationToken
这个实现类。- 二、然后过滤器将
Authentication
提交至认证管理器(AuthenticationManager
)进行认证。- 三、认证成功后,
AuthenticationManager
身份管理器返回一个被填充了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除)Authentication
实例。- 四、
SecurityContextHolder
安全上下文容器将 第三步 填充了信息的Authentication
,通过SecurityContextHolder.getContext().setAuthentication(...)
方法,设置到其中。AuthenticationManage
接口(认证管理器)是认证相关的核心接口,也是发起认证的出发点,它的实现类为ProviderManager
。而Spring Security
支持多种认证方式,因此ProviderManager
维护着一个List<AuthenticationProvider>
列表,存放多种认证方式,最终实际的认证工作是由AuthenticationProvider
完成的。如:web 表单
的对应的AuthenticationProvider
实现类为DaoAuthenticationProvider
,它的内部又维护着一个UserDetailsService
负责UserDetails
的获取。最终AuthenticationProvider
将UserDetails
填充至Authentication
。
认证核心组件的大体关系如下:
AuthenticationProvider
通过前面的认证流程的介绍,我们可知 认证管理器(
AuthenticationManager
)委托AuthenticationProvider
完成认证工作。AuthenticationProvider
是一个接口,定义如下:
public interface AuthenticationProvider {
Authentication authenticate(Authentication var1) throws AuthenticationException;
boolean supports(Class<?> var1);
}
authenticate
方法定义了认证的实现过程,它的参数是一个Authentication
,里面包含了登录用户所提交的用户名、密码等。而返回值也是一个Authentication
,这个Authentication
则是在认证成功后,将用户的权限及其它信息重新组装后生成。Spring Security 中维护着一个
List<AuthenticationProvider>
列表,存放多种认证方式,不同的认证方式使用不同的AuthenticationProvider
。如:使用用户名密码登录时,使用 AuthenticationProvider1;短信登陆时,使用 AuthenticationProvider2 等等。每个
AuthenticationProvider
需要实现 supports() 方法来表明自己支持的认证方式。如:我们使用表单方式认证,在提交请求时Spring Security
会生成UsernamePasswordAuthenticationToken
,它就是一个Authentication
,里面封装着用户提交的用户名、密码信息。而对应的,由哪个AuthenticationProvider
来处理它呢?
我们在
DaoAuthenticationProvider
的基类AbstractUserDetailsAuthenticationProvider
发现如下方法:
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
也就是说当
web
表单提交用户名密码时,Spring Security
使用DaoAuthenticationProvider
处理认证。最后,我们来看下Authentication
(认证信息)的结构,它是一个接口,我们之前提到的UsernamePasswordAuthenticationToken
就是它的实现之一。
public interface Authentication extends Principal, Serializable { (1)
Collection<? extends GrantedAuthority> getAuthorities(); (2)
Object getCredentials(); (3)
Object getDetails(); (4)
Object getPrincipal(); (5)
boolean isAuthenticated(); (6)
void setAuthenticated(boolean var1) throws IllegalArgumentException;
}
- (1)Authentication 是
Spring Security
包中的接口,直接继承自Principal
类,而Principal
是位于java.security
包中的,他表示一个抽象主体身份,任何主体都有一个名称,因此包含一个 getName() 方法。- (2)getAuthorities(),权限信息列表,默认是
GrantedAuthority
接口的一些实现类,通常是代表权限信息的一系列字符串。- (3)getCredentials(),凭证信息,用户输入的密码字符串,在认证过后通常会被删除,用于保障安全。
- (4)getDetails(),细节信息,
web
应用中的实现接口通常为WebAuthenticationDetails
,它记录了访问者的 ip 地址和 sessionId 的值。- (5)getPrincipal(),身份信息,大部分情况下返回的是
UserDetails
接口的实现类,UserDetails
代表用户的详细信息,从Authentication
中取出来的UserDetails
就是当前登录用户信息,它也是框架中常用接口之一。- (6)isAuthenticated(),表示是否用户认证通过。
UserDetailsService
由上可知,
DaoAuthenticationProvider
处理了 web 表单认证逻辑,认证成功后即得到一个Authentication
(UsernamePasswordAuthenticationToken
实现),里面包含了身份信息(Principal)。这个身份信息就是一个 Object,大多数情况下它可以被强转为 UserDetails 对象。
DaoAuthenticationProvider
中包含一个UserDetailsService
实例,它负责根据用户名提取用户信息UserDetails
(含密码) ,而后DaoAuthenticationProvider
会去对比UserDetailsService
提取的用户密码与用户提交的密码是否匹配作为认证成功的关键依据。由此我们可以通过自定义UserDetailsService
来实现自定义的身份验证逻辑。
public interface UserDetailsService {
UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}
注意
DaoAuthenticationProvider
与UserDetailsService
的职责区分,UserDetailsService
只负责从特定的地方(通常是数据库)加载用户信息;而DaoAuthenticationProvider
负责完整的认证流程,同时会把UserDetails
填充到Authentication
中。
PasswordEncoder
由上可知,
DaoAuthenticationProvider
通过UserDetailsService
获取到UserDetails
,那么它是如何与请求Authentication
中的密码做对比的呢?在这里 Spring Security 为了适应多种多样的加密类型,又做了抽象,
DaoAuthenticationProvider
通过PasswordEncoder
接口的 matches 方法进行密码的比对,而具体的密码比对细节取决于实现类:
public interface PasswordEncoder {
String encode(CharSequence var1);
boolean matches(CharSequence var1, String var2);
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
Spring Security 提供了很多内置的
PasswordEncoder
,能够开箱即用,使用某种PasswordEncoder
只需要进行如下声明即可:
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
NoOpPasswordEncoder
采用字符串匹配方法,不对密码进行加密比较,其实现如下:
密码的比较流程如下:
- 用户输入密码(明文)
DaoAuthenticationProvider
获取UserDetails
(其中存储了用户的密码)DaoAuthenticationProvider
使用PasswordEncoder
对输入密码与正确密码进行校验,密码一致则通过校验,否则校验失败;具体的校验规则取决于PasswordEncoder
的实现类。在实际项目中推荐使用
BCryptPasswordEncoder
、Pbkdf2PasswordEncoder
、SCryptPasswordEncoder
等;感兴趣的可以看看其具体实现,具体实现类如下:【其中中间划横线的是不再建议使用的】
在这里我使用
BCryptPasswordEncoder
,具体修改如下:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
注意一旦修改了
PasswordEncoder
,其对应的密码也应该改为其实现的密码加密后的字符串。
Security 授权流程
授权流程
Spring Security 可以通过
http.authorizeRequests()
对 web 请求进行授权保护。Spring Security 使用标准的Filter
建立了对 web 请求的拦截,最终实现对资源的授权访问。Spring Security 授权流程如下:
分析授权流程:
一、拦截请求,已认证的用户访问受保护的 web 资源时,将被
SecurityFilterChain
中的FilterSecurityInterceptor
的子类拦截。二、获取资源访问策略,
FilterSecurityInterceptor
会从SecurityMetadataSource
的子类DefaultFilterInvocationSecurityMetadataSource
获取要访问当前资源所需要的权限Collection<ConfigAttribute>
。SecurityMetadataSource
其实就是读取访问策略的抽象,而读取的内容,其实就是我们配置的访问规则,读取访问策略如:http.csrf().disable().authorizeRequests() .antMatchers("/r/r1").hasAuthority("p1") .antMatchers("/r/r2").hasAuthority("p2")
三、最后,
FilterSecurityInterceptor
会调用AccessDecisionManager
进行授权决策,若决策通过,则允许访问资源,否则将禁止访问。
AccessDecisionManager
AccessDecisionManager
(访问决策管理器)的核心接口如下:
public interface AccessDecisionManager {
/**
* 通过传递的参数来决定用户是否有访问对应受保护资源的权限
*/
void decide(Authentication var1, Object var2, Collection<ConfigAttribute> var3) throws AccessDeniedException, InsufficientAuthenticationException;
boolean supports(ConfigAttribute var1);
boolean supports(Class<?> var1);
}
decide() 方法参数介绍:
Authentication:要访问资源的访问者的身份【包含用户权限信息】;
Object:要访问的受保护资源,web 请求对应
FilterInvocation
;Collection:受保护资源的访问策略【访问资源所需的权限】,通过
SecurityMetadataSource
获取。decide方法就是用来鉴定当前用户是否有访问对应受保护资源的权限。
其实现类如下:
AccessDecisionManager 授权决策
AccessDecisionManager
采用投票的方式来确定是否能够访问受保护的资源。
AccessDecisionManager
中包含的一系列AccessDecisionVoter
将会被用来对Authentication
是否有权访问受保护对象进行投票,AccessDecisionManager
根据投票结果,做出最终决策。
AccessDecisionVoter
是一个接口,其中定义有三个方法,具体结构如下:
public interface AccessDecisionVoter<S> {
int ACCESS_GRANTED = 1; //同意
int ACCESS_ABSTAIN = 0; //弃权
int ACCESS_DENIED = -1; //拒绝
boolean supports(ConfigAttribute var1);
boolean supports(Class<?> var1);
int vote(Authentication var1, S var2, Collection<ConfigAttribute> var3);
}
vote() 方法用来返回投票结果,其返回结果会是
AccessDecisionVoter
中定义的三个常量之一,ACCESS_GRANTED 表示同意、ACCESS_ABSTAIN 表示弃权、ACCESS_DENIED 表示拒绝。当一个AccessDecisionVoter
不能判定当前Authentication
是否拥有访问对应资源的权限时,其 vote() 方法的返回结果应为 ACCESS_ABSTAIN【弃权】。Spring Security 内置了三个基于投票的
AccessDecisionManager
实现类,分别是:AffirmativeBased
、ConsensusBased
、UnanimousBased
,默认采用AffirmativeBased
。
AffirmativeBased
AffirmativeBased
的决策逻辑为:(1)只要有
AccessDecisionVoter
的投票为 ACCESS_GRANTED,则同意用户进行访问;(2)如果全部弃权,也表示通过;
(3)如果没有一个投同意票,但是有人投反对票,则将抛出
AccessDeniedException
异常。总结:当没有人投同意票且有人投反对票时,则拒绝访问;否则,将允许用户访问。
Spring Security 默认使用的是 AffirmativeBased
ConsensusBased
ConsensusBased
的决策逻辑为:(1)如果赞成票多于反对票则表示通过;
(2)反过来,如果反对票多余赞成票则将抛出
AccessDeniedException
;(3)如果赞成票与反对票相同且不等于0,并且属性
allowIfEqualGrantedDeniedDecisions
的值为 true,则表示通过,否则将抛出异常AccessDeniedException
。参数allowIfEqualGrantedDeniedDecisions
的值默认为 true。(4)如果所有的
AccessDecisionVoter
都弃权了,则将视参数allowIfAllAbstainDecisions
的值而定,如果该值为 true 则表示通过,否则将抛出异常AccessDeniedException
。参数allowIfAllAbstainDecisions
的默认值为 false。
UnanimousBased
UnanimousBased
的逻辑与另外两种实现有点不一样,另外两种会一次性把受保护对象的配置属性全部传递给AccessDecisionVoter
进行投票,而UnanimousBased
会一次只传递一个ConfigAttribute
给AccessDecisionVoter
进行投票。
UnanimousBased
的决策逻辑具体来说是这样的:(1)如果受保护对象配置的某一个
ConfigAttribute
被任意的AccessDecisionVoter
反对了,则将抛出AccessDeniedException
。(3)如果没有反对票,但是有赞成票,则表示通过。
(3)如果全部弃权了,则将视参数
allowIfAllAbstainDecisions
的值而定,true 则通过,false 则抛出AccessDeniedException
。总结:只要有一个反对了,就拒绝;没有反对票且有赞成票,则通过;若是全部弃权了,则根据参数
allowIfAllAbstainDecisions
而定;Spring Security 也内置一些投票者实现类,如:RoleVoter、AuthenticatedVoter、WebExpressionBoter 等,可以自行查阅学习。
自定义认证
会话
用户认证通过以后,为了避免用户的每次操作都进行认证,可将用户信息保存在会话中。Spring Security 提供会话管理,认证通过后将身份信息放入 SecurityContextHolder 上下文,SecurityContext 与当前线程进行绑定,方便获取用户身份。
获取用户身份
修改 TestController,添加 getUsername 方法,注意 Spring Security 获取当前登录用户信息的方法为 SecurityContextHolder.getContext().getAuthentication()
@RestController
public class TestController {
@GetMapping("/r/r1")
public String testR1() {
return getUsername() + "访问资源r1";
}
@GetMapping("/r/r2")
public String testR2() {
return getUsername() + "访问资源r2";
}
@PostMapping("/login-success")
public String loginSuccess() {
return getUsername() + "登录成功";
}
/**
* 获取登录用户名
*/
private String getUsername() {
String username = null;
//当前认证通过的用户身份
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
//用户身份
Object principal = authentication.getPrincipal();
if (null == principal) {
username = "匿名";
}
if (principal instanceof UserDetails) {
UserDetails userDetails = (UserDetails) principal;
username = userDetails.getUsername();
} else {
username = principal.toString();
}
return username;
}
}
会话控制
我们可以通过以下选项准确的控制会话何时创建以及 Spring Security 如何与之交互:
机制 | 描述 |
---|---|
ALWAYS | 如果没有 Session 存在就创建一个 |
IF_REQUIRED | 如果需要就创建一个 Session(默认)登陆时 |
NEVER | Spring Security 将不会创建 Session,但是如果应用中其它地方创建了 Session,那么 Spring Security 将会使用它。 |
STATELESS | Spring Security 将绝对不会创建 Session,也不使用 Session |
通过以下配置方式可以对该选项进行配置:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
}
默认情况下,Spring Security 会为每个登陆成功的用户新建一个 Session ,就是 IF_REQUIRED。
若选用 NEVER ,Spring Security 对登陆成功的用户不会创建 Session,但若你的应用程序在某个地方新建了 session,那么 Spring Security 会用它的。
若使用 STATELESS,Spring Security 对登陆成功的用户不会创建 Session,你的应用程序也不会允许新建 Session。并且它会暗示不适用 cookie,所以每个请求都需要重新进行身份验证。这种无状态架构适用于REST API 及其它无状态认证机制。
会话超时
可以在 servlet 容器中设置 Session 的超时时间,如下设置 Session 有效期为 3600s;
server:
servlet:
session:
timeout: 3600s
session 超时之后,可以通过 Spring Security 设置跳转的路径。
http.sessionManagement()
.invalidSessionUrl("/login")
invalidSessionUrl 指传入的 sessionId 无效。
安全会话 cookie
我们可以使用 httpOnly 和 secure 标签来保护我们的会话 cookie:
- httpOnly:如果为 true,那么浏览器脚本将无法访问 cookie
- secure:如果为 true,则 cookie 将仅通过 HTTPS 连接发送
server:
servlet:
session:
cookie:
http-only: true
secure: true
授权
授权的方式包括 web 授权和方法授权,web 授权是通过 url 拦截进行授权,方法授权是通过 方法拦截进行授权。它们都会调用 accessDecisionManager 进行授权决策,若为 web 授权则拦截器为 FilterSecurityInterceptor;若为方法授权则拦截器为 MethodSecurityInterceptor。如果同时通过 web 授权和方法授权则优先执行 web 授权,再执行方法授权;最后决策通过,则允许访问资源,否则将禁止访问。
类关系如下:
web 授权
再上面的例子中我们完成了认证拦截,并对
/r/**
下的某些资源进行了简单的授权保护,但是我们想进行灵活的授权控制该怎么做呢?通过给 http.authorizeRequests() 添加多个子节点来定制需求到我们的 URL,如下面的案例:
@Override
protected void configure(HttpSecurity http) throws Exception {
(1) http.authorizeRequests()
(2) .antMatchers("/r/r1").hasAuthority("p1")
(3) .antMatchers("/r/r2").hasAuthority("p2")
(4) .antMatchers("/r/r3").access("hasAuthority('p1') and hasAuthority('p2')")
(5) .antMatchers("/r/**").authenticated()
(6) .anyRequest().permitAll()
.and()
.formLogin()
// .....
}
(1)http.authorizeRequests() 方法有多个子节点,每个 matcher 安装它们的声明顺序执行。
(2)指定 “/r/r1” URL,拥有 p1 权限才能访问
(3)指定 “/r/r2” URL,拥有 p2 权限才能访问
(4)指定 “/r/r3” URL,拥有 p1 和 p2 权限才能访问
(5)指定 除了上面的资源外 “/r/**” 的其它所有资源,只要通过身份认证就可以访问
(6)剩余的尚未匹配的资源,不做保护。
注意:
规则的顺序是很重要的,更具体的规则应该先写;否则一旦前面的规则匹配上了,就不会再匹配下面的规则;
如:若是把 第(5)条规则放在第(2)条规则之前,则后面的(2)(3)(4)都不会被匹配到
http.authorizeRequests()
.antMatchers("/r/**").authenticated()
.antMatchers("/r/r1").hasAuthority("p1")
.antMatchers("/r/r2").hasAuthority("p2")
.antMatchers("/r/r3").access("hasAuthority('p1') and hasAuthority('p2')")
.anyRequest().permitAll()
.and()
.formLogin()
// .....
这时,只需要登录即可访问 r1、r2、r3 不会再验证是否具有 p1、p2 权限;所以需要特别注意规则的添加顺序。
常见的方法有:
authenticated(): 保护 URL,需要用户登录
permitAll(): 指定 URL 无需保护,一般应用与静态资源文件
hasRole(String role): 限制单个角色访问,角色将被增加 “ROLE_” 所以 “ADMIN” 角色的实际写法为 “ROLE_ADMIN”
hasAuthority(String authority): 限制单个权限访问
hasAnyRole(String …role): 允许多个角色访问
hasAnyAuthority(String …authority): 允许多个权限访问
access(String attribute): 该方法使用 SpEL 表达式,所以可以创建复杂的限制
**hasIpAddress(String ipaddressExpression):**限制 IP 地址或子网
方法授权
现在我们已经掌握了如何使用 http.authorizeRequests() 对 web 资源进行授权保护,从 Spring Security 2.0 版本开始,它提供了服务层方法的安全性支持。本节学习如何使用 @PreAuthorize、@PostAuthorize、@Secured 注解。
首先 我们需要添加 @EnableGlobalMethodSecurity 开启基于注解的安全性规则,然后再方法(在类或接口上)添加注解就会限制对该方法的访问
@Configuration
@EnableGlobalMethodSecurity( securedEnabled = true, prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
一、使用 @Secured 注解
@GetMapping("/r/r3")
@Secured("ROLE_TELLER")
public String testR3() {
return getUsername() + "访问资源r3";
}
@PostMapping("/login-success")
@Secured("IS_AUTHENTICATED_ANONYMOUSLY")
public String loginSuccess() {
return getUsername() + "登录成功";
}
以上配置标明 loginSuccess 方法可以匿名访问,testR3 方法需要 TELLER 角色才能访问。
二、使用 @PreAuthorize 注解
@GetMapping("/r/r1")
@PreAuthorize("hasAuthority('r:r1')")
public String testR1() {
return SecurityUtils.getAccountName() + "访问资源r1";
}
@GetMapping("/r/r2")
@PreAuthorize("hasAuthority('r:r2')")
public String testR2() {
return SecurityUtils.getAccountName() + "访问资源r2";
}
@GetMapping("/r/r3")
@PreAuthorize("hasAuthority('r:r1') and hasAuthority('r:r3')")
public String testR3() {
return SecurityUtils.getAccountName() + "访问资源r3";
}
@PostMapping("/login-success")
@PreAuthorize("isAnonymous()")
public String loginSuccess() {
return SecurityUtils.getAccountName() + "登录成功";
}
以上配置标明:testR1 需要 ‘r:r1’ 权限,testR2 需要 ‘r:r2’ 权限才能访问;testR3 需要 ‘r:r1’ 与 ‘r:r3’ 权限才能访问;loginSuccess 可以匿名访问。
三、使用 @PostAuthorize 注解
由于 @PostAuthorize 与 @PreAuthoriz 使用方法差不多,这里就不单独介绍了。有兴趣的可以自己试试。
@PreAuthorize 与 @PostAuthorize 的区别在于:
@PreAuthoriz 在方法执行前进行拦截,@PostAuthorize 在方法执行之后进行拦截。