一、SpringSecurity 简介
1.1 SpringSecurity 简介
SpringSecurity
是基于Spring
框架,提供了一套Web
应用安全性的完整解决方案,核心功能就是用户认证(Authentication)
和用户授权(Authorization)
。
用户认证:通俗的说就是系统认为用户是否登录。
用户授权:通俗的说就是系统判断用户是否有权限去做某些事情。
1.2 SpringSecurity 与 Shiro
SSM
配置SpringSecurity
会很麻烦,而配置Shiro
则简单很多,因此在SSM
时使用Shiro
很多;而使用SpringBoot
使用SpringSecurity
则会很简单,因此使用SpringBoot
创建的项目使用SpringSecurity
比较多。
SpringSecurity 优点
- 和 Spring 无缝整合;
- 全面的权限控制;
- 专门为 Web 开发而设计。旧版本不能脱离 Web 环境使用;新版本对真个框架进行了分层抽取,分成了核心模块和 Web 模块。单独引入核心模块就可以脱离 Web 环境。
- 重量级框架。
Shiro 优点
- Apache 旗下轻量级权限控制框架;
- 可以脱离 Web 环境使用;在 Web 环境下一些特定的需求需要手动编写代码定制。
1.3 SpringSecutiry 入门案例
引入依赖
<!-- SpringBoot依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- web 环境依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Security 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
编写控制器
@RestController
@RequestMapping("/test")
public class MainController {
@GetMapping("hello")
public String hello() {
return "Hello Security";
}
}
访问控制器
访问http://127.0.0.1:8080/test/hello
页面会重定向到登录页面,如下图:
- 默认用户名:user
- 默认密码:在控制台中打印,如下图:
1.4 SpringSecutiry 基本原理
在 SpringBoot 中,我们只需要引入 SpringSecurity 依赖,就可以使用 SpringSecurity 的功能,这是因为 SpringBoot 为我们自动化配置,因此不需要我们手动配置。SpringSecurity 本质上就是过滤器链,由一个个过滤器组成。在程序运行后,可以控制台看到如下代码,这里所展示的过滤器就是组成 SpringSecutiry 的所有过滤器。
# 下面所有类省略 org.springframework.security.web 前缀包名
.context.request.async.WebAsyncManagerIntegrationFilter
.context.SecurityContextPersistenceFilter
.header.HeaderWriterFilter
.csrf.CsrfFilter
.authentication.logout.LogoutFilter
# 对/login的 POST 请求做拦截,校验表单中的用户名、密码
.authentication.UsernamePasswordAuthenticationFilter
.authentication.ui.DefaultLoginPageGeneratingFilter
.authentication.ui.DefaultLogoutPageGeneratingFilter
.authentication.www.BasicAuthenticationFilter
.savedrequest.RequestCacheAwareFilter
.servletapi.SecurityContextHolderAwareRequestFilter
.authentication.AnonymousAuthenticationFilter
.session.SessionManagementFilter
# 异常过滤器,用来处理在认证授权过程中抛出的异常
.access.ExceptionTranslationFilter
# 方法级的权限过滤器,位于过滤链的最底部
.access.intercept.FilterSecurityInterceptor
运行代码时会执行到FilterSecurityInterceptor
的如下方法中:super.beforeInvocation(filterIncocation)
表示查看之前的 filter 是否通过。fi.getChain().doFilter(fi.getRequest(), fi.getResponse())
真正的调用后台任务。
1.5 SpringSecurity 两个重要的接口
1.5.1 UserDetailService 接口
当我们没有配置任何东西时,账号和密码是由 SpringSecurity 定义生成的。而在实际项目中,账号和密码都是从数据库中查询出来,所以我们需要自定义逻辑来控制认证逻辑。
所以当我们需要自定义逻辑时,首先需要创建一个类继承UsernamePasswordAuthenticationFilter
类,并重写三个方法:
public abstract Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
// 认证成功回调
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult)
// 认证失败回调
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed)
其次需要实现UserDetailsService
接口(我们需要从数据库中查询用户名和密码,就在这个接口中进行查询):
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
UserDetails
类就是系统默认的用户“主体”,其结构如下:
public interface UserDetails extends Serializable {
// 表示获取登录用户所有权限
Collection<? extends GrantedAuthority> getAuthorities();
// 获取密码
String getPassword();
// 获取用户名
String getUsername();
// 判断账户是否过期
boolean isAccountNonExpired();
// 表示账号是否被锁定
boolean isAccountNonLocked();
// 表示密码是否过期
boolean isCredentialsNonExpired();
// 表示当前用户是否可用
boolean isEnabled();
}
SpringSecurity 框架中有一个名为User
的类,其实现了UserDetails
接口,构造如下:
public User(String username, String password, Collection<? extends GrantedAuthority> authorities) {
this(username, password, true, true, true, true, authorities);
}
注意的是:方法参数username
表示用户名。这个值是客户端表单传递过来的数据。默认情况下必须叫username
,否则无法接收。
1.5.2 PasswordEncoder 接口
public interface PasswordEncoder {
// 解析密码
String encode(CharSequence rawPassword);
// 判断密码是否匹配
// 第一个参数表示需要被解析的密码
// 第二个参数表示存储的密码
boolean matches(CharSequence rawPassword, String encodedPassword);
// 如果解析的密码能够再次进行解析且达到更安全的结果则返回true,否则返回false
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
BCryptPasswordEncoder
是 Spring Security 推荐的密码解析器。它是对bcrypt
强散列方法的具体实现,是基于 Hash 算法实现的单向加密。可以通过strength
控制加密强度,默认为10
。
使用步骤如下:
public void checkPwd() {
// 创建密码解析器
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
// 对密码进行加密
String password = encoder.encode("原始密码");
// 判断原字符加密后和加密之前是否匹配
boolean result = encoder.matches("原始密码", password);
}
二、SpringSecurity Web 权限方案
2.1 设置用户名和密码
方式1:配置文件配置
spring.security.user.name=xiaoxia
spring.security.user.password=xiaoxia
方式2:配置类
// 1.继承 WebSecurityConfigurerAdapter 类
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 2.重写 configure 方法
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
String username = "admin";
String password = "123";
String role = "admin";
// 对密码进行加密
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
password = encoder.encode(password);
auth.inMemoryAuthentication() // 添加权限,从内存中获取
.withUser(username) // 添加用户名
.password(password) // 添加密码
.roles(role); // 添加角色
}
// 因为configure()方法使用到了BCryptPasswordEncoder,所以需要我们进行注入,否则会报异常
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
在上面代码中,如果没有注入PasswordEncoder
则会出现下面异常:
There is no PasswordEncoder mapped for the id "null"
方式3:自定义编写实现类
上面 2 个方案在真实项目中并不适用,因为用户名和密码都是从数据库中查找出来的,因此我们需要自定义编写实现类。
Spring Security 在认证过程中,会去先找配置文件和配置类,如果没有找到就会找到继承UserDetailsService
接口的类。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 注入UserDetailsService
@Autowired
UserDetailsService userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService) // 设置UserDetailsService的实现类
.passwordEncoder(passwordEncoder()); // 设置密码解析器
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
// 注意这里的userDetailsService一定要与SecurityConfig注入的名字保持一致
// 这里测试的时候,@Service中没有内容也可以
@Service("userDetailsService")
public class MyUserDetailsService implements UserDetailsService {
// username:这里的username是我们从表单提交中获取到的数据
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 我们在这里查询数据库
// 创建权限角色
List<GrantedAuthority> authorities =
AuthorityUtils.commaSeparatedStringToAuthorityList("admin");
// User:这个User实现了UserDetails接口,所以我们可以直接返回
return new User(username,
new BCryptPasswordEncoder().encode("1234"),
authorities);
}
}
2.2 自定义用户登录页面
当我们访问链接时会跳转到Spring Security
的登录页面。我们可以自定义这个登录页面。
首先需要自定义一个登录页面login.html
,注意from
表单的action
就是我们登录的Controller
路径,该Controller
不需要我们来写。
注意:
method
的属性必须是post
。- 用户名的
name
属性必须是username
。 - 密码的
name
属性必须是password
。
这是因为在Spring Security
的过滤器UsernamePasswordAuthenticationFilter
中定义的。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<form action="/user/login" method="post">
用户名:<input type="text" name="username"><br>
密码:<input type="password" name="password"><br>
<input type="submit" value="登录">
</form>
</body>
</html>
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() // 自定义自己编写登录页面
.loginPage("/login.html") // 配置哪个url为登录页面
.loginProcessingUrl("/user/login") // 登录的Controller访问路径,不需要我们写,提供路径即可
.defaultSuccessUrl("/test/index") // 登录成功默认的页面
.permitAll()
.and().authorizeRequests()
// 设置哪些页面不需要经过认证
.antMatchers("/","/test/hello","/user/login")
.permitAll() // 指定URL,无需保护
.anyRequest() // 其他请求
.authenticated() // 需要认证
.and().csrf().disable(); // 关闭csrf防护
}
当然如果需要修改默认参数username
和password
则需要使用下面方法:
http.formLogin() // 自定义自己编写登录页面
.loginPage("/login.html") // 设置默认登录路径
.loginProcessingUrl("/user/login") // 登录的Controller访问路径,不需要我们写,提供路径即可
.defaultSuccessUrl("/test/index") // 登录成功默认的页面
.successForwardUrl("") // 登录成功跳转的页面
.failureForwardUrl("") // 登录失败跳转的页面
.usernameParameter("name") // 修改前端传入的默认参数 username
.passwordParameter("pwd") // 修改前端传入的默认参数 password
这样前端传参的时候就需要使用name
和pwd
的属性了。
<form action="/user/login" method="post">
用户名:<input type="text" name="name"><br>
密码:<input type="password" name="pwd"><br>
<input type="submit" value="登录">
</form>
2.3 权限
2.3.1 hasAuthority 和 hasAnyAuthority
hasAuthority
和hasAnyAuthority
的区别就是前者只能设置一个角色,而后者可以设置多个角色。
使用如下:
// 设置 find1 只能有admin身份访问
.antMatchers("/find1").hasAuthority("admin")
// 设置 find2 由 admin和manager两个身份访问
.antMatchers("/find2").hasAnyAuthority("admin","manager")
添加权限如下:
List<GrantedAuthority> authorities =
AuthorityUtils.commaSeparatedStringToAuthorityList("admin,manager");
hasAuthority
和hasAnyAuthority
的源码如下,这样就会明白hasAnyAuthority
为什么会在添加权限时使用,
并且是在一个参数中:
// ExpressionUrlAuthorizationConfigurer.java
private static String hasAuthority(String authority) {
return "hasAuthority('" + authority + "')";
}
private static String hasAnyAuthority(String... authorities) {
String anyAuthorities = StringUtils.arrayToDelimitedString(authorities, "','");
return "hasAnyAuthority('" + anyAuthorities + "')";
}
2.3.2 hasRole 与 hasAnyRole
如果用户具备给定角色就允许访问,否则出现 403。如果当前主体具有指定的角色,则返回true。
使用如下:
.antMatchers("/find3").hasRole("role")
.antMatchers("/find4").hasAnyRole("role","sale")
添加权限如下:
List<GrantedAuthority> authorities = AuthorityUtils.
commaSeparatedStringToAuthorityList("admin,manager,ROLE_role,ROLE_sale");
hasRole
和hasAnyRole
的源码如下,这样也就明白为啥会在身份之前添加ROLE_
前缀:
// ExpressionUrlAuthorizationConfigurer.java
// 这里可以添加 rolePrefix 前缀
private static String hasAnyRole(String rolePrefix, String... authorities) {
String anyAuthorities = StringUtils.arrayToDelimitedString(authorities, "','" + rolePrefix);
return "hasAnyRole('" + rolePrefix + anyAuthorities + "')";
}
private static String hasRole(String rolePrefix, String role) {
Assert.notNull(role, "role cannot be null");
Assert.isTrue(rolePrefix.isEmpty() || !role.startsWith(rolePrefix), () -> "role should not start with '"
+ rolePrefix + "' since it is automatically inserted. Got '" + role + "'");
return "hasRole('" + rolePrefix + role + "')";
}
// 如果我们不自己添加前缀,则使用默认前缀
public ExpressionUrlAuthorizationConfigurer(ApplicationContext context) {
String[] grantedAuthorityDefaultsBeanNames = context.getBeanNamesForType(GrantedAuthorityDefaults.class);
if (grantedAuthorityDefaultsBeanNames.length == 1) {
GrantedAuthorityDefaults grantedAuthorityDefaults = context.getBean(grantedAuthorityDefaultsBeanNames[0],
GrantedAuthorityDefaults.class);
this.rolePrefix = grantedAuthorityDefaults.getRolePrefix();
}
else {
// 添加默认前缀
this.rolePrefix = "ROLE_";
}
this.REGISTRY = new ExpressionInterceptUrlRegistry(context);
}
2.3.3 自定义 403 页面
@Override
protected void configure(HttpSecurity http) throws Exception {
// 自定义配置 403 页面
http.exceptionHandling().accessDeniedPage("/unauth");
}
2.4 权限注解
2.4.1 @Secured
@Secured
注解作用是:判断是否具有角色,需要添加前缀“ROLE_”。
步骤1:开启注解
@SpringBootApplication
@MapperScan("com.example.springsecuritystudy.mapper")
// 开启注解
@EnableGlobalMethodSecurity(securedEnabled = true)
public class SpringSecurityStudyApplication {
public static void main(String[] args) {
SpringApplication.run(SpringSecurityStudyApplication.class, args);
}
}
步骤2:在Controller方法上添加注解
@GetMapping("main")
@Secured({"ROLE_role","Role_admin"})
public String main() {
return "MAIN";
}
2.4.2 @PreAuthorize
@Preauthorize
注解作用:在进入方法前验证权限。
步骤1:开启注解
@SpringBootApplication
@MapperScan("com.example.springsecuritystudy.mapper")
@EnableGlobalMethodSecurity(
securedEnabled = true,
prePostEnabled = true) // 开启@PreAuthorize和@PostAuthorize注解权限
步骤2:在Controller方法上添加注解
@GetMapping("main")
@PreAuthorize("hasAuthority('admin')")
@PreAuthorize("hasAnyAuthority('admin,root')")
@PreAuthorize("hasRole('ROLE_admin')")
@PreAuthorize("hasAnyRole('ROLE_admin','ROLE_root')")
public String main() {
return "MAIN";
}
2.4.3 @PostAuthorize
@PostAuthorize
和@PreAuthorize
使用方式一模一样,不过@PostAuthorize
的作用是在方法执行后再进行权限验证,适合验证带有返回值的权限。该注解使用不多。
2.4.4 @PostFilter
@PostFilter
的作用是权限验证之后数据进行过滤。
示例如下:
@GetMapping("postFilter")
@Secured("ROLE_admin")
@PostFilter("filterObject.password=='456'")
public List<User> postFilter() {
List<User> list = new ArrayList<>();
list.add(new User(1, "admin1", "123"));
list.add(new User(2, "admin2", "456"));
return list;
}
上面内容访问的结果如下:
[{"id":2,"username":"admin2","password":"456"}]
这里需要注意的是:
- 使用
@PostFilter
注解后的方法返回值必须是集合或者Map类型,其他类型会报异常。 filterObject
代表集合中每一个类型(实体类),password
代表实体类中的属性。- 参数匹配注意使用
==
双等于符号。
2.4.5 @PreFilter
@PreFilter
的作用是对入参进行过滤。
@GetMapping("preFilter")
@Secured("ROLE_admin")
@PreFilter(value = "filterObject.id%2==0")
public List<User> preFilter(@RequestBody List<User> list) {
list.forEach(item -> {
System.out.println(item.getId() + " -- "+ item.getUsername());
})
return list;
}
更多权限表达式可见官网文档:https://docs.spring.io/spring-security/site/docs/5.3.4.RELEASE/reference/html5/#el-access
https://docs.spring.io/spring-security/site/docs/5.3.4.RELEASE/reference/html5/#el-access
2.5 注销功能
@Override
protected void configure(HttpSecurity http) throws Exception {
http.logout() // 注销
.logoutUrl("/logout") // 注销访问的url
.logoutSuccessUrl("/index") // 注销成功后跳转到的页面
.permitAll()
}
2.6 自动登录功能(记住密码功能)
实现自动登录或者记住密码的功能原理如下:
- 客户端技术 Cookie 可以设置过期时长。
- 当我们在浏览器访问时,经过安全验证。
- 验证成功后将一段加密串设置到cookie中并设置有效时长,保存在浏览器中。
- 验证成功后同时也会将这一段加密串和用户信息字符串对应,并保存到数据库中。
- 当再次访问时,获取cookie信息,拿着cookie的信息到数据库进行比对,如果查询到对应信息,认证成功,直接登录。
Spring Security的实现流程:
- 浏览器请求认证,经过
UsernamePasswordAuthenticationFilter
过滤器。 - 认证成功后,调用
RemeberMeService
类,通过TokenRepository
类包装 Token,将 Token 写入浏览器的 Cookie 中。 - 同时,通过
JdbcTokenRepositoryImpl
类将该 Token 保存到数据库中。 - 再次发起请求,会经过
RememberMeAuthenticationFilter
过滤器,读取 Cookie 中的 Token。 - 并从数据库中查到对应的 Token,在
UserDetailsService
中判断。
步骤1:新建数据表(可省略)
CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL,
`series` varchar(64) NOT NULL,
`token` varchar(64) NOT NULL,
`last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE
CURRENT_TIMESTAMP,
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
步骤2:配置数据源
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/spring_security?userUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=123456
步骤3:创建配置类
@Configuration
public class BrowserSecurityConfig {
@Autowired
DataSource dataSource; // 注入数据源
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
// Spring Security 已经为我们实现好数据库的实现,所以我们只需要提供数据源即可
jdbcTokenRepository.setDataSource(dataSource);
// 自动创建数据表,第一次执行会创建,以后要执行就要删除掉!
// 这里设置了,就可以省略掉第一步
jdbcTokenRepository.setCreateTableOnStartup(true);
return jdbcTokenRepository;
}
}
步骤4:修改安全配置类
@Autowired
UserDetailsService userDetailsService;
@Autowired
PersistentTokenRepository tokenRepository;
http.rememberMe()
.tokenRepository(tokenRepository)
.userDetailsService(userDetailsService)
.tokenValiditySeconds(60) // 设置有限时长
步骤5:修改前端页面
记住我:<input type="checkbox" name="remember-me" title="记住密码"/>
注意:这里name
属性的值必须为remember-me
,当我们访问成功后,会在浏览器的 Cookie 信息中看到名为remember-me
的 Cookie。
2.7 CSRF 功能
**跨站请求伪造**
:英文名Cross-site request forgery
,也被称为one-click attack
或者session riding
,通常缩写为CSRF
或者XSRF
,是一种挟制用户在当前已登录的 Web 应用程序上执行非本意的操作的攻击方法。**跨站请求伪造**
和跨网站脚本(XSS)
相比,XSS 利用的是用户对执行网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。
跨站请求攻击,简单来说,是攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站并运行一些操作(如发邮件、发消息、转账、购买商品等)。由于浏览器曾经认证过,所以被访问的网站会认为是真正的用户操作而去运行。这利用了 Web 中用户身份验证的一个漏洞:简单的身份验证只能保证请求发自某个用户的浏览器,却不能保证请求本身是用户自愿发出的。
从 SpringSecurity 4.0 开始,默认会开启 CSRF 保护,以方式 CSRF 攻击应用程序,SpringSecurity CSRF 会针对 PATCH、POST、PUT 和 DELETE 方法进行防护。也就是说这些方法只能在本站访问,而不能在其他网站访问到。
关闭 csrf 功能
http.csrf().disable();
如何使用 csrf ?
只需要在表单中添加一个隐藏域。
<input type="hidden" th:if="${_csrf}!=null" th:value="${_csrf.token}" name="_csrf"/>
CSRF 功能来源于CsrfFilter
过滤器:
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(HttpServletResponse.class.getName(), response);
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
boolean missingToken = (csrfToken == null);
if (missingToken) {
csrfToken = this.tokenRepository.generateToken(request);
this.tokenRepository.saveToken(csrfToken, request, response);
}
// 获取从前端表单传入的 csrf 参数
request.setAttribute(CsrfToken.class.getName(), csrfToken);
request.setAttribute(csrfToken.getParameterName(), csrfToken);
// requireCsrfProtectionMatcher 这里会检测方法,GET等方法不需要csrf认证
if (!this.requireCsrfProtectionMatcher.matches(request)) {
filterChain.doFilter(request, response);
return;
}
filterChain.doFilter(request, response);
}