虽然 Spring Security 不是我发明的,相关的配置方法也是整理自网络,但至少整理的工作是我做的,文章是我自己写的,所以也就算是原创吧
本人菜鸟一枚,这篇文章算是我学习 Spring Security 的记录吧,文中的代码都是自己运行过的,所以放心食用
@EnableWebSecurity
public class SecurityConfig {
}
因为 WebSecurityConfigurerAdapter
被标记了 @Deprecated
,所以,没有通过继承它来配置 Spring Security。
启动项目,访问接口时,发现需要认证。Spring Security 提供了一个登录页面,用户名是 user,密码在IDEA的控制台里。
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@EnableWebSecurity
public class SecurityConfig {
/**
* HttpSecurity 相关的设置
*/
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 如果没有下面的语句, 那么任何请求都可以免认证
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
// 使用默认的表单登录
.formLogin(withDefaults())
// 使用默认的 http basic 登录
.httpBasic(withDefaults());
return http.build();
}
/**
* 配置要忽略的路径
*/
@Bean
WebSecurityCustomizer webSecurityCustomizer() {
// 忽略 /error 页面
return web -> web.ignoring().antMatchers("/error")
// 忽略常见的静态资源路径
.requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
}
2.2 和 2.1 的运行效果相同。究其原因是 Spring Security 默认就是 2.2 这样的配置,如下代码所示:
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.SERVLET)
class SpringBootWebSecurityConfiguration {
@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
static class SecurityFilterChainConfiguration {
@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated();
http.formLogin();
http.httpBasic();
return http.build();
}
}
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(withDefaults())
.httpBasic(withDefaults());
return http.build();
}
此时有2种登录方式——表单登录和httpBasic登录,如下图:
### hello 接口测试
GET http://localhost:8080/hello/say
Authorization: Basic user 079c38e0-86b3-4e2f-ac65-8429f09a1bff
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(form -> form.disable())
.httpBasic(withDefaults());
return http.build();
}
此时,浏览器地址栏访问 http://localhost:8080/hello/say
不会重定向到 /login
表单登录页面,而是会弹出一个框,让输入用户名和密码。如下图:
点击登录按钮,由F12可知,实际走的是 httpBasic 认证方式,如下图:
上图中, 请求头 Authorization: Basic dXNlcjo5MzRmYjY5ZS01YmViLTQ3NTgtOTczMC00MTRmYWZlZGZjOTQ=
使用 Base64 解码后, 可知 dXNlcjo5MzRmYjY5ZS01YmViLTQ3NTgtOTczMC00MTRmYWZlZGZjOTQ=
这一串, 解码后是 user:934fb69e-5beb-4758-9730-414fafedfc94
. 也就是说, 所谓的 Base64 编码, 就是 用户名 冒号 密码
Spring Security 的配置:
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 如果这里不设置 authorizeHttpRequests, 那么任何请求都不需要登录
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
// 使用自定义的登录页面, 注意这里需要 permitAll() 否则访问 login.html 页面的时候也会需要认证
.formLogin(form -> form.loginPage("/login").permitAll());
return http.build();
}
}
配置一个视图控制器: (点击查看 index.html)
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/login").setViewName("login");
// 还有一个首页的视图, 相关的 html 详见文章末尾
registry.addViewController("/").setViewName("index");
}
}
引入 thymeleaf 依赖:
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-thymeleafartifactId>
dependency>
把 Spring Security 官网示例1 的 src/main/resources/templates/login.html
复制粘贴到自己的项目中,如下:
DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<title>Please Log Intitle>
head>
<body>
<h1>Please Log Inh1>
<div th:if="${param.error}">
Invalid username and password.div>
<div th:if="${param.logout}">
You have been logged out.div>
<form th:action="@{/login}" method="post">
<div>
<input type="text" name="username" placeholder="Username" />
div>
<div>
<input type="password" name="password" placeholder="Password" />
div>
<div>
<input type="checkbox" name="remember-me" />Remember Me
div>
<input type="submit" value="Log in" />
form>
body>
html>
然后运行效果如下图(图示的 login.html 没有 remember me):
由 F12 可知,/login
登录请求,除了传用户名和密码,还传了 csrf token ,如下图:
Spring Security 的配置:
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 如果这里不设置 authorizeHttpRequests, 那么任何请求都不需要登录
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
// 使用自定义的登录页面, 注意这里需要 permitAll() 否则访问 login.html 页面的时候也会需要认证
.formLogin(form -> form.loginPage("/login").permitAll())
// 退出登录的配置
.logout(logout -> logout.logoutUrl("/my-logout"))
// 记住我的设置, 注意前端 login.html 把 remember me 的标签加上
.rememberMe(rememberMe -> rememberMe.key("myKey").tokenValiditySeconds(7*24*3600))
;
return http.build();
}
请求的时候,参数里面会有 remember-me
响应头里面会让浏览器设置 remember me 相关的 Cookie
Spring Security 的配置:
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.val;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 如果这里不设置 authorizeHttpRequests, 那么任何请求都不需要登录
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
// 使用自定义的登录页面, 注意这里需要 permitAll() 否则访问 login.html 页面的时候也会需要认证
.formLogin(form -> form.loginPage("/login").permitAll()
.successHandler(jsonAuthenticationSuccessHandler())
.failureHandler(jsonAuthenticationFailureHandler()))
// 退出登录的配置
.logout(logout -> logout.logoutUrl("/my-logout"))
// 记住我的设置
.rememberMe(rememberMe -> rememberMe.key("myKey").tokenValiditySeconds(7 * 24 * 3600))
;
return http.build();
}
/**
* 认证失败的处理器
*
* @return 函数
*/
private static AuthenticationFailureHandler jsonAuthenticationFailureHandler() {
return (request, response, exception) -> {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
val objectMapper = new ObjectMapper();
val data = Map.of("title", "登录失败", "status", "error");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().println(objectMapper.writeValueAsString(data));
};
}
/**
* 认证成功的处理器
*
* @return 函数
*/
private static AuthenticationSuccessHandler jsonAuthenticationSuccessHandler() {
return (request, response, authentication) -> {
response.setStatus(HttpStatus.OK.value());
val objectMapper = new ObjectMapper();
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().println(objectMapper.writeValueAsString(authentication));
};
}
}
登录成功如下图:
登录失败如下图:
Spring Security 的配置:
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 如果这里不设置 authorizeHttpRequests, 那么任何请求都不需要登录
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
// 使用自定义的登录页面, 注意这里需要 permitAll() 否则访问 login.html 页面的时候也会需要认证
.formLogin(form -> form.loginPage("/login").permitAll()
.successHandler(jsonAuthenticationSuccessHandler())
.failureHandler(jsonAuthenticationFailureHandler()))
// 退出登录的配置
.logout(logout -> logout.logoutUrl("/my-logout")
.logoutSuccessHandler(jsonLogoutSuccessHandler()))
// 记住我的设置
.rememberMe(rememberMe -> rememberMe.key("myKey").tokenValiditySeconds(7 * 24 * 3600))
;
return http.build();
}
/**
* 退出登录成功时的处理器
*
* @return 函数
*/
private static LogoutSuccessHandler jsonLogoutSuccessHandler() {
return (request, response, authentication) -> {
response.setStatus(HttpStatus.OK.value());
val objectMapper = new ObjectMapper();
val data = Map.of("title", "退出登录成功", "status", "success");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().println(objectMapper.writeValueAsString(data));
};
}
Spring Security 的配置:
// debug = true 可以看到更多的 spring security 的日志
@EnableWebSecurity(debug = true)
@RequiredArgsConstructor
@Slf4j
public class SecurityConfig {
private final ObjectMapper objectMapper;
private final AuthenticationHandler handler;
private final ObjectPostProcessor<Object> objectPostProcessor;
/**
* HttpSecurity 相关的设置
*/
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
// 用自定义的 RestAuthenticationFilter 替换 UsernamePasswordAuthenticationFilter
.addFilterAt(restAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
// 用户是通过 json 请求登录的, 是无状态的, 可以把 csrf 禁用
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
/**
* 自定义的认证过滤器
*/
private RestAuthenticationFilter restAuthenticationFilter() throws Exception {
RestAuthenticationFilter filter = new RestAuthenticationFilter(objectMapper);
// 设置认证成功的处理器
filter.setAuthenticationSuccessHandler(handler.jsonAuthenticationSuccessHandler());
// 设置认证失败的处理器
filter.setAuthenticationFailureHandler(handler.jsonAuthenticationFailureHandler());
// 设置认证管理器, 如果不设置, 会报错, 说缺少 authenticationManager
filter.setAuthenticationManager(authenticationManager());
// 设置自定义过滤器要针对的 URL 路径
filter.setFilterProcessesUrl("/rest/login");
return filter;
}
/**
* 构造一个认证管理器
*/
@Bean
AuthenticationManager authenticationManager() throws Exception {
// 这里打日志 验证 authenticationManager 是否只初始化了一次, 因为上面有调用 authenticationManager()
log.info("初始化 authenticationManager");
// objectPostProcessor 是可以直接使用 spring context 的对象, 这个是参考了已废弃的 WebSecurityConfigurerAdapter 得知的
AuthenticationManagerBuilder auth = new AuthenticationManagerBuilder(objectPostProcessor);
// 创建一些内存中的用户, 用作测试
auth.inMemoryAuthentication()
.withUser("user")
.password("{bcrypt}" + passwordEncoder().encode("password"))
.roles("USER")
.and()
.withUser("admin")
.password("{bcrypt}" + passwordEncoder().encode("password"))
.roles("ADMIN", "USER");
return auth.build();
}
/**
* 密码编码器
*/
@Bean
PasswordEncoder passwordEncoder() {
// 也是验证 passwordEncoder 是否只初始化了一次
log.info("初始化 passwordEncoder");
return new BCryptPasswordEncoder();
}
/**
* 配置要忽略的路径
*/
@Bean
WebSecurityCustomizer webSecurityCustomizer() {
// 忽略 /error 页面
return web -> web.ignoring().antMatchers("/error")
// 忽略常见的静态资源路径
.requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
}
自定义的过滤器类:
@RequiredArgsConstructor
public class RestAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final ObjectMapper objectMapper;
private static final String POST = "POST";
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// 只处理 POST 请求
if (!POST.equalsIgnoreCase(request.getMethod())) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username;
String password;
try {
// 从请求体中获取用户名和密码
ServletInputStream inputStream = request.getInputStream();
JsonNode jsonNode = objectMapper.readTree(inputStream);
username = jsonNode.get("username").textValue();
password = jsonNode.get("password").textValue();
} catch (IOException e) {
throw new BadCredentialsException("用户名或密码错误");
}
// 参照 UsernamePasswordAuthenticationFilter 的 attemptAuthentication() 方法来组装 token, setDetails(), 进行 authenticat()
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
password);
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
自定义的认证成功失败的处理器类:
@Component
@RequiredArgsConstructor
public class AuthenticationHandler {
private final ObjectMapper objectMapper;
/**
* 退出登录成功时的处理器
*
* @return 函数
*/
public LogoutSuccessHandler jsonLogoutSuccessHandler() {
return (request, response, authentication) -> {
response.setStatus(HttpStatus.OK.value());
val data = Map.of("title", "退出登录成功", "status", "success");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().println(objectMapper.writeValueAsString(data));
};
}
/**
* 认证失败的处理器
*
* @return 函数
*/
public AuthenticationFailureHandler jsonAuthenticationFailureHandler() {
return (request, response, exception) -> {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
val data = Map.of("title", "登录失败", "status", "error");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().println(objectMapper.writeValueAsString(data));
};
}
/**
* 认证成功的处理器
*
* @return 函数
*/
public AuthenticationSuccessHandler jsonAuthenticationSuccessHandler() {
return (request, response, authentication) -> {
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().println(objectMapper.writeValueAsString(authentication));
};
}
}
在 IDEA 的 http client 中验证:
### login rest 登录测试
POST http://localhost:8080/rest/login
Content-Type: application/json
{
"username": "user",
"password": "password"
}
DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>首页title>
head>
<body>
<h1>欢迎~h1>
<div>
<form th:action="@{/my-logout}" method="post">
<input type="submit" value="退出登录" />
form>
div>
body>
html>
Spring Security 官网示例连接 https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/form.html ↩︎