微服务 Spring Cloud Gateway + Spring Security LDAP 作为 SSO + JWT - 请求/响应之间丢失令牌

Posted

技术标签:

【中文标题】微服务 Spring Cloud Gateway + Spring Security LDAP 作为 SSO + JWT - 请求/响应之间丢失令牌【英文标题】:Microservices Spring Cloud Gateway + Spring Security LDAP as SSO + JWT - Token lost between request/response 【发布时间】:2020-12-27 17:33:35 【问题描述】:

我正在使用 spring-boot 开发一个微服务生态系统。目前已经到位的微服务:

Spring Cloud Gateway - Zuul(还负责微服务下游的授权请求 - 从请求中提取令牌并验证用户是否具有执行请求的正确角色), SSO 使用 Spring Security LDAP(负责验证用户并生成 JWT 令牌),SSO 也只有一个使用 thymeleaf 的登录页面 使用没有登录页面的 Thymeleaf 的 Web 界面(目前不确定我是否应该在这里使用 spring security) 另一个微服务,它根据来自浏览器的请求向 Web ui 提供数据 使用 Eureka 的发现服务

这个想法是过滤网关上的所有请求以验证和转发请求。如果用户未通过身份验证或令牌过期,则将用户转发到 SSO 进行登录。 防火墙将只暴露网关端的端口,然后其他端口将是使用防火墙规则阻止的端口。

现在我被阻止了,不知道去哪里或者是否应该将 SSO 与网关一起移动(概念上是错误的,但如果我找不到任何解决方案,这可能是一种解决方法)

以下问题:用户点击网关(例如 http://localhost:7070/web)然后网关将用户转发到(例如 http://localhost:8080/sso/login),之后凭据已被验证,SSO 创建 JWT 令牌并将其添加到响应的标头中。 然后 SSO 将请求重定向回网关(例如 http://localhost:7070/web)。

到这里为止,一切正常,但是当请求到达网关时,请求上没有“授权”标头,这意味着没有 JWT 令牌。

因此网关应该提取令牌、检查凭据并将请求转发到 Web 界面(例如 http://localhost:9090)

我知道在 SSO 上使用 Handler 来重定向请求根本不起作用,因为 spring 的“重定向”会在重定向之前从标头中删除令牌。 但我不知道在 Spring 将其从请求中删除之后,是否还有另一种方法可以在标头上再次设置 JWT。

在架构方面是否存在任何概念上的问题?如何将 JWT 转发到网关进行检查?

单点登录

@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");
        
    


网关

    @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;
    

【问题讨论】:

【参考方案1】:

JWT 存在问题。它被称为“注销问题”。首先你需要了解它是什么。

然后,检查TokenRelay过滤器(TokenRelayGatewayFilterFactory),它负责将授权标头传递给下游。

如果您查看该过滤器,您将看到 JWT 存储在 ConcurrentHashMap (InMemoryReactiveOAuth2AuthorizedClientService) 中。键是会话,值是 JWT。因此,返回 session-id 而不是 JWT 标头作为提供的响应。

直到这里,一切正常,但是当请求到达 网关请求没有“授权”标头,这意味着没有 JWT 令牌。

是的。当请求到达网关时,TokenRelay 过滤器从请求中获取 session-id,并从 ConcurrentHashMap 中找到 JWT,然后在下游传递给 Authorization 头。

可能这个流程是 Spring Security 团队为解决 JWT 注销问题而设计的。

【讨论】:

以上是关于微服务 Spring Cloud Gateway + Spring Security LDAP 作为 SSO + JWT - 请求/响应之间丢失令牌的主要内容,如果未能解决你的问题,请参考以下文章

spring cloud微服务快速教程之 gateway 服务网关

微服务架构之spring cloud gateway

从spring cloud gateway调用微服务

微服务权限终极解决方案(spring-cloud-gateway-oauth2)

服务门户:Spring Cloud Gateway 如何把好微服务的大门

Spring Cloud Gateway面试攻略,微服务网关的作用以及案例