SpringSecurity
Nuyoah 第一章:SpringSecurity入门
功能
- 身份认证(authentication)
- 身份认证是验证
谁正在访问系统资源
,判断用户是否为合法用户。认证用户的常见方式是要求用户输入用户名和密码。
- 授权(authorization)
- 用户进行身份认证后,系统会控制
谁能访问哪些资源
,这个过程叫做授权。用户无法访问没有权限的资源。
- 防御常见攻击(protection against common attacks)
- CSRF
- HTTP Headers
- HTTP Requests
案例-身份认证
创建项目引入依赖
pom文件依赖
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
| <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.3.4</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.thymeleaf.extras</groupId> <artifactId>thymeleaf-extras-springsecurity6</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> </dependencies>
|
创建基础启动类
1 2 3 4 5 6
| @SpringBootApplication public class SpringSecurityDemoApplication { public static void main(String[] args) { SpringApplication.run(SpringSecurityDemoApplication.class, args); } }
|
创建Controller对象
1 2 3 4 5 6 7
| @Controller public class IndexController { @GetMapping("/index") public String index() { return "index"; } }
|
创建前台页面
1 2 3 4 5 6 7 8 9
| <html xmlns:th="https://www.thymeleaf.org"> <head> <title>Hello Security!</title> </head> <body> <h1>Hello Security</h1> <a th:href="@{/logout}">Log Out</a> </body> </html>
|
SpringSecurity页面加载慢
引入SpringSecurity之后,Security会自动判断请求是否已经登录,如果没有登录则会跳转到Security的自带登录页面,可以使用logout登出系统
SpringSecurity自带登录页样式加载不出来: SpringSecurity会引入一个Bootstrap依赖,我们需要开启梯子之后才能访问器样式
SpringSecurity做了什么
- 保护应用的所有URL,要求在访问该系统的URL的时候都需要进行身份验证
- 自动生成了一个初始用户user,密码在控制台上
- 生成登录页和登出页
- 对于未登录的web请求,重定向到登录页面
- 对应服务请求,返回401,未授权
- 处理跨站请求伪造(CSRF)攻击
- 处理会话劫持攻击
- 写入Strict-Transport-Security以确保HTTPS
- 写入X-Content-Type-Options以处理嗅探攻击
- 写入Cache-Control头来保护经过身份验证的资源
- 写入X-Frame-Options以处理点击劫持攻击
SpringSecurity底层原理
SpringSecurity是通过层层过滤器来进行操作的
客户端进行访问请求的时候需要通过层层过滤器,然后到达Servlet端
传统的应用启动时,需要将过滤器全部注册进Servlet中,但是Spring希望可以将过滤器设置成Bean对象,放到Spring容器中进行管理
通过DelegatingFilterProxy来进行Bean容器的代理,DelegatingFilterProxy可以将我们注册Spring容器的中的bean对象中的过滤器加载到Servlet生命周期中
方式:先将自己的过滤器注册进Spring对象中,通过DelegatingFilterProxy将自己注册的filter对象加载到Servlet生命周期中
DelegatingFilterProxy也是一个FIlter,会被注册进行Servlet容器链中,这样我们注册在SpringBean容器中的过滤器就可以通过DelegatingFilterProxy代理进Servlet容器中
通常我们注册的Filter都不只是一个,所以DelegatingFilterProxy管理的是一个FIlter链
Spring容器中可以有很多个Filter链,所以DelegatingFilterProxy需要通过一个FilterChainProxy来进行过滤器链的管理
SpringFilter链中可以有多个Filter
通过多个FilterChain让我们程序更加灵活
客户端通过向服务端发送请求的时候,FilterChainProxy通过判断请求地址,将请求送到指定的SecurityFilterChain中进行处理,如果SecurityFilterChain中没有指定路径的请求,则会送到最后一个SecurityFilterChain中进行处理
DelegatingFilterProxy帮助调用Spring中注册的Filter,FilterChainProxy帮助我们管理多个SecurityFilterChain,SecurityFilterChain0 - n可以处理各种业务逻辑
程序启动和运行
DefaultSecurityFilterChain
DefaultSecurityFilterChain实现了SecurityFilterChain接口,是SecurityFilterChain的实现体
1
| public final class DefaultSecurityFilterChain implements SecurityFilterChain {}
|
DefaultSecurityFilterChain默认实现15个过滤器
SecurityProperties
SecurityProperties配置Security初始化用户名和密码
用户名为user,密码为UUID
也可以通过配置文件来配置初始化用户名和密码
在spring配置文件中修改默认用户
1 2 3 4 5 6 7
| spring: application: name: SpringSecurityDemo security: user: name: default password: default
|
第二章 SpringSecurity自定义配置
基于内存的用户认证
创建自定义配置
UserDetailsService用来管理用户信息, InMemoryUserDetailsServiceManager是UserDetailsService的一个实现,用来管理内存用户信息。
创建webSecurityConfig对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @Configuration
@EnableWebSecurity public class webSecurityConfig {
@Bean public UserDetailsService userDetailsService() { InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(); manager.createUser( User.withDefaultPasswordEncoder() .username("user") .password("password") .roles("USER") .build() ); return manager; } }
|
基于内存的用户认证流程
- 程序启动时:
- 创建InMemoryUserDetailsManager对象
- 创建UserDetails对象,封装用户名密码
- 通过我们创建的userDetailsService的Bean对象中实现将初始化对象设置到InMemoryUserDetailsManager内存管理对象中
- 校验用户时:
- 在AbstractAuthenticationProcessingFilter中拦截到发送的请求
- 通过UsernamePasswordAuthenticationFilter去校验用户名密码
- AbstractUserDetailsAuthenticationProvider来获取用户对象,并校验
- DaoAuthenticationProvider获取用户对象
- InMemoryUserDetailsServiceManager使用loadUserByUsername通过我们输入的用户名去内存中寻找该用户名的用户
- AbstractUserDetailsAuthenticationProvider调用DaoAuthenticationProvider的additionalAuthenticationChecks方法去校验密码,因为用户名已经在InMemoryUserDetailsServiceManager的loadUserByUsername方法中校验成功了,如果loadUserByUsername无法根据我们输入的用户名来获取到指定用户,则表明用户校验失败
基于数据库的用户认证
引入依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.32</version> </dependency>
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.8</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency>
|
配置数据源
在application.yml文件中配置数据源
1 2 3 4 5 6 7 8 9 10 11
| spring: application: name: SpringSecurityDemo security: user: name: default password: default datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/db2024 username: root
|
基于数据库的用户认证流程
类似基于内存的用户认证流程中
-
首先创建了InMemoryUserDetailsManager管理用户对象
-
然后再通过创建UserDetails对象来设置一个初始化对象放到内存中
-
等后续来了用户之后,使用InMemoryUserDetailsServiceManager中的loadUserByUserName方法从内存中获取我们初始化的用户对象
-
在UsernamePasswordAuthenticationFilter中的attemptAuthentication方法将用户输入的用户名和密码进行对比校验
基于数据库的用户认证流程和基于内存的用户认证流程的区别是
- 无需通过初始化用户到内存中
- 重构loadUserByUserName方法,通过用户名去数据库中获取相应的用户信息
基于数据库的用户认证流程
程序启动时
- 创建DBUserDetailsManager对象,实现 UserDetailsManager, UserDetailsPasswordService这两个接口
用户校验时
- SpringSecurity通过调用DBUserDetailsManager的loadUserByUserName方法从数据库中获取对应的用户对象
- 在UsernamePasswordAuthenticationFilter中的attemptAuthentication方法对用户输入的账号密码和从数据库中查到的账号密码进行比对
创建DBUserDetailsManager对象
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
| public class DBUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService { @Resource private IUserService userService; @Override public UserDetails updatePassword(UserDetails user, String newPassword) { return null; }
@Override public void createUser(UserDetails user) {
}
@Override public void updateUser(UserDetails user) {
}
@Override public void deleteUser(String username) {
}
@Override public void changePassword(String oldPassword, String newPassword) {
}
@Override public boolean userExists(String username) { return false; }
@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userService.getOne(new LambdaQueryWrapper<User>().eq(User::getUsername, username)); if (user == null) { throw new UsernameNotFoundException(username); } else { Collection<GrantedAuthority> authorities = new ArrayList<>(); new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), user.getEnabled(), true, true, true, authorities); } return null; }
|
配置SecurityConfig对象来进行用户获取
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Configuration
@EnableWebSecurity public class webSecurityConfig {
@Bean public UserDetailsService userDetailsService() { DBUserDetailsManager manager = new DBUserDetailsManager(); return manager; } }
|
上述在配置文件中注册我们自己创建的DBUserDetailsManager对象,是根据基于内存的用户创建方式类比过来的。
其实我们通过创建DBUserDetailsManager类实现了UserDetailsManager, UserDetailsPasswordService两个类,我们创建的DBUserDetailsManager,通过@Configuration注解可以将DBUserDetailsManager放到SpringBoot的上下文中,无需通过配置文件再次引入
SpringSecurity的默认配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| @Configuration
@EnableWebSecurity public class webSecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests( authorize -> authorize .anyRequest() .authenticated() ) .formLogin(withDefaults()) .httpBasic(withDefaults()); return http.build(); } }
|
添加用户功能
创建Controller
1 2 3 4
| @PostMapping("/save") public void saveUserDetail(@RequestBody User user) { userService.saveUserDetail(user); }
|
创建Service
1 2 3 4 5 6 7 8 9
| @Override public void saveUserDetail(User user) { UserDetails userDetails = org.springframework.security.core.userdetails.User.withDefaultPasswordEncoder() .username("user") .password("password") .roles("USER") .build(); dbUserDetailsManager.createUser(userDetails); }
|
实现DBUserDetailsManager方法
1 2 3 4 5 6 7
| @Override public void createUser(UserDetails userDetails) { User user = new User(); user.setUsername(userDetails.getUsername()); user.setPassword(userDetails.getPassword()); userService.save(user); }
|
密码加密算法
密码加密方式
明文密码:
- 密码以明文形式存储在数据库中。但是恶意用户可能会通过SQL注入等手段获取到明文密码,或者程序员将数据库数据泄露的情况也可能发生。
Hash算法:
- Spring Security的PasswordEncoder 接口用于对密码进行单向转换,从而将密码安全地存储。对密码单向转换需要用到哈希算法 ,例如MD5、SHA-256、SHA-512等,哈希算法是单向的,只能加密,不能解密
- 因此,数据库中存储的是单向转换后的密码,Spring Security在进行用户身份验证时需要将用户输入的密码进行单向转换,然后与数据库的密码进行比较
- 因此,如果发生数据泄露,只有密码的单向哈希会被暴露。由于哈希是单向的,并且在给定哈希的情况下只能通过暴力破解的方式猜测密码。
彩虹表:
- 彩虹表就是一个庞大的、针对各种可能的字母组合预先生成的哈希值集合,有了它可以快速破解各类密码。越是复杂的密码,需的彩虹表就越大,主流的彩虹表都是188G以上,目前主要的算法有LM,NTLM,MD5,SHA1,MYSQLSHA1,HALFLMCHALL,NTLMCHALL, ORACLE-SYSTEM, MD5-HALF
加盐密码:
- 为了减轻彩虹表的效果,开发人员开始使用加盐密码。不再只使用密码作为哈希函数的输入,而是为每个用户的密码生成随机字节(称为盐)。盐和用户的密码将一起经过哈希函数运算,生成一个唯一的哈希。盐将以明文形式与用户的密码一起存储。然后,当用户尝试进行身份验证时,盐和用户输入的密码一起经过哈希函数运算,再与存储的密码进行比较。唯一的盐意味着彩虹表不再有效,因为对于每个盐和密码的组合,哈希都是不同的。
自适应单向函数:
- 随着硬件的不断发展,加盐哈希也不再安全。原因是,计算机可以每秒执行数十亿次哈希计算。这意味着我们可以轻松地破解每个密码。
- 现在,开发人员开始使用自适应单向函数来存储密码。使用自适应单向函数验证密码时,故意占用资源(故意使用大量的CPU、内存或其他资源)。自适应单向函数允许配置一个“工作因子”,随着硬件的改进而增加。我们建议将“工作因子”调整到系统中验证密码需要约一秒钟的时间。这种权衡是为了让攻击者难以破解密码。
- 自适应单向函数包括bcrypt、PBKDF2、scrypt和argon2。
PasswordEncoder
BcryptPasswordEncoder
使用广泛支持的bcrypt算法来对密码进行哈希。为了增加对密码破解的抵抗力,bcrypt故意设计得较慢。和其他自适应单向函数一样,应该调整其参数,使其在您的系统上验证一个密码大约需要1秒的时间。
BCryptPasswordEncoder的默认实现使用强度10。建议您在自己的系统上调整和测试强度参数,以便验证密码时大约需要1秒的时间。
Argon2PasswordEncoder
使用Argon2算法对密码进行哈希处理。Argon2是密码哈希比赛的获胜者。为了防止在自定义硬件上进行密码破解,Argon2是一种故意缓慢的算法,需要大量内存。与其他自适应单向函数一样,它应该在您的系统上调整为大约1秒来验证一个密码。
当前的Argon2PasswordEncoder实现需要使用BouncyCastle库。
Pbkdf2PasswordEncoder
使用PBKDF2算法对密码进行哈希处理。为了防止密码破解,PBKDF2是一种故意缓慢的算法。与其他自适应单户函数一样,它应该在您的系统上调整为大约1秒来验证一个密码。当需要FIPS认证时,这种算法是一个很好的选
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @Test void testPassword() { PasswordEncoder encoder = new BCryptPasswordEncoder(4); String result = encoder.encode("password"); System.out.println(result);
Assert.isTrue(encoder.matches("password", result), "密码不一致");
}
|
DelegatingPasswordEncoder
- 表中存储的密码形式:(bcrypt}$2aS10SGRLdNijsQMUvl/au9ofL.eDwmoohzzS7.mmNSJZ.0FxO/BTk76klW
- 通过如下源码可以知道:可以通过(bcrypt}前缀动态获取和密码的形式类型一致的PasswordEncoder对象
- 目的:方便随时做密码策略的升级,兼容数据库中的老版本密码策略生成的密码
自定义登录页面
创建登录页面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <html xmlns:th="https://www.thymeleaf.org"> <head> <title>登录</title> </head> <body> <h1>登录</h1> <div th:if="${param.error}">错误的用户名和密码</div> <form th:action="@{/login}" method="post" > <div> <input type="text" name="username" placeholder="用户名" /> </div> <div> <input type="password" name="password" placeholder="密码" /> </div> <input type="submit" value="登录"/> </form> </body> </html>
|
即使配置了登录页面,也同样会跳转到默认登录页面
更改配置项
因为在SpringSecurity配置中配置的了登录验证的页面,配置的是默认的页面。
我们需要将自定义的登录页面,配置到验证中,并设置permitAll(),如果不设置该选项,则会一直重定向
因为不设置这个无需授权的选项,登录页也需要授权,但是当时我们并没有登录,所以会跳转到自定义登录页面,自定义登录页面有需要授权,由此会重定向过多导致出错
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
| @Configuration
@EnableWebSecurity public class webSecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests( authorize -> authorize .anyRequest() .authenticated() ) .formLogin(from -> { from.loginPage("/login").permitAll(); }); http.csrf(AbstractHttpConfigurer::disable); return http.build(); } }
|
注意点
登录参数必须是username和password,如果参数名不是这两个则需要进行单独配置
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
| @Configuration
@EnableWebSecurity public class webSecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests( authorize -> authorize .anyRequest() .authenticated() ) .formLogin(from -> { from.loginPage("/login") .permitAll() .usernameParameter("myUsername") .passwordParameter("myPassword"); }); http.csrf(AbstractHttpConfigurer::disable); return http.build(); } }
|
配置失败的返回页面
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
| @Configuration
@EnableWebSecurity public class webSecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests( authorize -> authorize .anyRequest() .authenticated() ) .formLogin(from -> { from.loginPage("/login") .permitAll() .usernameParameter("myUsername") .passwordParameter("myPassword") .failureUrl("/login?failure") ;
}); http.csrf(AbstractHttpConfigurer::disable); return http.build(); } }
|
第三章 前后端分离
后端需要在登录成功或失败的时候返回给前端一个JSON字符串,而不是让控制前台页面跳转到登录页面
认证流程
用户在输入账号密码的时候是需要调用UsernamePasswordAuthenticationFilter过滤器生成一个UsernamePasswordAuthenticationToken对象,通过AuthenticationManager管理器来进行登录用户的认证。
登录成功最后调用AuthenticationSuccessHandler处理器来处理成功结果
失败最后调用AuthenticationFailureHandler来处理失败请求
引入FastJson依赖
1 2 3 4 5
| <dependency> <groupId>com.alibaba.fastjson2</groupId> <artifactId>fastjson2</artifactId> <version>2.0.37</version> </dependency>
|
认证成功响应
配置认证成功处理器AuthenticationSuccessHandler
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
Object principal = authentication.getPrincipal();
SysResult<Object> sysResult = new SysResult<>(); sysResult.ok(principal); response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(JSON.toJSONString(sysResult)); } }
|
将处理器注册进SpringSecurity配置中
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
| @Configuration
@EnableWebSecurity public class webSecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests( authorize -> authorize .anyRequest() .authenticated() ) .formLogin(from -> { from.loginPage("/login") .permitAll() .usernameParameter("username") .passwordParameter("password") .failureUrl("/login?failure") .successHandler(new MyAuthenticationSuccessHandler()) ;
}); http.csrf(AbstractHttpConfigurer::disable); return http.build(); } }
|
认证失败相应
通认证成功相似
先创建认证失败处理器
MyAuthenticationFailureHandler
1 2 3 4 5 6 7 8 9 10 11
| public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { String localizedMessage = exception.getLocalizedMessage(); response.setContentType("application/json;charset=utf-8"); response.getWriter().println(JSON.toJSONString(SysResult.errorResult(-1, localizedMessage))); } }
|
将认证失败处理器注册进SpringSecurity配置中
1 2 3 4 5 6 7 8 9 10 11
| public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { String localizedMessage = exception.getLocalizedMessage(); response.setContentType("application/json;charset=utf-8"); response.getWriter().println(JSON.toJSONString(SysResult.errorResult(-1, localizedMessage))); } }
|
注销处理
类似登陆成功是登录失败处理流程
先创建注销处理器
MyLogoutSuccessHandler
1 2 3 4 5 6 7
| public class MyLogoutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(JSON.toJSONString(SysResult.okResult(200, "注销成功"))); } }
|
注册进Security配置文件中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Configuration
@EnableWebSecurity public class webSecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.logout(logout -> { logout.logoutSuccessHandler(new MyLogoutSuccessHandler()); }); http.csrf(AbstractHttpConfigurer::disable); return http.build(); } }
|
请求未认证的接口
当我们访问一个需要认证的的接口的时候,SpringSecurity会判断是否已经认证成功,如果认证成功,则可以访问,如果未成功,SpringSecurity会自动调转到登录页面,请求用户认证
期望登录不成功的时候不进行页面的跳转,而是返回JSON信息
我们需要实现AuthenticationEntryPoint接口
1 2 3 4 5 6 7 8 9 10
| public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { String localizedMessage = authException.getLocalizedMessage(); response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(JSON.toJSONString(SysResult.errorResult(-1, localizedMessage)));
} }
|
进行配置注册
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @Configuration
@EnableWebSecurity public class webSecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.exceptionHandling(exception -> { exception.authenticationEntryPoint(new MyAuthenticationEntryPoint()); }); return http.build(); } }
|
跨越问题
跨域:在网络请求中,前台请求后台,默认需要在同一台服务器上,需要相同的IP和协议,如果不同的话,则会进行跨域拦截
在前后台分离系统中可能前台系统和后台系统部署在不同的服务器中,这时候前台系统的IP地址和后台服务的IP地址就不相同,这时候我们需要进行跨域解决
跨域全称是跨域资源共享(Cross-Origin Resources Sharing,CORS),它是浏览器的保护机制,只允许网页请求统一域名下的服务,同一域名指=>协议、域名、端口号都要保持一致,如果有一项不同,那么就是跨域请求。在前后端分离的项目中,需要解决跨域的问题。
在SpringSecurity中解决跨域很简单,在配置文件中添加如下配置即可
1 2
| http.cors(withDefaults());
|
第四章 身份认证
用户认证信息
基本概念
在SpringSecurity中与身份认证有关的是Authentication===》可以直接获取到用户信息,SecurityContext和SecurityContextHolder
他们之间的关系是:
- SecurityContextHolder:是SpringSecurity存储用户详细信息的地方
- SecurityContext:是从SecurityContextHolder中获取的内容,包含当前已认证的用户的Authentication信息
- Authentication:表示用户的身份认证信息,包含了Principal,Credentials和Authorities对象
- Principal:表示用户的身份标识,通常是一个表示用户的实体对象,例如用户名,Principal可以通过Authentication的getPrincipal方法获取
- Credentials:表示用户的凭证信息,例如密码、证书或其他认证凭据。Credential可以通过Authentication对象的getCredentials()方法获取。
- Authorities:表示用户被授予的权限
总结起来,SecurityContextHolder用于管理当前线程的安全上下文,存储已认证用户的详细信息,其中包含了SecurityContext对象,该对象包含了Authentication对象,后者表示用户的身份验证信息,包括Principal(用户的身份标识)和Credential(用户的凭证信息)。
获取用户详细信息
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
| @RestController public class IndexController { @GetMapping("/index") public SysResult<HashMap<String, Object>> index() { SecurityContext context = SecurityContextHolder.getContext(); Authentication authentication = context.getAuthentication(); Object principal = authentication.getPrincipal(); Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities(); Object credentials = authentication.getCredentials(); String name = authentication.getName(); HashMap<String, Object> result = new HashMap<>(); result.put("username", name); result.put("authorities", authorities); result.put("principal", principal); return SysResult.okResult(result);
} }
|
会话并发处理
选择同一个账号可以在几个设备进行登录,如果超出规定设备数量的时候,需要将前面的设备的Session会话注销
Security通过SessionInformationExpiredStrategy接口来实现该功能
实现处理器接口
1 2 3 4 5 6 7 8 9 10 11 12 13
| public class MySessionInformationExpireStrategy implements SessionInformationExpiredStrategy {
@Override public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException { HttpServletResponse response = event.getResponse(); response.setContentType("application/json;charset=utf-8"); response.getWriter().println(JSON.toJSONString(SysResult.errorResult(-1, "该账号已从其他设备登录"))); } }
|
配置接口
在WebSecurityConfig中配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Configuration
@EnableWebSecurity public class WebSecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.sessionManagement(session -> { session.maximumSessions(1) .expiredSessionStrategy(new MySessionInformationExpireStrategy()); }); return http.build(); } }
|
第五章 授权
SpringSecurity有两种授权形式:
- 用户-权限-资源:将权限分配至用户上,用户权限的用户,才能访问指定的接口和指定的前台菜单或按钮
- 用户-角色-权限-资源:将权限分配到角色上,拥有该角色的用户都可以使用该角色的权限,更加灵活
基于Request的授权
用户-权限-资源
需求:
- 拥有USER_LIST权限的用户可以访问/user/list的接口
- 拥有USER_ADD权限的用户可以访问/user/add的接口
配置:在配置项中配置指定资源需要的权限信息
/user/list接口信息只有拥有USER_LIST权限的用户才能访问
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @Configuration
@EnableWebSecurity public class WebSecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests( authorize -> authorize .requestMatchers("/user/list").hasAuthority("USER_LIST") .requestMatchers("/user/add").hasAuthority("USER_ADD") .anyRequest() .authenticated() ); return http.bulid(); } }
|
配置用户信息
在DBUserDetailsManager的loadUserByUsername方法中来获取用户信息,并设置用户权限
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUsername, username)); if (user == null) { throw new UsernameNotFoundException(username); } else { Collection<GrantedAuthority> authorities = new ArrayList<>(); authorities.add(() -> "USER_LIST"); authorities.add(() -> "USER_ADD"); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), user.getEnabled(), true, true, true, authorities); } }
|
在配置好用户信息之后,如果拥有指定权限访问指定接口的时候可以访问通过,如果没有指定权限访问指定接口的时候会返回403无权限
用户-角色-资源
需求:角色为ADMIN的用户才可以访问/user/**路径下的资源
和用户-角色-权限-资源设置方法类似,也是需要在过滤器中配置,只是把hasAuthority,改成hasRole即可
过滤器配置
1 2 3 4 5 6 7 8 9 10 11 12 13
| @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests( authorize -> authorize .requestMatchers("user/**").hasRole("ADMIN") .anyRequest() .authenticated()
|
在loadUserByUsername类中配置获取角色信息,带上角色权限
可以在数据库中配置角色信息,这里就使用硬编码直接给用户附上角色
之前的loadUserByUsername是通过new org.springframework.security.core.userdetails.User新建一个用户
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUsername, username)); if (user == null) { throw new UsernameNotFoundException(username); } else { Collection<GrantedAuthority> authorities = new ArrayList<>(); authorities.add(() -> "USER_LIST"); authorities.add(() -> "USER_ADD");
return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), user.getEnabled(), true, true, true, authorities); } }
|
上面该方法无法直接个用户角色赋值
使用org.springframework.security.core.userdetails.User.withUsername()方法来创建用户,可以附带角色信息
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
| @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUsername, username)); if (user == null) { throw new UsernameNotFoundException(username); } else { return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .disabled(!user.getEnabled()) .credentialsExpired(false) .accountLocked(false) .roles(List.of("ADMIN").toArray(new String[0])) .build(); } }
|
用户角色权限:本质也是将角色信息放到Authorities(权限)中,是将角色信息在前面添加一个ROLE标志,例如上面的ADMIN角色,放到Authorities中是ROLE_ADMIN
用户-角色-权限-资源
RBAC(Role_Based_Assess_Control,基于角色的访问控制)是一种常用的数据库设计方案,它将用户的权限分配和管理与角色相关联。
以下是一个基本的RBAC数据库设计方案的示例:
- 用户表(User table):包含用户的基本信息,例如用户名、密码和其他身份验证信息。
列名 |
数据类型 |
描述 |
user_id |
int |
用户ID |
username |
varchar |
用户名 |
password |
varchar |
密码 |
email |
varchar |
电子邮件地址 |
… |
… |
… |
- 角色表(Role table):存储所有可能的角色及其描述。
列名 |
数据类型 |
描述 |
role_id |
int |
角色ID |
role_name |
varchar |
角色名称 |
description |
varchar |
角色描述 |
… |
… |
… |
- 权限表(Permission table):定义系统中所有可能的权限。
列名 |
数据类型 |
描述 |
permission_id |
int |
权限ID |
permission_name |
varchar |
权限名称 |
description |
varchar |
权限描述 |
… |
… |
… |
- 用户角色关联表(User-Role table):将用户与角色关联起来。
列名 |
数据类型 |
描述 |
user_role_id |
int |
用户角色关联ID |
user_id |
int |
用户ID |
role_id |
int |
角色ID |
… |
… |
… |
- 角色权限关联表(Role-Permission table):将角色与权限关联起来。
列名 |
数据类型 |
描述 |
role_permission_id |
int |
角色权限关联ID |
role_id |
int |
角色ID |
permission_id |
int |
权限ID |
… |
… |
… |
在这个设计方案中,用户可以被分配一个或多个角色,而每个角色又可以具有一个或多个权限。通过对用户角色关联和角色权限关联表进行操作,可以实现灵活的权限管理和访问控制。
当用户尝试访问系统资源时,系统可以根据用户的角色和权限决定是否允许访问。这样的设计方案使得权限管理更加简单和可维护,因为只需调整角色和权限的分配即可,而不需要针对每个用户进行单独的设置。
基于方法的授权
上述基于Request的授权,是通过request连接器实现的。
基于方法的授权是通过在方法上设置权授权注解实现的
开启其方法授权
在SpringSecurity配置文件中添加上@EnableMethodSecurity注解
1 2 3 4 5 6 7 8 9 10 11
| @Configuration @EnableWebSecurity @EnableMethodSecurity public class WebSecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { } }
|
给用户授予角色和权限
DBUserDetailsManager中的loadUserByUsername方法:
1 2 3 4 5 6
| return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .roles("ADMIN") .authorities("USER_ADD", "USER_UPDATE") .build();
|
常用授权注解
使用@PreAuthorize注解来进行权限校验,使用hasRole 和hasAuthority来进行权限认证
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @RestController @RequestMapping("/user") public class UserController { @Resource private IUserService userService;
@GetMapping("/list") @PreAuthorize("hasRole('ADMIN')") public List<User> getUserList() { return userService.list(); }
@PostMapping("/save") @PreAuthorize("hasAuthority('USER_SAVE')") public void saveUserDetail(@RequestBody User user) { userService.saveUserDetail(user); } }
|
list方法需要拥有角色ADMIN才能访问,下面save需要拥有权限USER_SAVE才能访问
也可以写复杂的权限表达式来进行判断,下面就是同时需要拥有ADMIN角色的用户和用户名是admin的用户才能访问list接口
1 2 3 4 5 6 7 8 9 10 11 12
| @RestController @RequestMapping("/user") public class UserController { @Resource private IUserService userService;
@GetMapping("/list") @PreAuthorize("hasRole('ADMIN') and authentication.name == 'admin'") public List<User> getUserList() { return userService.list(); } }
|
第六章 OAuth2
OAuth2简介
是什么
“Auth” 表示 “授权” Authorization
“O” 是 Open 的简称,表示 “开放”
连在一起就表示 “开放授权”,OAuth2是一种开放授权协议。
首先需要有一个资源服务器来存放管理用户资源
其次需要有一个客户应用来访问资源服务器,通过资源服务器来获取指定资源信息
如果有恶意程序来访问资源服务器时候,如果资源服务器不加以检查则会导致用户数据泄露
这时候需要给客户端应用颁发一个AssetsToken来进行用户认证,拥有AssetsToken的应用才能访问用户数据
谁给客户端颁发AssetsToken呢? === 授权服务器
授权服务器何时给用户颁发AssetsToken呢?
在客户端想要访问资源服务器的时候,发现需要AssetsToken,进而去授权服务器上要求授权服务器给自己颁发AssetsToken,授权服务器去征求该用户的同意,如果用户同意,则进行颁发AssetsToken,客户端就可以通过AssetsToken去资源服务器上访问该用户的资源了
角色
OAuth 2协议包含以下角色:
- 资源所有者(Resource Owner):即用户,资源的拥有人,想要通过客户应用访问资源服务器上的资源。
- 客户应用(Client):通常是一个Web或者无线应用,它需要访问用户的受保护资源。
- 资源服务器(Resource Server):存储受保护资源的服务器或定义了可以访问到资源的API,接收并验证客户端的访问令牌,以决定是否授权访问资源。
- 授权服务器(Authorization Server):负责验证资源所有者的身份并向客户端颁发访问令牌。
代理授权:资源所有者本身就拥有访问资源服务器上自己的资源信息,但是现在想要通过客户应用来代理用户去访问资源服务器上对应用户的信息
使用场景
开放系统间授权
社交登录
在传统的身份验证中,用户需要提供用户名和密码,还有很多网站登录时,允许使用第三方网站的身份,这称为"第三方登录"。所谓第三方登录,实质就是 OAuth 授权。用户想要登录 A 网站,A 网站让用户提供第三方网站的数据,证明自己的身份。获取第三方网站的身份数据,就需要 OAuth 授权。
开放API
例如云冲印服务的实现
现代微服务安全
单块应用安全
微服务安全
企业内部应用认证授权
OAuth2四种授权方式
RFC6749:
RFC 6749 - The OAuth 2.0 Authorization Framework (ietf.org)
阮一峰:
OAuth 2.0 的四种方式 - 阮一峰的网络日志 (ruanyifeng.com)
四种模式:
- 授权码(authorization-code)
- 隐藏式(implicit)
- 密码式(password)
- 客户端凭证(client credentials)
第一种方式:授权码
授权码(authorization code),指的是第三方应用先申请一个授权码,然后再用该码获取令牌。
这种方式是最常用,最复杂,也是最安全的,它适用于那些有后端的 Web 应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。这样的前后端分离,可以避免令牌泄漏。
- 注册客户应用:客户应用如果想要访问资源服务器需要有凭证,需要在授权服务器上注册客户应用。注册后会获取到一个ClientID和ClientSecrets
例如下图,ResourceOwner资源拥有者,想通过前台页面UserAgent去访问Client服务端内容,类似我们在登陆Gitee的时候想要访问我们自己的仓库需要先登录,我们通过Github身份认证来登录Gitee,
首先需要Gitee携带ClientIdentifier和Redirection跳转到Github的授权页面,如果没有登录则需要先登录。
UserAuthenticates是GIthub用户认证的操作例如输入密码之类的操作
Github进行用户认证之后返回Authorization(授权码)给Gitee,Gitee后台获取到Authorization之后,通过携带Authorization和RedirectUrl去访问Github服务端去请求AssetsToken,Github进行Authorization校验之后返回AssetsToken给Gitee,Gitee认证登陆成功,这时候Gitee就可以访问Github的一些受保护资源,例如用户的昵称基本信息之类的东西
第二种方式:隐藏式
隐藏式(implicit),也叫简化模式,有些 Web 应用是纯前端应用,没有后端。这时就不能用上面的方式了,必须将令牌储存在前端。
RFC 6749 规定了这种方式,允许直接向前端颁发令牌。这种方式没有授权码这个中间步骤,所以称为隐藏式。这种方式把令牌直接传给前端,是很不安全的。因此,只能用于一些安全要求不高的场景,并且令牌的有效期必须非常短,通常就是会话期间(session)有效,浏览器关掉,令牌就失效了。
资源拥有者通过前台页面方位授权服务器,授权服务器直接返回携带AssetsToken的URL网址,但是返回的URL地址Client无法直接解析,前台页面通过访问一个专门的解析网址发服务资源服务端,解析出AssetsToken之后,前台页面通过AssetsToken访问Client端
1 2
| https://a.com/callback#token=ACCESS_TOKEN 将访问令牌包含在URL锚点中的好处:锚点在HTTP请求中不会发送到服务器,减少了泄漏令牌的风险。
|
第三种方式:密码式
密码式(Resource Owner Password Credentials):如果你高度信任某个应用,RFC 6749 也允许用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌。
就是像,如果我想通过GITHUB登录GITEE,我不能直接在GITEE这里输入我GITHUB的账号密码,这样就会将资源服务器的账号密码暴漏给客户应用非常不安全。客户端应用可能会通过此账号密码来对资源进行删除操作,风险极大
这种方式需要用户给出自己的用户名/密码,显然风险很大,因此只适用于其他授权方式都无法采用的情况,而且必须是用户高度信任的应用。
第四种方式:凭证式
凭证式(client credentials):也叫客户端模式,适用于没有前端的命令行应用,即在命令行下请求令牌。
这种方式给出的令牌,是针对第三方应用的,而不是针对用户的,即有可能多个用户共享同一个令牌。
授权类型的选择
Spring实现OAuth2
Spring实现
OAuth2 :: Spring Security
Spring Security可以实现的
- 客户应用(OAuth2 Client):OAuth2客户端功能中包含OAuth2 Login
- 资源服务器(OAuth2 Resource Server)
需要独立实现的
- 授权服务器(Spring Authorization Server):它是在Spring Security之上的一个单独的项目。
依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId> </dependency>
|
授权登录的实现思路
使用OAuth2 Login,只需要实现客户应用,对应的资源服务器和授权服务器都由别人设计好
Github登录案例
创建应用
注册客户应用:
登录GitHub,在开发者设置中找到OAuth Apps,创建一个application,为客户应用创建访问GitHub的凭据:
填写应用信息:默认的重定向URI模板为{baseUrl}/login/oauth2/code/{registrationId}
。registrationId是ClientRegistration的唯一标识符。
获取应用程序id,生成应用程序密钥:
创建测试项目
创建一个springboot项目oauth2-login-demo,创建时引入如下依赖
示例代码参考:spring-security-samples/servlet/spring-boot/java/oauth2/login at 6.2.x · spring-projects/spring-security-samples (github.com)
配置OAuth客户端属性
application.yml:
1 2 3 4 5 6 7 8 9
| spring: security: oauth2: client: registration: github: client-id: 7807cc3bb1534abce9f2 client-secret: 008dc141879134433f4db7f62b693c4a5361771b
|
创建Controller
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| package com.atguigu.oauthdemo.controller;
@Controller public class IndexController {
@GetMapping("/") public String index( Model model, @RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient, @AuthenticationPrincipal OAuth2User oauth2User) { model.addAttribute("userName", oauth2User.getName()); model.addAttribute("clientName", authorizedClient.getClientRegistration().getClientName()); model.addAttribute("userAttributes", oauth2User.getAttributes()); return "index"; } }
|
创建html页面
resources/templates/index.html
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
| <!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org" xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity5"> <head> <title>Spring Security - OAuth 2.0 Login</title> <meta charset="utf-8" /> </head> <body> <div style="float: right" th:fragment="logout" sec:authorize="isAuthenticated()"> <div style="float:left"> <span style="font-weight:bold">User: </span><span sec:authentication="name"></span> </div> <div style="float:none"> </div> <div style="float:right"> <form action="#" th:action="@{/logout}" method="post"> <input type="submit" value="Logout" /> </form> </div> </div> <h1>OAuth 2.0 Login with Spring Security</h1> <div> You are successfully logged in <span style="font-weight:bold" th:text="${userName}"></span> via the OAuth 2.0 Client <span style="font-weight:bold" th:text="${clientName}"></span> </div> <div> </div> <div> <span style="font-weight:bold">User Attributes:</span> <ul> <li th:each="userAttribute : ${userAttributes}"> <span style="font-weight:bold" th:text="${userAttribute.key}"></span>: <span th:text="${userAttribute.value}"></span> </li> </ul> </div> </body> </html>
|
启动应用程序
- 启动程序并访问localhost:8080。浏览器将被重定向到默认的自动生成的登录页面,该页面显示了一个用于GitHub登录的链接。
- 点击GitHub链接,浏览器将被重定向到GitHub进行身份验证。
- 使用GitHub账户凭据进行身份验证后,用户会看到授权页面,询问用户是否允许或拒绝客户应用访问GitHub上的用户数据。点击允许以授权OAuth客户端访问用户的基本个人资料信息。
- 此时,OAuth客户端访问GitHub的获取用户信息的接口获取基本个人资料信息,并建立一个已认证的会话。
案例分析
登录流程
- A 网站让用户跳转到 GitHub,并携带参数ClientID 以及 Redirection URI。
- GitHub 要求用户登录,然后询问用户"A 网站要求获取用户信息的权限,你是否同意?"
- 用户同意,GitHub 就会重定向回 A 网站,同时发回一个授权码。
- A 网站使用授权码,向 GitHub 请求令牌。
- GitHub 返回令牌.
- A 网站使用令牌,向 GitHub 请求用户数据。
- GitHub返回用户数据
- A 网站使用 GitHub用户数据登录
CommonOAuth2Provider
CommonOAuth2Provider是一个预定义的通用OAuth2Provider,为一些知名资源服务API提供商(如Google、GitHub、Facebook)预定义了一组默认的属性。
例如,授权URI、令牌URI和用户信息URI通常不经常变化。因此,提供默认值以减少所需的配置。
因此,当我们配置GitHub客户端时,只需要提供client-id和client-secret属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| GITHUB { public ClientRegistration.Builder getBuilder(String registrationId) { ClientRegistration.Builder builder = this.getBuilder( registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}"); builder.scope(new String[]{"read:user"}); builder.authorizationUri("https://github.com/login/oauth/authorize"); builder.tokenUri("https://github.com/login/oauth/access_token"); builder.userInfoUri("https://api.github.com/user"); builder.userNameAttributeName("id"); builder.clientName("GitHub"); return builder; } },
|