web-dev-qa-db-ja.com

Spring SecurityとKeycloakを使用したSpring Websockets認証

Angularで記述されたアプリケーションのバックエンドを作成するために、Spring Boot(v1.5.10.RELEASE)を使用しています。背中は春のセキュリティ+キークロークを使用して保護されています。現在、SockJSではなくSTOMPを使用してWebソケットを追加しており、それを保護したいと考えていました。 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);

私の質問は、それを認証オブジェクトに変換するにはどうすればよいですか?または、ここから先に進むには?私は常に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;
  }
}

そして、これはWebセキュリティ構成です。

@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();
  }

}

ヘルプやアイデアは大歓迎です。

7
adrianmoya

この質問Raman による推奨事項に従って、トークンベースの認証を有効にすることができました。動作させるための最終的なコードは次のとおりです。

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を処理する認証子を作成し、キークロークに対して検証します。ユーザーはユーザーを表す私の独自のアプリクラスです:

@Slf4j
@Component
@Qualifier("websoket")
@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 に基づいて、証明書のエンドポイントを呼び出してトークンの署名を検証することにより、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)最後に、オーセンティケーターをWebsoket構成に挿入し、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;
      }
    });
  }

}

セキュリティ構成も少し変更しました。最初に、春の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;
  }
}

つまり、最終的な結果は次のとおりです。ローカルネットワークの誰でもソケットに接続できますが、実際に任意のチャネルにサブスクライブするには、認証を受ける必要があるため、元のCONNECTメッセージでベアラートークンを送信する必要があります。そうしないと、UnauthorizedExceptionが発生します。 。それがこの要求で他の人を助けることを願っています!

11
adrianmoya

KeycloakTokenVerifierの部分を除いて、adrianmoyaの答えが好きです。代わりに次を使用します。

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);
       ...
      }
}
1
rloeffel

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は必要ありません。お役に立てれば :)

0
maslick