【问题标题】:Spring Websockets Authentication with Spring Security and Keycloak使用 Spring Security 和 Keycloak 进行 Spring Websockets 身份验证
【发布时间】:2018-11-07 11:10:59
【问题描述】:

我正在使用 Spring Boot (v1.5.10.RELEASE) 为用 Angular 编写的应用程序创建后端。背面使用弹簧安全 + 钥匙斗篷固定。现在我正在添加一个 websocket,使用 STOMP over SockJS,并希望保护它。我正在尝试关注Websocket Token Authentication 的文档,它显示了以下代码:

if (StompCommand.CONNECT.equals(accessor.getCommand())) {
  Authentication user = ... ; // access authentication header(s)
  accessor.setUser(user);
}

我可以使用以下方法从客户端检索不记名令牌:

String token = accessor.getNativeHeader("Authorization").get(0);

我的问题是,如何将其转换为 Authentication 对象?或者如何从这里开始?因为我总是得到 403。这是我的 websocket 安全配置:

@Configuration
public class WebSocketSecurityConfig extends 
     AbstractSecurityWebSocketMessageBrokerConfigurer {

@Override
protected void configureInbound(MessageSecurityMetadataSourceRegistry 
    messages) {
messages.simpDestMatchers("/app/**").authenticated().simpSubscribeDestMatchers("/topic/**").authenticated()
    .anyMessage().denyAll();
}

  @Override
  protected boolean sameOriginDisabled() {
    return true;
  }
}

这是网络安全配置:

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@Configuration
public class WebSecurityConfiguration extends KeycloakWebSecurityConfigurerAdapter {

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
        .csrf().disable()
        .authenticationProvider(keycloakAuthenticationProvider())
        .addFilterBefore(keycloakAuthenticationProcessingFilter(), BasicAuthenticationFilter.class)
        .sessionManagement()
          .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
          .sessionAuthenticationStrategy(sessionAuthenticationStrategy())
        .and()
        .authorizeRequests()
          .requestMatchers(new NegatedRequestMatcher(new AntPathRequestMatcher("/management/**")))
            .hasRole("USER");
  }

  @Override
  protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
    return new NullAuthenticatedSessionStrategy();
  }

  @Bean
  public KeycloakConfigResolver KeycloakConfigResolver() {
    return new KeycloakSpringBootConfigResolver();
  }

}

欢迎任何帮助或想法。

【问题讨论】:

  • KC Spring 安全适配器基于由 cookie 管理的 Web 会话,链接到访问令牌。如果您能够通过套接字使用会话(似乎可以:docs.spring.io/spring-session/docs/current/reference/html5/…),那么您应该能够将适配器放入游戏中。
  • 你能详细说明一下吗?我查看了该文档并尝试激活 spring-session,但我认为它不起作用,因为该应用程序是无状态的,我正在为 KC 安全适配器添加部分配置。
  • 您能否添加客户端代码以了解您如何从客户端发送 Auth 令牌?

标签: spring spring-boot spring-security websocket keycloak


【解决方案1】:

按照Ramanthis question 的建议,我能够启用基于令牌的身份验证。这是使它工作的最终代码:

1) 首先,创建一个代表 JWS 身份验证令牌的类:

public class JWSAuthenticationToken extends AbstractAuthenticationToken implements Authentication {

  private static final long serialVersionUID = 1L;

  private String token;
  private User principal;

  public JWSAuthenticationToken(String token) {
    this(token, null, null);
  }

  public JWSAuthenticationToken(String token, User principal, Collection<GrantedAuthority> authorities) {
    super(authorities);
    this.token = token;
    this.principal = principal;
  }

  @Override
  public Object getCredentials() {
    return token;
  }

  @Override
  public Object getPrincipal() {
    return principal;
  }

}

2) 然后,创建一个处理 JWSToken 的身份验证器,针对 keycloak 进行验证。 User 是我自己的代表用户的应用类:

@Slf4j
@Component
@Qualifier("websocket")
@AllArgsConstructor
public class KeycloakWebSocketAuthManager implements AuthenticationManager {

  private final KeycloakTokenVerifier tokenVerifier;

  @Override
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    JWSAuthenticationToken token = (JWSAuthenticationToken) authentication;
    String tokenString = (String) token.getCredentials();
    try {
      AccessToken accessToken = tokenVerifier.verifyToken(tokenString);
      List<GrantedAuthority> authorities = accessToken.getRealmAccess().getRoles().stream()
          .map(SimpleGrantedAuthority::new).collect(Collectors.toList());
      User user = new User(accessToken.getName(), accessToken.getEmail(), accessToken.getPreferredUsername(),
          accessToken.getRealmAccess().getRoles());
      token = new JWSAuthenticationToken(tokenString, user, authorities);
      token.setAuthenticated(true);
    } catch (VerificationException e) {
      log.debug("Exception authenticating the token {}:", tokenString, e);
      throw new BadCredentialsException("Invalid token");
    }
    return token;
  }

}

3) 基于this gists,通过调用 certs 端点来验证令牌签名,实际针对 keycloak 验证令牌的类。它返回一个 keycloak AccessToken:

@Component
@AllArgsConstructor
public class KeycloakTokenVerifier {

  private final KeycloakProperties config;

  /**
   * Verifies a token against a keycloak instance
   * @param tokenString the string representation of the jws token
   * @return a validated keycloak AccessToken
   * @throws VerificationException when the token is not valid
   */
  public AccessToken verifyToken(String tokenString) throws VerificationException {
    RSATokenVerifier verifier = RSATokenVerifier.create(tokenString);
    PublicKey publicKey = retrievePublicKeyFromCertsEndpoint(verifier.getHeader());
    return verifier.realmUrl(getRealmUrl()).publicKey(publicKey).verify().getToken();
  }

  @SuppressWarnings("unchecked")
  private PublicKey retrievePublicKeyFromCertsEndpoint(JWSHeader jwsHeader) {
    try {
      ObjectMapper om = new ObjectMapper();
      Map<String, Object> certInfos = om.readValue(new URL(getRealmCertsUrl()).openStream(), Map.class);
      List<Map<String, Object>> keys = (List<Map<String, Object>>) certInfos.get("keys");

      Map<String, Object> keyInfo = null;
      for (Map<String, Object> key : keys) {
        String kid = (String) key.get("kid");
        if (jwsHeader.getKeyId().equals(kid)) {
          keyInfo = key;
          break;
        }
      }

      if (keyInfo == null) {
        return null;
      }

      KeyFactory keyFactory = KeyFactory.getInstance("RSA");
      String modulusBase64 = (String) keyInfo.get("n");
      String exponentBase64 = (String) keyInfo.get("e");
      Decoder urlDecoder = Base64.getUrlDecoder();
      BigInteger modulus = new BigInteger(1, urlDecoder.decode(modulusBase64));
      BigInteger publicExponent = new BigInteger(1, urlDecoder.decode(exponentBase64));

      return keyFactory.generatePublic(new RSAPublicKeySpec(modulus, publicExponent));

    } catch (Exception e) {
      e.printStackTrace();
    }
    return null;
  }

  public String getRealmUrl() {
    return String.format("%s/realms/%s", config.getAuthServerUrl(), config.getRealm());
  }

  public String getRealmCertsUrl() {
    return getRealmUrl() + "/protocol/openid-connect/certs";
  }

}

4) 最后,在 Websocket 配置中注入验证器,并按照 spring 文档的建议完成这段代码:

@Slf4j
@Configuration
@EnableWebSocketMessageBroker
@AllArgsConstructor
public class WebSocketConfiguration extends AbstractWebSocketMessageBrokerConfigurer {

  @Qualifier("websocket")
  private AuthenticationManager authenticationManager;

  @Override
  public void configureMessageBroker(MessageBrokerRegistry config) {
    config.enableSimpleBroker("/topic");
    config.setApplicationDestinationPrefixes("/app");
  }

  @Override
  public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/ws-paperless").setAllowedOrigins("*").withSockJS();
  }

  @Override
  public void configureClientInboundChannel(ChannelRegistration registration) {
    registration.interceptors(new ChannelInterceptorAdapter() {
      @Override
      public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        if (StompCommand.CONNECT.equals(accessor.getCommand())) {
          Optional.ofNullable(accessor.getNativeHeader("Authorization")).ifPresent(ah -> {
            String bearerToken = ah.get(0).replace("Bearer ", "");
            log.debug("Received bearer token {}", bearerToken);
            JWSAuthenticationToken token = (JWSAuthenticationToken) authenticationManager
                .authenticate(new JWSAuthenticationToken(bearerToken));
            accessor.setUser(token);
          });
        }
        return message;
      }
    });
  }

}

我还稍微更改了我的安全配置。首先,我将 WS 端点从 Spring Web 安全中排除,并让连接方法对 WebSocket 安全中的任何人开放:

在 WebSecurityConfiguration 中:

  @Override
  public void configure(WebSecurity web) throws Exception {
    web.ignoring()
        .antMatchers("/ws-endpoint/**");
  }

在 WebSocketSecurityConfig 类中:

@Configuration
public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {

  @Override
  protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
    messages.simpTypeMatchers(CONNECT, UNSUBSCRIBE, DISCONNECT, HEARTBEAT).permitAll()
    .simpDestMatchers("/app/**", "/topic/**").authenticated().simpSubscribeDestMatchers("/topic/**").authenticated()
        .anyMessage().denyAll();
  }

  @Override
  protected boolean sameOriginDisabled() {
    return true;
  }
}

所以最终结果是:本地网络中的任何人都可以连接到套接字,但要实际订阅任何频道,您必须经过身份验证,因此您需要发送带有原始 CONNECT 消息的 Bearer 令牌,否则您会得到 UnauthorizedException。希望它可以帮助其他人解决这个要求!

【讨论】:

  • 如果客户端仍然连接到您的 websocket 端点并且令牌不再有效会发生什么?
【解决方案2】:

我喜欢adrianmoya的回答,除了KeycloakTokenVerifier的部分。我改用以下内容:

public class KeycloakWebSocketAuthManager implements AuthenticationManager {

  private final KeycloakSpringBootConfigResolver keycloakSpringBootConfigResolver;

  @Override
  public Authentication authenticate(final Authentication authentication) throws AuthenticationException {
     final JWSAuthenticationToken token = (JWSAuthenticationToken) authentication;
     final String tokenString = (String) token.getCredentials();
     try {
        final KeycloakDeployment resolve = keycloakSpringBootConfigResolver.resolve(null);
        final AccessToken accessToken = AdapterRSATokenVerifier.verifyToken(tokenString, resolve);
       ...
      }
}

【讨论】:

    【解决方案3】:

    我能够在不使用 Spring Security 和 SockJS 的情况下进行 websocket 身份验证/授权:

    @Configuration
    @EnableWebSocketMessageBroker
    @RequiredArgsConstructor
    public class StompConfiguration implements WebSocketMessageBrokerConfigurer {
    
        private final KeycloakSpringBootProperties configuration;
    
        @Override
        public void configureMessageBroker(MessageBrokerRegistry config) {
            config.setApplicationDestinationPrefixes("/stompy");  // prefix for incoming messages in @MessageMapping
            config.enableSimpleBroker("/broker");                 // enabling broker @SendTo("/broker/blabla")
        }
    
        @Override
        public void registerStompEndpoints(StompEndpointRegistry registry) {
            registry.addEndpoint("/stomp")
                    .addInterceptors(new StompHandshakeInterceptor(configuration))
                    .setAllowedOrigins("*");
        }
    }
    

    握手拦截器:

    @Slf4j
    @RequiredArgsConstructor
    public class StompHandshakeInterceptor implements HandshakeInterceptor {
    
        private final KeycloakSpringBootProperties configuration;
    
        @Override
        public boolean beforeHandshake(ServerHttpRequest req, ServerHttpResponse resp, WebSocketHandler h, Map<String, Object> atts) {
            List<String> protocols = req.getHeaders().get("Sec-WebSocket-Protocol");
            try {
                String token = protocols.get(0).split(", ")[2];
                log.debug("Token: " + token);
                AdapterTokenVerifier.verifyToken(token, KeycloakDeploymentBuilder.build(configuration));
                resp.setStatusCode(HttpStatus.SWITCHING_PROTOCOLS);
                log.debug("token valid");
            } catch (IndexOutOfBoundsException e) {
                resp.setStatusCode(HttpStatus.UNAUTHORIZED);
                return false;
            }
            catch (VerificationException e) {
                resp.setStatusCode(HttpStatus.FORBIDDEN);
                log.error(e.getMessage());
                return false;
            }
            return true;
        }
    
        @Override
        public void afterHandshake(ServerHttpRequest rq, ServerHttpResponse rp, WebSocketHandler h, @Nullable Exception e) {}
    }
    

    Websocket 控制器:

    @Controller
    public class StompController {
        @MessageMapping("/test")
        @SendTo("/broker/lol")
        public String lol(String message) {
            System.out.println("Incoming message: " + message);
            return message;
        }
    }
    

    客户端(javascript):

    function connect() {
        let protocols = ['v10.stomp', 'v11.stomp'];
        protocols.push("KEYCLOAK TOKEN");
        const url = "ws://localhost:8080/stomp";
    
        client = Stomp.client(url, protocols);
        client.connect(
            {},
            () => {
                console.log("Connection established");
                client.subscribe("/broker/lol", function (mes) {
                    console.log("New message for /broker/lol: " + mes.body);
                });
            },
            error => { console.log("ERROR: " + error); }
        );
    }
    
    function sendMessage() {
        let message = "test message";
        if (client) client.send("/stompy/test", {}, message);
    }
    

    build.gradle:

    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-web'
        implementation 'org.springframework.boot:spring-boot-starter-websocket'
        compileOnly 'org.projectlombok:lombok'
        testImplementation 'org.springframework.boot:spring-boot-starter-test'
    
        // keycloak
        implementation 'org.keycloak:keycloak-spring-boot-starter'
    
        // stomp.js
        implementation("org.webjars:webjars-locator-core")
        implementation("org.webjars:stomp-websocket:2.3.3")
    }
    
    dependencyManagement {
        imports {
            mavenBom "org.keycloak.bom:keycloak-adapter-bom:$keycloakVersion"
        }
    }
    

    如您所见,客户端在握手期间已通过身份验证。 HandshakeInterceptor 类从 Sec-WebSocket-Protocol 标头中提取令牌。不需要 SockJS 或 Spring Security。希望这会有所帮助:)

    【讨论】:

    • 当我实现WebSocketMessageBrokerConfigurer时,我的IDE也要求我也实现configureWebSocketTransport, configureClientInboundChannel, configureClientOutboundChannel, addArgumentResolvers, addReturnValueHandlers, configureMessageConverters的方法。你知道为什么吗?
    • @cactuschibre,这很奇怪,因为所有WebSocketMessageBrokerConfigurer 接口的方法都是默认的,这意味着它们具有默认实现。您使用的是哪个 Java 版本?
    猜你喜欢
    • 2014-07-03
    • 1970-01-01
    • 2012-11-27
    • 2011-10-17
    • 2017-08-14
    • 2017-03-22
    • 2014-09-15
    • 2018-08-09
    相关资源
    最近更新 更多