Appearance
Spring Security
Spring Security 是一个功能强大且高度可定制的身份验证和访问控制框架。它是用于保护基于 Spring 的应用程序的事实上的标准。
主要特点有:
身份验证 —— Spring Security 为身份验证提供了全面的支持。身份验证是我们验证谁试图访问特定资源的身份的方法。验证用户身份的常用方法是要求用户输入用户名和密码。一旦执行了身份验证,我们就会知道身份并可以执行授权。
漏洞防护 —— Spring Security 提供了针对常见漏洞(例如:CSRF)的保护。只要有可能,默认情况下就启用保护。
启用 Spring Security
保护 Spring 应用程序的第一步是将 spring-boot-starter-security
依赖项添加到构建中。在项目的 pom.xml 文件中,添加以下依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
现在,启动应用程序并访问主页(或任何页面): (数据来源:自己截得)
将提示使用 HTTP 基本身份验证对话框进行身份验证。要想通过认证,需要提供用户名和密码。用户名是 user。至于密码,它是随机生成并写入了应用程序日志文件。日志条目应该是这样的:
sh
...
2021-07-22 17:20:58.787 INFO 8240 --- [ main] .s.s.UserDetailsServiceAutoConfiguration :
Using generated security password: a220c23b-d48b-4305-9c34-d465e83f9a3f
...
假设正确地输入了用户名和密码,将被授予对应用程序的访问权。
那么,Spring Boot 为我们做了哪些自动配置呢?主要有三点:
启用 Spring Security 的默认配置,该配置将创建一个名为
springSecurityFilterChain
的bean
,它本质上是一个 Servlet 过滤器。 此bean
负责应用程序内的所有安全性(保护应用程序 URL,验证提交的用户名和密码,重定向到登录表单等)。创建一个
UserDetailsService
的bean
,其中包含用户名 user 和随机生成的密码,该密码将记录到控制台。针对每个请求,使用 Servlet 容器向名为
springSecurityFilterChain
的bean
注册过滤器。
可以看到,只需要在项目构建中添加 spring-boot-starter-security
,就可以获得以下安全特性:
所有的 HTTP 请求路径都需要认证
不需要特定的角色或权限
一个默认的登录页面
身份验证由 HTTP 基本身份验证提供
只有一个用户,用户名是 user
但是,大多数应用程序的安全需求不止于此。它们可能想:
自定义登录页面进行身份验证
提供注册页面供新用户注册
为不同的请求路径应用不同的安全规则。例如,注册、登录和主页页面根本不需要身份验证。
默认的 SecurityFilterChain 配置
java
package org.springframework.boot.autoconfigure.security.servlet;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.SecurityFilterChain;
/**
* The default configuration for web security. It relies on Spring Security's
* content-negotiation strategy to determine what sort of authentication to use. If the
* user specifies their own {@link WebSecurityConfigurerAdapter} or
* {@link SecurityFilterChain} bean, this will back-off completely and the users should
* specify all the bits that they want to configure as part of the custom security
* configuration.
*
* @author Madhura Bhave
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
@ConditionalOnWebApplication(type = Type.SERVLET)
class SpringBootWebSecurityConfiguration {
@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated().and().formLogin().and().httpBasic();
return http.build();
}
}
前后端分离
配置默认用户和密码方便调试
方式一
properties
spring.security.user.name=user
spring.security.user.password=pass
方式二
java
@Bean
public InMemoryUserDetailsManager inMemoryUserDetailsManager() {
return new InMemoryUserDetailsManager(User.withUsername("user")
.password("{noop}pass").roles().build());
}
警告
选择在密码前加上 {noop}
来表示不应该使用编码,这应该只用于测试目的。
禁用 CSRF
java
http.csrf(csrf -> csrf.disable());
未登录返回自定义内容而不是登录页面
java
http.formLogin(formLogin -> formLogin.loginPage("/login").permitAll());
java
@RestController
@RequestMapping("/login")
public class LoginController {
@GetMapping
public String login() {
return "You need to login.";
}
}
这种做法虽然返回了自定义的内容,但用浏览器访问就会发现依然会触发 302
重定向。
最佳实践
翻看源码可以发现,在构建 SecurityFilterChain
时,会初始化一个 LoginUrlAuthenticationEntryPoint
实例作为 ExceptionTranslationFilter
过滤 AuthenticationException
的认证方案。
LoginUrlAuthenticationEntryPoint
中的 commence(HttpServletRequest, HttpServletResponse, AuthenticationException)
方法如下:
java
/**
* Performs the redirect (or forward) to the login form URL.
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
if (!this.useForward) {
// redirect to login page. Use https if forceHttps true
String redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
this.redirectStrategy.sendRedirect(request, response, redirectUrl);
return;
}
String redirectUrl = null;
if (this.forceHttps && "http".equals(request.getScheme())) {
// First redirect the current request to HTTPS. When that request is received,
// the forward to the login page will be used.
redirectUrl = buildHttpsRedirectUrlForRequest(request);
}
if (redirectUrl != null) {
this.redirectStrategy.sendRedirect(request, response, redirectUrl);
return;
}
String loginForm = determineUrlToUseForThisRequest(request, response, authException);
logger.debug(LogMessage.format("Server side forward to: %s", loginForm));
RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
dispatcher.forward(request, response);
return;
}
可以看到执行策略不是重定向就是转发到 /login
这个地址。既然这样,我们就自定义这个方法,使其直接返回自定义内容。
java
http.exceptionHandling(
exceptionHandling -> exceptionHandling.authenticationEntryPoint(
(request, response, authException) -> {
response.getWriter()
.write("Note: You need to login.");
}));
登录成功或失败后返回自定义内容
方式一
java
http.formLogin(formLogin -> formLogin.loginPage("/login").permitAll()
.successForwardUrl("/login/success")
.failureForwardUrl("/login/failure"));
java
@RestController
@RequestMapping("/login")
public class LoginController {
@PostMapping("/success")
public String success() {
return "Login success. Your username is " + SecurityContextHolder
.getContext().getAuthentication().getName();
}
@PostMapping("/failure")
public String failure() {
return "Login failure.";
}
}
方式二
java
http.formLogin(formLogin -> formLogin.loginPage("/login").permitAll()
// .successForwardUrl("/login/success")
// .failureForwardUrl("/login/failure")
.successHandler((HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) -> {
response.getWriter()
.write("Note: Login success. Your username is "
+ SecurityContextHolder.getContext()
.getAuthentication().getName());
})
.failureHandler((HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) -> {
response.getWriter().write("Note: Login failure.");
}));
CORS
java
http.cors();
java
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
跨域 Cookie
XMLHttpRequest 或 Fetch 与 CORS 的一个有趣的特性是,可以基于 HTTP cookies 和 HTTP 认证信息发送身份凭证。一般而言,对于跨源 XMLHttpRequest 或 Fetch 请求,浏览器 不会 发送身份凭证信息。如果要发送凭证信息,需要设置 XMLHttpRequest 的某个特殊标志位。
……
将 XMLHttpRequest 的
withCredentials
标志设置为true
,从而向服务器发送 Cookies。如果服务器端的响应中未携带Access-Control-Allow-Credentials: true
,浏览器将不会把响应内容返回给请求的发送者。
上面的内容摘自 MDN。
简而言之:
- 想要跨域携带 Cookie,客户端需要设置
withCredentials = true
- 想要跨域返回 Cookie,服务端需要设置
Access-Control-Allow-Credentials = true
在 Spring Security 中调整 CorsConfigurationSource
的配置:
java
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowCredentials(true);
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
发现再次访问会报下面的错误:
log
java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.
再次调整 CorsConfigurationSource
配置后如下:
java
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowCredentials(true);
// configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedOriginPatterns(
Arrays.asList("http://localhost:[*]"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}