使用 Spring Security 和 Keycloak 进行 Spring Websockets 身份验证

标签 spring spring-boot spring-security websocket keycloak

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

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

我可以使用以下方法从客户端检索不记名 token :
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();
  }

}

欢迎任何帮助或想法。

最佳答案

根据 Ramanthis question 的建议,我能够启用基于 token 的身份验证。这是使其工作的最终代码:

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

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 端点来验证 token 签名,从而针对 keycloak 实际验证 token 的类。它返回一个 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配置中注入(inject)验证器,并按照spring docs的推荐完成这段代码:
@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;
      }
    });
  }

}

我还稍微更改了我的安全配置。首先,我从 spring web 安全中排除了 WS 端点,并且还让连接方法对 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;
  }
}

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

关于使用 Spring Security 和 Keycloak 进行 Spring Websockets 身份验证,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/50573461/

相关文章:

java - Spring 身份验证,它是否使用加密的 cookie?

java - 如何在@InitBinder 验证之前执行代码

spring - 多个客户端ID Spring 安全配置

spring - 在 Spring Boot 中使用自定义 token 进行身份验证

spring - 降低在 AWS EC2 中运行的 Spring Boot 项目的成本

java - 无法将 '' 下的属性绑定(bind)到 com.zaxxer.hikari.HikariDataSource : spring boot 2. 4.1

java - Spring Hibernate MySQL - 没有连接?

Spring Boot 2.3.x 应用程序卡在启动时

spring-boot - Apache Camel Spring Boot - 处理路由后应用程序的正常关闭

java - 如何在Spring mvc登录中更改登录成功Url