Spring Security的默认行为易于用于标准Web应用程序。 它使用基于cookie的身份验证和会话。 此外,它会自动为您处理CSRF令牌(以防止中间人攻击)。 在大多数情况下,您只需要为特定路由设置授权权限即可,这是一种从数据库中检索用户的方法。
另一方面,如果您仅构建将由外部服务或SPA /移动应用程序使用的REST API,则可能不需要完整的会话。 JWT(JSON Web令牌)是一个小型的数字签名令牌。 所有需要的信息都可以存储在令牌中,因此您的服务器可以没有会话。
JWT需要附加到每个HTTP请求,以便服务器可以授权您的用户。 关于如何发送令牌,有一些选项。 例如,作为URL参数或使用承载模式在HTTP授权标头中:
Authorization: Bearer <token string>
JSON Web令牌包含三个主要部分:
- 标头-通常包括令牌的类型和哈希算法。有效负载–通常包括有关用户的数据以及为其颁发令牌的数据。签名–用于验证邮件在此过程中是否未更改。
Example token
授权标头中的JWT令牌可能如下所示:
Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJzZWN1cmUtYXBpIiwiYXVkIjoic2VjdXJlLWFwcCIsInN1YiI6InVzZXIiLCJleHAiOjE1NDgyNDI1ODksInJvbCI6WyJST0xFX1VTRVIiXX0.GzUPUWStRofrWI9Ctfv2h-XofGZwcOog9swtuqg1vSkA8kDWLcY3InVgmct7rq4ZU3lxI6CGupNgSazypHoFOA
如您所见,用逗号分隔三个部分–标头,声明和签名。 标头和有效负载是Base64编码的JSON对象。
Header:
{
"typ": "JWT",
"alg": "HS512"
}
Claims/Payload:
{
"iss": "secure-api",
"aud": "secure-app",
"sub": "user",
"exp": 1548242589,
"rol": [
"ROLE_USER"
]
}
Example application
在下面的示例中,我们将创建一个简单的API,该API包含2条路由-其中1条公开可用,而1条仅用于授权用户。
We will use page start.spring.io to create our application skeleton and select Security and Web dependencies. Rest of the options are up to your preferences.
JWT support for Java is provided by the library ĴJWT so we also need to add following dependencies to the pom.xml file:
<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>
Controllers
我们的示例应用程序中的控制器将尽可能简单。 如果用户未被授权,他们只会返回一条消息或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";
}
}
Filters
首先,我们将定义一些可重用的常量和默认值,以生成和验证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;
}
}
Security configuration
我们需要配置的最后一部分是Spring Security本身。 配置很简单,我们只需要设置一些细节即可:
- Password encoder – in our case bcrypt
- CORS configuration
- Authentication manager – in our case simple in-memory authentication but in real life, you’ll need something like UserDetailsService
- Set which endpoints are secure and which are publicly available
- Add our 2 filters into the security context
- Disable session management – we don’t need sessions so this will prevent the creation of session cookies
@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;
}
}
Test
Request to public 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
Authenticate user
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
Request to private API with token
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
Request to private API without token
如果您在没有有效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
Conclusion
本文的目的不是展示一种在Spring Security中使用JWT的正确方法。 这是如何在实际应用中实现此目标的示例。 另外,我不想深入探讨该主题,因此缺少诸如令牌刷新,无效等之类的东西,但将来可能会涉及这些主题。
Ťl;dr You can find the full source code of this example API in my GiŤHub repository.