Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WebSockets #290

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-messaging</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId>
Expand Down
39 changes: 39 additions & 0 deletions src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,36 @@
import cz.cvut.kbss.termit.security.JwtAuthorizationFilter;
import cz.cvut.kbss.termit.security.JwtUtils;
import cz.cvut.kbss.termit.security.SecurityConstants;
import cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor;
import cz.cvut.kbss.termit.service.security.TermItUserDetailsService;
import cz.cvut.kbss.termit.util.Constants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.messaging.Message;
import org.springframework.messaging.simp.SimpMessageType;
import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity;
import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
Expand Down Expand Up @@ -143,4 +153,33 @@ protected static CorsConfigurationSource createCorsConfiguration(
source.registerCorsConfiguration("/**", corsConfiguration);
return source;
}

/**
* Part of {@link EnableWebSocketSecurity @EnableWebSocketSecurity} replacement
* @see WebSocketConfig
*/
@Bean
@Scope("prototype")
public MessageMatcherDelegatingAuthorizationManager.Builder messageAuthorizationManagerBuilder(
ApplicationContext context) {
return MessageMatcherDelegatingAuthorizationManager.builder().simpDestPathMatcher(
() -> (context.getBeanNamesForType(SimpAnnotationMethodMessageHandler.class).length > 0)
? context.getBean(SimpAnnotationMethodMessageHandler.class).getPathMatcher()
: new AntPathMatcher());
}

/**
* WebSocket endpoint authorization
*/
@Bean
public AuthorizationManager<Message<?>> messageAuthorizationManager(
MessageMatcherDelegatingAuthorizationManager.Builder messages) {
return messages.simpTypeMatchers(SimpMessageType.DISCONNECT).permitAll()
.anyMessage().authenticated().build();
}

@Bean
public WebSocketJwtAuthorizationInterceptor webSocketJwtAuthorizationInterceptor() {
return new WebSocketJwtAuthorizationInterceptor(jwtUtils, userDetailsService);
}
}
8 changes: 4 additions & 4 deletions src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -153,27 +153,27 @@ public SimpleUrlHandlerMapping sparqlQueryControllerMapping() throws Exception {
}

@Bean
public HttpMessageConverter<?> stringMessageConverter() {
public HttpMessageConverter<?> termitStringHttpMessageConverter() {
ledsoft marked this conversation as resolved.
Show resolved Hide resolved
return new StringHttpMessageConverter(StandardCharsets.UTF_8);
}

@Bean
public HttpMessageConverter<?> jsonLdMessageConverter() {
public HttpMessageConverter<?> termitJsonLdHttpMessageConverter() {
final MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(
jsonLdObjectMapper());
converter.setSupportedMediaTypes(Collections.singletonList(MediaType.valueOf(JsonLd.MEDIA_TYPE)));
return converter;
}

@Bean
public HttpMessageConverter<?> jsonMessageConverter() {
public HttpMessageConverter<?> termitJsonHttpMessageConverter() {
final MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
converter.setObjectMapper(objectMapper());
return converter;
}

@Bean
public HttpMessageConverter<?> resourceMessageConverter() {
public HttpMessageConverter<?> termitResourceHttpMessageConverter() {
return new ResourceHttpMessageConverter();
}

Expand Down
136 changes: 136 additions & 0 deletions src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package cz.cvut.kbss.termit.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor;
import cz.cvut.kbss.termit.util.Constants;
import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler;
import cz.cvut.kbss.termit.websocket.handler.WebSocketMessageWithHeadersValueHandler;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.messaging.Message;
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
import org.springframework.messaging.converter.MessageConverter;
import org.springframework.messaging.converter.StringMessageConverter;
import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver;
import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.authorization.SpringAuthorizationEventPublisher;
import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity;
import org.springframework.security.messaging.access.intercept.AuthorizationChannelInterceptor;
import org.springframework.security.messaging.context.AuthenticationPrincipalArgumentResolver;
import org.springframework.security.messaging.context.SecurityContextChannelInterceptor;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration;

import java.nio.charset.StandardCharsets;
import java.util.List;

/*
We are not using @EnableWebSocketSecurity
it automatically requires CSRF which cannot be configured (disabled) at the moment
(will probably change in the future)
*/
@Configuration
@EnableWebSocketMessageBroker
@Order(Ordered.HIGHEST_PRECEDENCE + 99) // ensures priority above Spring Security
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

private final cz.cvut.kbss.termit.util.Configuration configuration;

private final ApplicationContext context;

private final AuthorizationManager<Message<?>> messageAuthorizationManager;

private final WebSocketJwtAuthorizationInterceptor jwtAuthorizationInterceptor;

private final ObjectMapper jsonLdMapper;

private final SimpMessagingTemplate simpMessagingTemplate;

@Autowired
public WebSocketConfig(cz.cvut.kbss.termit.util.Configuration configuration, ApplicationContext context,
AuthorizationManager<Message<?>> messageAuthorizationManager,
WebSocketJwtAuthorizationInterceptor jwtAuthorizationInterceptor,
@Qualifier("jsonLdMapper") ObjectMapper jsonLdMapper,
@Lazy SimpMessagingTemplate simpMessagingTemplate) {
this.configuration = configuration;
this.context = context;
this.messageAuthorizationManager = messageAuthorizationManager;
this.jwtAuthorizationInterceptor = jwtAuthorizationInterceptor;
this.jsonLdMapper = jsonLdMapper;
this.simpMessagingTemplate = simpMessagingTemplate;
}

/**
* WebSocket security setup (replaces {@link EnableWebSocketSecurity @EnableWebSocketSecurity})
*/
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
AuthenticationPrincipalArgumentResolver resolver = new AuthenticationPrincipalArgumentResolver();
argumentResolvers.add(resolver);
}

/**
* WebSocket security setup (replaces {@link EnableWebSocketSecurity @EnableWebSocketSecurity})
* @see <a href="https://github.com/spring-projects/spring-security/blob/6.3.x/config/src/main/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfiguration.java#L97">Spring security source</a>
*/
@Override
public void configureClientInboundChannel(@NotNull ChannelRegistration registration) {
AuthorizationChannelInterceptor interceptor = new AuthorizationChannelInterceptor(this.messageAuthorizationManager);
interceptor.setAuthorizationEventPublisher(new SpringAuthorizationEventPublisher(this.context));
registration.interceptors(jwtAuthorizationInterceptor, new SecurityContextChannelInterceptor(), interceptor);
}

@Override
public void addReturnValueHandlers(List<HandlerMethodReturnValueHandler> returnValueHandlers) {
returnValueHandlers.add(new WebSocketMessageWithHeadersValueHandler(simpMessagingTemplate));
}

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOrigins(configuration.getCors().getAllowedOrigins().split(","));
registry.setErrorHandler(new StompExceptionHandler());
}

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/")
.setUserDestinationPrefix("/user");
}

@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
registry.setTimeToFirstMessage(Constants.WEBSOCKET_TIME_TO_FIRST_MESSAGE);
registry.setSendBufferSizeLimit(Constants.WEBSOCKET_SEND_BUFFER_SIZE_LIMIT);
}

@Override
public boolean configureMessageConverters(List<MessageConverter> messageConverters) {
messageConverters.add(termitJsonLdMessageConverter());
messageConverters.add(termitStringMessageConverter());
return false; // do not add default converters
}

@Bean
public MessageConverter termitStringMessageConverter() {
return new StringMessageConverter(StandardCharsets.UTF_8);
}

@Bean
public MessageConverter termitJsonLdMessageConverter() {
return new MappingJackson2MessageConverter(jsonLdMapper);
}

}
22 changes: 0 additions & 22 deletions src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
import cz.cvut.kbss.termit.model.acl.AccessControlRecord;
import cz.cvut.kbss.termit.model.acl.AccessLevel;
import cz.cvut.kbss.termit.model.changetracking.AbstractChangeRecord;
import cz.cvut.kbss.termit.model.validation.ValidationResult;
import cz.cvut.kbss.termit.rest.doc.ApiDocConstants;
import cz.cvut.kbss.termit.rest.util.RestUtils;
import cz.cvut.kbss.termit.security.SecurityConstants;
Expand Down Expand Up @@ -413,27 +412,6 @@ public List<RdfsStatement> termsRelations(@Parameter(description = ApiDoc.ID_LOC
return vocabularyService.getTermRelations(vocabulary);
}

@Operation(description = "Validates the terms in a vocabulary with the specified identifier.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "A collection of validation results."),
@ApiResponse(responseCode = "404", description = ApiDoc.ID_NOT_FOUND_DESCRIPTION)
})
@PreAuthorize("permitAll()") // TODO Authorize?
@GetMapping(value = "/{localName}/validate",
produces = {MediaType.APPLICATION_JSON_VALUE, JsonLd.MEDIA_TYPE})
public List<ValidationResult> validateVocabulary(
@Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION,
example = ApiDoc.ID_LOCAL_NAME_EXAMPLE)
@PathVariable String localName,
@Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION,
example = ApiDoc.ID_NAMESPACE_EXAMPLE)
@RequestParam(name = QueryParams.NAMESPACE,
required = false) Optional<String> namespace) {
final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName);
final Vocabulary vocabulary = vocabularyService.getReference(identifier);
return vocabularyService.validateContents(vocabulary);
}

@Operation(security = {@SecurityRequirement(name = "bearer-key")},
description = "Creates a snapshot of the vocabulary with the specified identifier.")
@ApiResponses({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
* The general pattern should be that unless an exception can be handled in a more appropriate place it bubbles up to a
* REST controller which originally received the request. There, it is caught by this handler, logged and a reasonable
* error message is returned to the user.
* @implSpec Should reflect {@link cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler}
*/
@RestControllerAdvice
public class RestExceptionHandler {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package cz.cvut.kbss.termit.security;

import cz.cvut.kbss.termit.exception.AuthorizationException;
import cz.cvut.kbss.termit.exception.JwtException;
import cz.cvut.kbss.termit.security.model.TermItUserDetails;
import cz.cvut.kbss.termit.service.security.SecurityUtils;
import cz.cvut.kbss.termit.service.security.TermItUserDetailsService;
import org.jetbrains.annotations.NotNull;
import org.springframework.http.HttpHeaders;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

/**
* Authorizes STOMP CONNECT messages
* <p>
* Retrieves token from the {@code Authorization} header of STOMP message and validates JWT token.
*/
public class WebSocketJwtAuthorizationInterceptor implements ChannelInterceptor {

private final JwtUtils jwtUtils;

private final TermItUserDetailsService userDetailsService;

public WebSocketJwtAuthorizationInterceptor(JwtUtils jwtUtils, TermItUserDetailsService userDetailsService) {
this.jwtUtils = jwtUtils;
this.userDetailsService = userDetailsService;
}

@Override
public Message<?> preSend(@NotNull Message<?> message, @NotNull MessageChannel channel) {
StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (headerAccessor != null && StompCommand.CONNECT.equals(headerAccessor.getCommand()) && headerAccessor.isMutable()) {
final String authHeader = headerAccessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION);
if (authHeader != null && authHeader.startsWith(SecurityConstants.JWT_TOKEN_PREFIX)) {
headerAccessor.removeNativeHeader(HttpHeaders.AUTHORIZATION);
return process(message, authHeader, headerAccessor);
}
throw new AuthorizationException("Authorization header is invalid");
}
return message;
}

private Message<?> process(final @NotNull Message<?> message, final @NotNull String authHeader,
final @NotNull StompHeaderAccessor headerAccessor) {
final String authToken = authHeader.substring(SecurityConstants.JWT_TOKEN_PREFIX.length());
try {
final TermItUserDetails userDetails = jwtUtils.extractUserInfo(authToken);
final TermItUserDetails existingDetails = userDetailsService.loadUserByUsername(userDetails.getUsername());
SecurityUtils.verifyAccountStatus(existingDetails.getUser());
Authentication authentication = SecurityUtils.setCurrentUser(existingDetails);
headerAccessor.setUser(authentication);
return message;
} catch (JwtException | DisabledException | LockedException | UsernameNotFoundException e) {
throw new AuthorizationException(e.getMessage());
}
}
}
11 changes: 11 additions & 0 deletions src/main/java/cz/cvut/kbss/termit/util/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -243,4 +243,15 @@ private QueryParams() {
throw new AssertionError();
}
}

/**
* the maximum amount of data to buffer when sending messages to a WebSocket session
*/
public static final int WEBSOCKET_SEND_BUFFER_SIZE_LIMIT = Integer.MAX_VALUE;

/**
* Set the maximum time allowed in milliseconds after the WebSocket connection is established
* and before the first sub-protocol message is received.
*/
public static final int WEBSOCKET_TIME_TO_FIRST_MESSAGE = 15 * 1000 /* 15s */;
}
Loading