Skip to content

Commit

Permalink
Added security to the STOMP messaging layer [#648]
Browse files Browse the repository at this point in the history
 * Attach the web auth token to outbound websocket connections.
 * Generate a principal as a part of this process.
 * Created a new module for messaging in Angular.
  • Loading branch information
mcpierce committed Mar 14, 2021
1 parent 5476464 commit 43a792e
Show file tree
Hide file tree
Showing 31 changed files with 688 additions and 443 deletions.
8 changes: 8 additions & 0 deletions comixed-rest-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-messaging</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,6 @@

package org.comixedproject.authentication;

import static org.comixedproject.authentication.AuthenticationConstants.HEADER_STRING;
import static org.comixedproject.authentication.AuthenticationConstants.TOKEN_PREFIX;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
Expand All @@ -29,6 +26,7 @@
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang.StringUtils;
import org.comixedproject.utils.Utils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
Expand All @@ -46,6 +44,11 @@
@Component
@Log4j2
public class ComiXedAuthenticationFilter extends OncePerRequestFilter {
static final String HEADER_STRING = "Authorization";
static final String TOKEN_PREFIX = "Bearer ";
public static final String BASIC_PREFIX = "Basic ";
public static final String USER_PREFIX = "user";

@Autowired private ComiXedUserDetailsService userDetailsService;
@Autowired private JwtTokenUtil jwtTokenUtil;
@Autowired private Utils utils;
Expand All @@ -58,28 +61,28 @@ protected void doFilterInternal(
String username = null;
String password = null;
String authToken = null;
if ((header != null) && header.startsWith(TOKEN_PREFIX)) {
if (StringUtils.startsWith(header, TOKEN_PREFIX)) {
authToken = header.replace(TOKEN_PREFIX, "").trim();
try {
username = this.jwtTokenUtil.getEmailFromToken(authToken);
} catch (Exception error) {
log.error("Unable to extract username from auth token", error);
}
} else if ((header != null) && header.toLowerCase().startsWith("basic")) {

String base64Credentials = header.substring("Basic".length()).trim();
} else if (StringUtils.startsWith(header, BASIC_PREFIX)) {
String base64Credentials = header.substring(BASIC_PREFIX.length()).trim();
byte[] credDecoded = Base64.getDecoder().decode(base64Credentials);
String credentials = new String(credDecoded, StandardCharsets.UTF_8);

String[] userDetails = credentials.split(":", 2);
if (!userDetails[0].equals("user")) {
if (!userDetails[0].equals(USER_PREFIX)) {
username = userDetails[0];
password = this.utils.createHash(userDetails[1].getBytes());
}
} else {
this.logger.warn("couldn't find bearer string, will ignore the header");
}
if ((username != null) && (SecurityContextHolder.getContext().getAuthentication() == null)) {
if (!StringUtils.isEmpty(username)
&& (SecurityContextHolder.getContext().getAuthentication() == null)) {

UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@

package org.comixedproject.authentication;

import static org.comixedproject.authentication.AuthenticationConstants.ROLE_PREFIX;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;
Expand All @@ -46,6 +44,8 @@
@Component
@Log4j2
public class ComiXedAuthenticationProvider implements AuthenticationProvider {
private static final String ROLE_PREFIX = "ROLE_";

@Autowired private UserService userService;
@Autowired private Utils utils;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,6 @@

package org.comixedproject.authentication;

import static org.comixedproject.authentication.AuthenticationConstants.ROLE_PREFIX;
import static org.comixedproject.authentication.AuthenticationConstants.SIGNING_KEY;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
Expand All @@ -47,6 +44,9 @@
@Component
@Log4j2
public class JwtTokenUtil {
private static final String ROLE_PREFIX = "ROLE_";
private static final String SIGNING_KEY = "comixedproject";

@Autowired private UserService userService;

public String getEmailFromToken(String token) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.comixedproject.messaging;

import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
@Log4j2
public class ComiXedWebSocketAuthenticationConfig implements WebSocketMessageBrokerConfigurer {
@Autowired private ComiXedWebSocketChannelInterceptor webSocketChannelTokenInterceptor;

@Override
public void configureClientInboundChannel(final ChannelRegistration registration) {
log.trace("Configuring websocket inbound channel");
registration.interceptors(webSocketChannelTokenInterceptor);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* ComiXed - A digital comic book library management application.
* Copyright (C) 2021, The ComiXed Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses>
*/

package org.comixedproject.messaging;

import java.util.Collections;
import java.util.Optional;
import java.util.stream.Stream;
import lombok.extern.log4j.Log4j2;
import org.comixedproject.authentication.ComiXedUserDetailsService;
import org.comixedproject.authentication.JwtTokenUtil;
import org.comixedproject.messaging.model.StompPrincipal;
import org.springframework.beans.factory.annotation.Autowired;
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.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

/**
* <code>ComiXedWebSocketChannelInterceptor</code> provides a security interceptor to create the
* principal for the messaging layer.
*
* @author Darryl L. Pierce
*/
@Component
@Log4j2
public class ComiXedWebSocketChannelInterceptor implements ChannelInterceptor {
private static final String HEADER_STRING = "Authorization";
static final String TOKEN_PREFIX = "Bearer ";

@Autowired private ComiXedUserDetailsService userDetailsService;
@Autowired private JwtTokenUtil jwtTokenUtil;

@Override
public Message<?> preSend(final Message<?> message, final MessageChannel channel) {
StompHeaderAccessor accessor =
MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor != null) {
log.debug("Received STOMP command: {}", accessor.getCommand());
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
final Optional<String> optionalToken =
Optional.ofNullable(accessor.getNativeHeader(HEADER_STRING))
.orElse(Collections.emptyList()).stream()
.flatMap(Stream::ofNullable)
.findFirst();
optionalToken.ifPresent(
token -> {
log.trace("Auth token found");
createPrincipal(accessor, token);
});
}
}
return message;
}

void createPrincipal(final StompHeaderAccessor accessor, final String token) {
String username = null;
if (!StringUtils.isEmpty(token)) {
try {
username = this.jwtTokenUtil.getEmailFromToken(token);
} catch (Exception error) {
log.error("Unable to extract username from auth token", error);
}
}
if (!StringUtils.isEmpty(username)) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

if (Boolean.TRUE.equals(this.jwtTokenUtil.validateToken(token, userDetails))) {
log.debug("authenticated user " + username + ", setting security context");
accessor.setUser(new StompPrincipal(username));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,48 @@
* along with this program. If not, see <http://www.gnu.org/licenses>
*/

package org.comixedproject.state;
package org.comixedproject.messaging;

import lombok.extern.log4j.Log4j2;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.SimpMessageType;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.security.config.annotation.web.messaging.MessageSecurityMetadataSourceRegistry;
import org.springframework.security.config.annotation.web.socket.AbstractSecurityWebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

/**
* <code>ComiXedWebSocketConfig</code> provides the configuration for using websockets.
* <code>ComiXedWebSocketSecurityConfig</code> provides the configuration for using websockets.
*
* @author Darryl L. Pierce
*/
@Configuration
@EnableWebSocketMessageBroker
@Log4j2
public class ComiXedWebSocketConfig implements WebSocketMessageBrokerConfigurer {
public class ComiXedWebSocketSecurityConfig
extends AbstractSecurityWebSocketMessageBrokerConfigurer {

@Override
protected void configureInbound(final MessageSecurityMetadataSourceRegistry messages) {
messages
.nullDestMatcher()
.permitAll()
.simpTypeMatchers(
SimpMessageType.CONNECT, SimpMessageType.DISCONNECT, SimpMessageType.UNSUBSCRIBE)
.permitAll();
// TODO need to secure connects
}

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

@Override
public void configureMessageBroker(final MessageBrokerRegistry registry) {
log.trace("Configuring websocket message broker");
registry.enableSimpleBroker("/topic");
registry.enableSimpleBroker("/topic", "/queue");
registry.setApplicationDestinationPrefixes("/comixed");
registry.setUserDestinationPrefix("/secured/user");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* ComiXed - A digital comic book library management application.
* Copyright (C) 2018, The ComiXed Project
* Copyright (C) 2021, The ComiXed Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
Expand All @@ -16,21 +16,25 @@
* along with this program. If not, see <http://www.gnu.org/licenses>
*/

package org.comixedproject.authentication;
package org.comixedproject.messaging.model;

import java.security.Principal;
import javax.security.auth.Subject;
import lombok.AllArgsConstructor;
import lombok.Getter;

/**
* <code>AuthenticationConstants</code> is a placeholder for constant values used in the
* authentication code.
* <code>StompPrincipal</code> provides an instance of {@link Principal} for use in messaging.
*
* @author Darryl L. Pierce
*/
public final class AuthenticationConstants {
public static final String ROLE_PREFIX = "ROLE_";
public static final String SIGNING_KEY = "comixedproject";
public static final String HEADER_STRING = "Authorization";
public static final String TOKEN_PREFIX = "Bearer ";
@AllArgsConstructor
public class StompPrincipal implements Principal {
@Getter private String name;

private AuthenticationConstants() {
// prevent it from being instantiates
@Override
public boolean implies(final Subject subject) {
return subject.getPrincipals().stream()
.anyMatch(principal -> this.name.equals(principal.getName()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@

import static junit.framework.TestCase.assertNotNull;
import static junit.framework.TestCase.assertSame;
import static org.comixedproject.authentication.AuthenticationConstants.HEADER_STRING;
import static org.comixedproject.authentication.AuthenticationConstants.TOKEN_PREFIX;
import static org.comixedproject.authentication.ComiXedAuthenticationFilter.*;

import java.io.IOException;
import java.util.Base64;
Expand All @@ -46,8 +45,8 @@ public class ComiXedAuthenticationFilterTest {
private static final String TEST_PASSWORD = "test password";
private static final String TEST_AUTH_TOKEN =
Base64.getEncoder().encodeToString((TEST_EMAIL + ":" + TEST_PASSWORD).getBytes());
private static final String TEST_TOKEN_AUTH_TOKEN = TOKEN_PREFIX + " " + TEST_AUTH_TOKEN;
private static final String TEST_BASIC_AUTH_HEADER = "basic " + TEST_AUTH_TOKEN;
private static final String TEST_TOKEN_AUTH_TOKEN = TOKEN_PREFIX + TEST_AUTH_TOKEN;
private static final String TEST_BASIC_AUTH_HEADER = BASIC_PREFIX + TEST_AUTH_TOKEN;

@InjectMocks private ComiXedAuthenticationFilter authenticationFilter;
@Mock private ComiXedUserDetailsService userDetailsService;
Expand All @@ -67,7 +66,6 @@ public void setUp() {
Mockito.when(userDetailsService.loadUserByUsername(Mockito.anyString()))
.thenReturn(userDetails);
Mockito.when(userDetails.getPassword()).thenReturn(TEST_PASSWORD);
Mockito.when(utils.createHash(Mockito.any(byte[].class))).thenReturn(TEST_PASSWORD);

SecurityContextHolder.setContext(securityContext);
}
Expand Down

0 comments on commit 43a792e

Please sign in to comment.