web-dev-qa-db-fra.com

Authentification Spring Websockets avec Spring Security et Keycloak

J'utilise Spring Boot (v1.5.10.RELEASE) pour créer un backend pour une application écrite en angulaire. Le dos est sécurisé à l'aide de la sécurité à ressort + de la clé. Maintenant, j'ajoute une prise Web, en utilisant STOMP sur SockJS, et je voulais le sécuriser. J'essaie de suivre les documents sur Websocket Token Authentication , et il montre le morceau de code suivant:

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

Je suis en mesure de récupérer le jeton porteur du client en utilisant:

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

Ma question est, comment puis-je convertir cela en un objet d'authentification? Ou comment procéder à partir d'ici? Parce que j'obtiens toujours 403. Voici ma configuration de sécurité 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;
  }
}

Et voici la configuration de sécurité 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();
  }

}

Toute aide ou idée est la bienvenue.

7
adrianmoya

J'ai pu activer l'authentification basée sur les jetons, en suivant les recommandations de Raman le cette question . Voici le code final pour le faire fonctionner:

1) Créez d'abord une classe qui représente le jeton d'authentification 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) Ensuite, créez un authentificateur qui gère le JWSToken, validant par rapport à keycloak. L'utilisateur est ma propre classe d'application qui représente un utilisateur:

@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) La classe qui valide réellement le jeton par rapport à keycloak en appelant le point de terminaison certs pour valider la signature du jeton, sur la base de ce qui est essentiel . Il renvoie un AccessToken keycloak:

@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) Enfin, injectez l'authentificateur dans la configuration de Websoket et complétez le morceau de code comme recommandé par les docs de printemps:

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

}

J'ai également changé un peu ma configuration de sécurité. Tout d'abord, j'ai exclu le point de terminaison WS de la sécurité Web du printemps et j'ai également laissé les méthodes de connexion ouvertes à tous les utilisateurs de la sécurité Websocket:

Dans WebSecurityConfiguration:

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

Et dans 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;
  }
}

Donc, le résultat final est: n'importe qui sur le réseau local peut se connecter à la socket, mais pour vous abonner à n'importe quel canal, vous devez être authentifié, vous devez donc envoyer le jeton Bearer avec le message CONNECT d'origine ou vous obtiendrez UnauthorizedException . J'espère que cela aide les autres avec cette exigence!

11
adrianmoya

J'aime la réponse d'Adrianmoya, sauf pour la partie du KeycloakTokenVerifier. J'utilise à la place ce qui suit:

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

J'ai pu faire l'authentification/autorisation Websocket sans utiliser Spring Security et SockJS:

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

Intercepteur de poignée de main:

@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) {}
}

Contrôleur Websocket:

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

Côté client (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"
    }
}

Comme vous pouvez le voir, le client est authentifié pendant la prise de contact. La classe HandshakeInterceptor extrait le jeton du Sec-WebSocket-Protocol entête. Aucune sécurité SockJS ou Spring n'est nécessaire. J'espère que cela t'aides :)

0
maslick