Spring Security 与 JWT
Spring Security 的默认行为对于标准 Web 应用程序来说非常易于使用。它使用基于 Cookie 的身份验证和会话。此外,它还会自动为您处理 CSRF 令牌(以防止中间人攻击)。在大多数情况下,您只需为特定路由设置授权权限,以及从数据库检索用户的方法即可。
另一方面,如果您只是构建一个 REST API,用于外部服务或 SPA/移动应用,则可能不需要完整的会话。JWT(JSON Web Token)就派上用场了——一个小型的数字签名令牌。所有需要的信息都可以存储在令牌中,这样您的服务器就可以实现无会话。
JWT 需要附加到每个 HTTP 请求中,以便服务器能够授权用户。发送令牌的方式有多种,例如,作为 URL 参数,或在 HTTP Authorization 标头中使用 Bearer 模式:
Authorization: Bearer <token string>
JSON Web Token主要包含三个部分:
- 标题——通常包括令牌的类型和散列算法。
- 有效载荷——通常包括有关用户以及向谁发放令牌的数据。
- 签名——用于验证消息在传输过程中是否未被更改。
示例令牌
来自授权标头的 JWT 令牌可能看起来像这样:
Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJzZWN1cmUtYXBpIiwiYXVkIjoic2VjdXJlLWFwcCIsInN1YiI6InVzZXIiLCJleHAiOjE1NDgyNDI1ODksInJvbCI6WyJST0xFX1VTRVIiXX0.GzUPUWStRofrWI9Ctfv2h-XofGZwcOog9swtuqg1vSkA8kDWLcY3InVgmct7rq4ZU3lxI6CGupNgSazypHoFOA
如您所见,它由三个部分组成,用逗号分隔:标头、声明和签名。标头和有效负载是 Base64 编码的 JSON 对象。
标头:
{
"typ": "JWT",
"alg": "HS512"
}
索赔/有效载荷:
{
"iss": "secure-api",
"aud": "secure-app",
"sub": "user",
"exp": 1548242589,
"rol": [
"ROLE_USER"
]
}
示例应用程序
在下面的示例中,我们将创建一个具有 2 个路由的简单 API - 一个公开可用,一个仅供授权用户使用。
我们将使用页面start.spring.io来创建应用程序框架,并选择“安全性”和“Web”依赖项。其余选项则由您自行选择。
JWT 对 Java 的支持由库JJWT提供,因此我们还需要向 pom.xml 文件添加以下依赖项:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.10.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.10.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.10.5</version>
<scope>runtime</scope>
</dependency>
控制器
我们示例应用中的控制器会尽可能简单。如果用户未获得授权,它们只会返回一条消息或 HTTP 403 错误代码。
@RestController
@RequestMapping("/api/public")
public class PublicController {
@GetMapping
public String getMessage() {
return "Hello from public API controller";
}
}
@RestController
@RequestMapping("/api/private")
public class PrivateController {
@GetMapping
public String getMessage() {
return "Hello from private API controller";
}
}
过滤器
首先,我们将定义一些可重用的常量和默认值,用于 JWT 的生成和验证。
注意:您不应将 JWT 签名密钥硬编码到应用程序代码中(本例中我们暂时忽略这一点)。您应该使用环境变量或 .properties 文件。此外,密钥需要具有适当的长度。例如,HS512 算法需要至少 512 字节大小的密钥。
public final class SecurityConstants {
public static final String AUTH_LOGIN_URL = "/api/authenticate";
// Signing key for HS512 algorithm
// You can use the page http://www.allkeysgenerator.com/ to generate all kinds of keys
public static final String JWT_SECRET = "n2r5u8x/A%D*G-KaPdSgVkYp3s6v9y$B&E(H+MbQeThWmZq4t7w!z%C*F-J@NcRf";
// JWT token defaults
public static final String TOKEN_HEADER = "Authorization";
public static final String TOKEN_PREFIX = "Bearer ";
public static final String TOKEN_TYPE = "JWT";
public static final String TOKEN_ISSUER = "secure-api";
public static final String TOKEN_AUDIENCE = "secure-app";
private SecurityConstants() {
throw new IllegalStateException("Cannot create instance of static util class");
}
}
第一个过滤器将直接用于用户身份验证。它将从 URL 中检查用户名和密码参数,并调用 Spring 的身份验证管理器进行验证。
如果用户名和密码正确,则过滤器将创建一个 JWT 令牌并将其返回到 HTTP 授权标头中。
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
setFilterProcessesUrl(SecurityConstants.AUTH_LOGIN_URL);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
var username = request.getParameter("username");
var password = request.getParameter("password");
var authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
return authenticationManager.authenticate(authenticationToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain, Authentication authentication) {
var user = ((User) authentication.getPrincipal());
var roles = user.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
var signingKey = SecurityConstants.JWT_SECRET.getBytes();
var token = Jwts.builder()
.signWith(Keys.hmacShaKeyFor(signingKey), SignatureAlgorithm.HS512)
.setHeaderParam("typ", SecurityConstants.TOKEN_TYPE)
.setIssuer(SecurityConstants.TOKEN_ISSUER)
.setAudience(SecurityConstants.TOKEN_AUDIENCE)
.setSubject(user.getUsername())
.setExpiration(new Date(System.currentTimeMillis() + 864000000))
.claim("rol", roles)
.compact();
response.addHeader(SecurityConstants.TOKEN_HEADER, SecurityConstants.TOKEN_PREFIX + token);
}
}
第二个过滤器处理所有 HTTP 请求,并检查 Authorization 标头中是否包含正确的令牌。例如,检查令牌是否未过期,或者签名密钥是否正确。
如果令牌有效,则过滤器将把身份验证数据添加到 Spring 的安全上下文中。
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private static final Logger log = LoggerFactory.getLogger(JwtAuthorizationFilter.class);
public JwtAuthorizationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws IOException, ServletException {
var authentication = getAuthentication(request);
if (authentication == null) {
filterChain.doFilter(request, response);
return;
}
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
var token = request.getHeader(SecurityConstants.TOKEN_HEADER);
if (StringUtils.isNotEmpty(token) && token.startsWith(SecurityConstants.TOKEN_PREFIX)) {
try {
var signingKey = SecurityConstants.JWT_SECRET.getBytes();
var parsedToken = Jwts.parser()
.setSigningKey(signingKey)
.parseClaimsJws(token.replace("Bearer ", ""));
var username = parsedToken
.getBody()
.getSubject();
var authorities = ((List<?>) parsedToken.getBody()
.get("rol")).stream()
.map(authority -> new SimpleGrantedAuthority((String) authority))
.collect(Collectors.toList());
if (StringUtils.isNotEmpty(username)) {
return new UsernamePasswordAuthenticationToken(username, null, authorities);
}
} catch (ExpiredJwtException exception) {
log.warn("Request to parse expired JWT : {} failed : {}", token, exception.getMessage());
} catch (UnsupportedJwtException exception) {
log.warn("Request to parse unsupported JWT : {} failed : {}", token, exception.getMessage());
} catch (MalformedJwtException exception) {
log.warn("Request to parse invalid JWT : {} failed : {}", token, exception.getMessage());
} catch (SignatureException exception) {
log.warn("Request to parse JWT with invalid signature : {} failed : {}", token, exception.getMessage());
} catch (IllegalArgumentException exception) {
log.warn("Request to parse empty or null JWT : {} failed : {}", token, exception.getMessage());
}
}
return null;
}
}
安全配置
我们需要配置的最后一部分是 Spring Security 本身。配置很简单,我们只需要设置几个细节:
- 密码编码器 – 在我们的例子中是 bcrypt
- CORS配置
- 身份验证管理器——在我们的例子中是简单的内存身份验证,但在现实生活中,你需要像UserDetailsService这样的东西
- 设置哪些端点是安全的以及哪些端点是公开可用的
- 将我们的 2 个过滤器添加到安全上下文中
- 禁用会话管理 – 我们不需要会话,因此这将阻止创建会话 cookie
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and()
.csrf().disable()
.authorizeRequests()
.antMatchers("/api/public").permitAll()
.anyRequest().authenticated()
.and()
.addFilter(new JwtAuthenticationFilter(authenticationManager()))
.addFilter(new JwtAuthorizationFilter(authenticationManager()))
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("user")
.password(passwordEncoder().encode("password"))
.authorities("ROLE_USER");
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues());
return source;
}
}
测试
请求公共 API
GET http://localhost:8080/api/public
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: text/plain;charset=UTF-8
Content-Length: 32
Date: Sun, 13 Jan 2019 12:22:14 GMT
Hello from public API controller
Response code: 200; Time: 18ms; Content length: 32 bytes
验证用户
POST http://localhost:8080/api/authenticate?username=user&password=password
HTTP/1.1 200
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJzZWN1cmUtYXBpIiwiYXVkIjoic2VjdXJlLWFwcCIsInN1YiI6InVzZXIiLCJleHAiOjE1NDgyNDYwNzUsInJvbCI6WyJST0xFX1VTRVIiXX0.yhskhWyi-PgIluYY21rL0saAG92TfTVVVgVT1afWd_NnmOMg__2kK5lcna3lXzYI4-0qi9uGpI6Ul33-b9KTnA
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Sun, 13 Jan 2019 12:21:15 GMT
<Response body is empty>
Response code: 200; Time: 167ms; Content length: 0 bytes
使用令牌请求私有 API
GET http://localhost:8080/api/private
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJzZWN1cmUtYXBpIiwiYXVkIjoic2VjdXJlLWFwcCIsInN1YiI6InVzZXIiLCJleHAiOjE1NDgyNDI1ODksInJvbCI6WyJST0xFX1VTRVIiXX0.GzUPUWStRofrWI9Ctfv2h-XofGZwcOog9swtuqg1vSkA8kDWLcY3InVgmct7rq4ZU3lxI6CGupNgSazypHoFOA
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: text/plain;charset=UTF-8
Content-Length: 33
Date: Sun, 13 Jan 2019 12:22:48 GMT
Hello from private API controller
Response code: 200; Time: 12ms; Content length: 33 bytes
不带令牌的私有 API 请求
当您在没有有效 JWT 的情况下调用安全端点时,您将收到 HTTP 403 消息。
GET http://localhost:8080/api/private
HTTP/1.1 403
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sun, 13 Jan 2019 12:27:25 GMT
{
"timestamp": "2019-01-13T12:27:25.020+0000",
"status": 403,
"error": "Forbidden",
"message": "Access Denied",
"path": "/api/private"
}
Response code: 403; Time: 28ms; Content length: 125 bytes
结论
本文的目的并非展示如何在 Spring Security 中使用 JWT 的唯一正确方法,而是一个在实际应用中使用的示例。此外,由于我并不想深入探讨这个话题,所以这里只略过了 token 刷新、失效等内容,但我以后可能会讲解这些主题。
tl;dr您可以在我的GitHub 存储库中找到此示例 API 的完整源代码。
文章来源:https://dev.to/kubadlo/spring-security-with-jwt-3j76