英文:
Microservices Spring Cloud Gateway + Spring Security LDAP as SSO + JWT - Token lost between request/response
问题
我正在使用Spring Boot开发微服务生态系统。目前已经部署的微服务如下:
- Spring Cloud Gateway - Zuul(还负责微服务下游的授权请求 - 从请求中提取令牌并验证用户是否具有执行请求的正确角色),
- 使用Spring Security LDAP的单点登录(负责验证用户并生成JWT令牌),SSO还有一个仅使用Thymeleaf的登录页面,
- 使用Thymeleaf的Web界面,无需登录页面(目前是否应该在这里使用Spring Security还不确定),
- 另一个微服务根据浏览器请求为Web界面提供数据,
- 使用Eureka的服务发现。
整体思路是在网关上对所有请求进行过滤、验证和转发。如果用户未经过身份验证或令牌过期,则将用户转发到SSO进行登录。防火墙只会公开网关端口,其他端口将使用防火墙规则阻止访问。
目前我遇到了困难,不知道该怎么办,或者是否应该将SSO与网关一起移动(在概念上是错误的,但如果找不到任何解决方案,这可能是一个变通方法)。
遇到的问题是:用户访问网关(例如http://localhost:7070/web),然后网关将用户转发到(例如http://localhost:8080/sso/login),在验证凭据后,SSO会创建JWT令牌并将其添加到响应的标头中。然后,SSO将请求重新定向回网关(例如http://localhost:7070/web)。
到目前为止,一切正常,但是当请求到达网关时,请求中没有'Authorization'标头,这意味着没有JWT令牌。
因此,网关应该提取令牌,检查凭据,并将请求转发到Web界面(例如http://localhost:9090)。
我知道在SSO上使用处理程序来重定向请求根本行不通,因为Spring的'redirect'会在重定向之前从标头中删除令牌。
但我不知道是否有其他方法可以在Spring从请求中删除令牌后再次将JWT设置到标头中。
在架构方面是否有概念性问题?我该如何将JWT转发到网关以进行验证?
SSO的代码在你的原文中,所以我将跳过对其内容的翻译。以下是网关部分的代码:
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtConfig jwtConfig;
@Value("${accessDeniedPage.url}")
private String accessDeniedUrl;
@Override
protected void configure(final HttpSecurity http) throws Exception {
http
.csrf().disable() // 禁用CSRF(跨站请求伪造)
// 使用无状态会话;不会使用会话来存储用户的状态。
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin()
.loginPage("/sso/login")
.permitAll()
.and()
// 处理未经授权的尝试
// 如果用户尝试访问资源但没有足够的权限
.exceptionHandling()
.accessDeniedPage(accessDeniedUrl)
.and()
// 添加一个过滤器来在每个请求中验证令牌
.addFilterBefore(new JwtTokenAuthenticationFilter(jwtConfig), UsernamePasswordAuthenticationFilter.class)
// 授权请求的配置
.authorizeRequests()
.antMatchers("/web/**").hasAuthority("ADMIN")
// 其他所有请求都需要经过身份验证
.anyRequest().authenticated();
}
}
@RequiredArgsConstructor
public class JwtTokenAuthenticationFilter extends OncePerRequestFilter {
private final JwtConfig jwtConfig;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 1. 获取身份验证标头。令牌应该在身份验证标头中传递
String header = request.getHeader(jwtConfig.getHeader());
// 2. 验证标头并检查前缀
if (header == null || !header.startsWith(jwtConfig.getPrefix())) {
chain.doFilter(request, response); // 如果不合法,进入下一个过滤器。
return;
}
// 如果没有提供令牌,用户将不会被认证。
// 这是可以接受的。也许用户正在访问公共路径或请求令牌。
// 所有需要令牌的受保护路径已经在配置类中定义并受保护。
// 如果用户尝试在没有访问令牌的情况下访问这些路径,那么他/她将无法通过身份验证,并且会抛出异常。
// 3. 获取令牌
String token = header.replace(jwtConfig.getPrefix(), "");
try { // 如果例如令牌已过期,可能会在创建声明时抛出异常
// 4. 验证令牌
Claims claims = Jwts.parser()
.setSigningKey(jwtConfig.getSecret().getBytes())
.parseClaimsJws(token)
.getBody();
String email = claims.get("email").toString();
if (email != null) {
String[] authorities = ((String) claims.get("authorities")).split(",");
final List<String> listAuthorities = Arrays.stream(authorities).collect(Collectors.toList());
// 5. 创建auth对象
// UsernamePasswordAuthenticationToken:Spring用于表示当前经过身份验证/正在经过身份验证的用户的内置对象。
// 它需要一个权限列表,该列表具有GrantedAuthority接口的类型,而SimpleGrantedAuthority是该接口的一个实现。
final UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
email, null, listAuthorities
.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList()));
// 6. 对用户进行身份验证
// 现在,用户已经经过身份验证
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (Exception e) {
//
<details>
<summary>英文:</summary>
I am developing a microservice ecosystem using spring-boot. The microservices which are in place at the moment :
- Spring Cloud Gateway - Zuul (responsible also for authorization requests downstream for microservices - extracting tokens from requests and validates whether the user has the right role to perform requests),
- SSO using spring security LDAP ( responsible for authenticate user and generate JWT tokens) , SSO has also just a login page using thymeleaf
- Web interface using Thymeleaf without login page ( not sure if I should use here spring security, at the moment)
- Another microservice which provides data to web ui based on request from the browser
- Discovery services using Eureka
The idea is filtering all the requests on the gateway for validating and forward the requests. If the user is not authenticated or token is experied then forward the user to SSO for login.
The firewall will expose only the port on Gateway side then others one will be theirs ports blocked using firewall rules.
Now i am blocked without knowing where to go or if I should move the SSO together with the gateway ( conceptually wrong but it might be a workaround if i do not find any solution)
Following the issue : The user hits the gateway (ex. http://localhost:7070/web) then the gateway forward the user to (ex. http://localhost:8080/sso/login), after the credentials have been validated , the SSO creates the JWT tokens and add it to the Header of response.
Afterwards the SSO redirect the request back to the gateway (ex. http://localhost:7070/web).
**Until here, everything works fine but when the request reaches the gateway there is no 'Authorization' header on request which means NO JWT token.**
So the gateway should extract the token, check the credentials and forward the request to the Web interface (ex. http://localhost:9090)
I am aware that using Handler on SSO to redirect request won't work at all due to 'Redirect' from spring will remove the token from the header before redirect.
But I do not know whether there is another way to set again the JWT on the header after Spring has removed it from the request or not.
Is there any conceptually issue on the architecture side? How can I forward the JWT to the gateway for being checked?
SSO
@EnableWebSecurity
public class SecurityCredentialsConfig extends WebSecurityConfigurerAdapter {
@Value("${ldap.url}")
private String ldapUrl;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
// Stateless session; session won't be used to store user's state.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin()
.loginPage("/login")
// Add a handler to add token in the response header and forward the response
.successHandler(jwtAuthenticationSuccessHandler())
.failureUrl("/login?error")
.permitAll()
.and()
// handle an authorized attempts
.exceptionHandling()
.accessDeniedPage("/login?error")
.and()
.authorizeRequests()
.antMatchers( "/dist/**", "/plugins/**").permitAll()
.anyRequest().authenticated();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userDnPatterns("uid={0},ou=people")
.groupSearchBase("ou=groups")
.userSearchFilter("uid={0}")
.groupSearchBase("ou=groups")
.groupSearchFilter("uniqueMember={0}")
.contextSource()
.url(ldapUrl);
}
@Bean
public AuthenticationSuccessHandler jwtAuthenticationSuccessHandler() {
return new JwtAuthenticationSuccessHandler();
}
}
public class JwtAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Autowired
private JwtConfig jwtConfig;
@Autowired
private JwtTokenService jwtTokenService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication auth) throws IOException, ServletException {
String token = jwtTokenService.expiring(ImmutableMap.of(
"email", auth.getName(),
"authorities", auth.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.map(Object::toString)
.collect(Collectors.joining(","))));
response.addHeader(jwtConfig.getHeader(), jwtConfig.getPrefix() + token);
DefaultSavedRequest defaultSavedRequest = (DefaultSavedRequest) request.getSession().getAttribute("SPRING_SECURITY_SAVED_REQUEST");
if(defaultSavedRequest != null){
getRedirectStrategy().sendRedirect(request, response, defaultSavedRequest.getRedirectUrl());
}else{
getRedirectStrategy().sendRedirect(request, response, "http://localhost:7070/web");
}
}
}
Gateway
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtConfig jwtConfig;
@Value("${accessDeniedPage.url}")
private String accessDeniedUrl;
@Override
protected void configure(final HttpSecurity http) throws Exception {
http
.csrf().disable() // Disable CSRF (cross site request forgery)
// we use stateless session; session won't be used to store user's state.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin()
.loginPage("/sso/login")
.permitAll()
.and()
// handle an authorized attempts
// If a user try to access a resource without having enough permissions
.exceptionHandling()
.accessDeniedPage(accessDeniedUrl)
//.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
.and()
// Add a filter to validate the tokens with every request
.addFilterBefore(new JwtTokenAuthenticationFilter(jwtConfig), UsernamePasswordAuthenticationFilter.class)
// authorization requests config
.authorizeRequests()
.antMatchers("/web/**").hasAuthority("ADMIN")
// Any other request must be authenticated
.anyRequest().authenticated();
}
}
@RequiredArgsConstructor
public class JwtTokenAuthenticationFilter extends OncePerRequestFilter {
private final JwtConfig jwtConfig;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 1. get the authentication header. Tokens are supposed to be passed in the authentication header
String header = request.getHeader(jwtConfig.getHeader());
// 2. validate the header and check the prefix
if(header == null || !header.startsWith(jwtConfig.getPrefix())) {
chain.doFilter(request, response); // If not valid, go to the next filter.
return;
}
// If there is no token provided and hence the user won't be authenticated.
// It's Ok. Maybe the user accessing a public path or asking for a token.
// All secured paths that needs a token are already defined and secured in config class.
// And If user tried to access without access token, then he/she won't be authenticated and an exception will be thrown.
// 3. Get the token
String token = header.replace(jwtConfig.getPrefix(), "");
try { // exceptions might be thrown in creating the claims if for example the token is expired
// 4. Validate the token
Claims claims = Jwts.parser()
.setSigningKey(jwtConfig.getSecret().getBytes())
.parseClaimsJws(token)
.getBody();
String email = claims.get("email").toString();
if(email != null) {
String[] authorities = ((String) claims.get("authorities")).split(",");
final List<String> listAuthorities = Arrays.stream(authorities).collect(Collectors.toList());
// 5. Create auth object
// UsernamePasswordAuthenticationToken: A built-in object, used by spring to represent the current authenticated / being authenticated user.
// It needs a list of authorities, which has type of GrantedAuthority interface, where SimpleGrantedAuthority is an implementation of that interface
final UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
email, null, listAuthorities
.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList()));
// 6. Authenticate the user
// Now, user is authenticated
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (Exception e) {
// In case of failure. Make sure it's clear; so guarantee user won't be authenticated
SecurityContextHolder.clearContext();
}
// go to the next filter in the filter chain
chain.doFilter(request, response);
}
}
----------
@Component
public class AuthenticatedFilter extends ZuulFilter {
@Override
public String filterType() {
return PRE_TYPE;
}
@Override
public int filterOrder() {
return 0;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
final Object object = SecurityContextHolder.getContext().getAuthentication();
if (object == null || !(object instanceof UsernamePasswordAuthenticationToken)) {
return null;
}
final UsernamePasswordAuthenticationToken user = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
final RequestContext requestContext = RequestContext.getCurrentContext();
/*
final AuthenticationDto authenticationDto = new AuthenticationDto();
authenticationDto.setEmail(user.getPrincipal().toString());
authenticationDto.setAuthenticated(true);
authenticationDto.setRoles(user.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList())); */
try {
//requestContext.addZuulRequestHeader(HttpHeaders.AUTHORIZATION, (new ObjectMapper()).writeValueAsString(authenticationDto));
requestContext.addZuulRequestHeader(HttpHeaders.AUTHORIZATION, (new ObjectMapper()).writeValueAsString("authenticationDto"));
} catch (JsonProcessingException e) {
throw new ZuulException("Error on JSON processing", 500, "Parsing JSON");
}
return null;
}
}
</details>
# 答案1
**得分**: 1
关于JWT存在一个问题,称之为"登出问题"。首先,您需要了解这是什么。
然后,检查**TokenRelay**过滤器(TokenRelayGatewayFilterFactory),该过滤器负责将授权头传递到下游。
如果您查看该过滤器,您会看到JWT被存储在ConcurrentHashMap中(InMemoryReactiveOAuth2AuthorizedClientService)。键是会话,值是JWT。因此,**响应中返回的是会话ID,而不是JWT头**。
> 到目前为止,一切都正常,但是当请求到达网关时,请求中没有'Authorization'头,这意味着没有JWT令牌。
**是的**。当请求到达网关时,TokenRelay过滤器会从请求中获取会话ID,并从ConcurrentHashMap中找到JWT,然后在下游传递到授权头。
很可能,这个流程是由Spring安全团队设计的,用来解决JWT登出问题。
<details>
<summary>英文:</summary>
There is an issue about JWT. It is called "Logout Problem". First you need to understand what it is.
Then, check **TokenRelay** filter (TokenRelayGatewayFilterFactory) which is responsible for passing authorization header to downstream.
If you look at that filter, you will see that JWTs are stored in ConcurrentHashMap (InMemoryReactiveOAuth2AuthorizedClientService). The key is session, the value is JWT. So, **session-id is returned instead of JWT header** as the response provided.
> Until here, everything works fine but when the request reaches the
> gateway there is no 'Authorization' header on request which means NO
> JWT token.
**Yes**. When the request comes to gateway, TokenRelay filter takes session-id from request and find JWT from ConcurrentHashMap, then it passes to Authorization header during downstream.
Probably, this flow is designed by spring security team to address JWT logout problem.
</details>
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论