Skip to content

Commit

Permalink
Support multiple installations, provide JWT via environment
Browse files Browse the repository at this point in the history
  • Loading branch information
CatoTH committed Mar 16, 2024
1 parent 67fb6d9 commit 902ee20
Show file tree
Hide file tree
Showing 15 changed files with 162 additions and 73 deletions.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ target/
!**/src/main/**/target/
!**/src/test/**/target/

src/main/resources/public.key
src/main/resources/static/stomp.umd.min.js
node_modules/

Expand Down
28 changes: 18 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,30 +21,38 @@ Users are connecting to the Live Server via Websocket/STOMP when using an intera
- When subscribing to the speech admin topic, the SPEECH_ADMIN role is checked in the JWT.
- SECURITY DISCLAIMER: the expiry date of the token is currently only checked when connecting. As long as the session is open, no expiry mechanism is in place, so revoking a user's access only has effect once that user reconnects.

## ID Mapping

The IDs referred to by this service can be found at the following places in the central Antragsgrün system:

- Installation ID: This is the ID specified as `live.installationId` in `config.json`. One Installation ID can hold either a single- or multi-site installation.
- Site: The subdomain used by a multi-site installation (field `subdomain` in the `site` database table). For single-site installations, this is typically `std`.
- Consultation: The URL path component identifying the specific consultation within a site (field `urlPath` in the `consultation` database table).
- User ID: The numerical ID of the user (field `id` in the `user` database table).

## RabbitMQ Setup

The central Antragsgrün system publishes all its messages to one central exchange (by default: `antragsgruen-exchange`). Messages to all subdomains and consultations within a subdomain are published through that exchange, but are classified by a routing key pattern.

The following routing key patterns are fixed, while its associated queues can be configured:
- `user.[site].[consultation].[userid]`, e.g. `user.stdparteitag.std-parteitag.1` contains messages directed to one particular user, by default being bound to the queue `antragsgruen-user-queue` and using the [MQUserEvent](src/main/java/de/antragsgruen/live/rabbitmq/dto/MQUserEvent.java)-DTO for deserialization.
- `speech.[site].[consultation]`, e.g. `speech.stdparteitag.std-parteitag` contains messages updating a speech queue, by default being bound to the queue `antragsgruen-speech-queue` and using the [MQSpeechQueue](src/main/java/de/antragsgruen/live/rabbitmq/dto/MQSpeechQueue.java)-DTO for deserialization. All users in the consultation receive this event, but in a personalized version.
- `user.[installationid].[site].[consultation].[userid]`, e.g. `user.localdev.stdparteitag.std-parteitag.1` contains messages directed to one particular user, by default being bound to the queue `antragsgruen-user-queue` and using the [MQUserEvent](src/main/java/de/antragsgruen/live/rabbitmq/dto/MQUserEvent.java)-DTO for deserialization.
- `speech.[installationid].[site].[consultation]`, e.g. `speech.localdev.stdparteitag.std-parteitag` contains messages updating a speech queue, by default being bound to the queue `antragsgruen-speech-queue` and using the [MQSpeechQueue](src/main/java/de/antragsgruen/live/rabbitmq/dto/MQSpeechQueue.java)-DTO for deserialization. All users in the consultation receive this event, but in a personalized version.

In case messages cannot be processed by this live server, they are rejected and, through the `antragsgruen-exchange-dead`, end up in the dead letter queues `antragsgruen-queue-speech-dead` and `antragsgruen-queue-user-dead`.

## Exposed Websocket STOMP Topics

- `/user/[subdomain]/[consultation]/[userid]/speech`
- `/admin/[subdomain]/[consultation]/[userid]/speech`
- `/topic/[subdomain]/[consultation]/[...]` (currently not used)
- `/user/[installationid]/[subdomain]/[consultation]/[userid]/speech`
- `/admin/[installationid]/[subdomain]/[consultation]/[userid]/speech`
- `/topic/[installationid]/[subdomain]/[consultation]/[...]` (currently not used)


## Installing, Running, Configuration

### Prerequisites

Before building the app, two steps have to be manually performed:
- Creating a public/private RSA key for the JWT signing. This app only needs the public key, located at `src/main/resources/public.key`. If you are just testing, the keys from the test suite can be used (`cp src/test/resources/jwt-test-public.key src/main/resources/public.key`).
- Creating a public/private RSA key for the JWT signing. This app only needs the public key, passed into the application along with the installation ID as an environment variable. If you are just testing, the keys from the test suite can be used.
- Installing Stomp.JS. This can be done by calling `npm ci`. After this step, the file `src/main/resources/static/stomp.umd.min.js` should exist.

### Running
Expand All @@ -59,7 +67,7 @@ Hint: this is only meant for local development. On production, you want to secur

### Configuration via Environment Variables

The following aspects can be configured through environment variables, expecially valuable when deploying it via docker (compose):
The following aspects can be configured through environment variables, especially valuable when deploying it via docker (compose):

| Environment Variable Name | Default Value | Explanation |
| ------------------------- | ---------------- | ------------------------------------------------------------ |
Expand Down Expand Up @@ -98,7 +106,7 @@ Compiling and running:

### Running with Docker (JRE)

A dummy docker-compose.yml is provided that builds and runs the application. Note that the the file `src/main/resources/public.key` mentioned in "Prerequisites" still needs to be created before building the docker images.
A dummy docker-compose.yml is provided that builds and runs the application.

```shell
docker compose -f docker-compose.jdk.yml build
Expand All @@ -107,10 +115,10 @@ docker compose -f docker-compose.jdk.yml up

## Testing

### Running spotbugs
### Running spotbugs && checkstyle

```shell
./mvnw compile && ./mvnw spotbugs:check
./mvnw compile && ./mvnw spotbugs:check && ./mvnw checkstyle:check
```

### Running the integration tests
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package de.antragsgruen.live.multisite;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class AntragsgruenInstallation {
private final String installationId;

private final AntragsgruenJwtDecoder jwtDecoder;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package de.antragsgruen.live.multisite;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;

import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.util.HashMap;

@Service
@RequiredArgsConstructor
@Slf4j
public class AntragsgruenInstallationProvider {
private final Environment environment;
private final HashMap<String, AntragsgruenInstallation> installations = new HashMap<>();

public AntragsgruenInstallation getInstallation(String installationId) throws InstallationNotFoundException {
if (!installations.containsKey(installationId)) {
throw new InstallationNotFoundException("Invalid installation id: " + installationId);
}

return installations.get(installationId);
}

@PostConstruct
public void onInit() {
String installationId;
String publicKey;
int count = 0;
do {
installationId = environment.getProperty("antragsgruen.installations." + count + ".id");
publicKey = environment.getProperty("antragsgruen.installations." + count + ".public-key");

if (installationId == null && publicKey == null) {
return;
}
if (installationId == null || publicKey == null || installationId.isEmpty() || publicKey.isEmpty()) {
throw new RuntimeException("ANTRAGSGRUEN_INSTALLATION_" + count + "_* is inconsistently filled");
}

try {
this.installations.put(installationId, AntragsgruenInstallationProvider.createInstallation(installationId, publicKey));
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new RuntimeException(e);
}
log.info("Found installation ID: " + installationId);

count++;
} while (true);
}

private static AntragsgruenInstallation createInstallation(String installationId, String publicKey)
throws NoSuchAlgorithmException, InvalidKeySpecException {
return new AntragsgruenInstallation(
installationId,
AntragsgruenJwtDecoder.create(publicKey)
);
}
}
Original file line number Diff line number Diff line change
@@ -1,50 +1,37 @@
package de.antragsgruen.live.websocket;
package de.antragsgruen.live.multisite;

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import lombok.RequiredArgsConstructor;
import org.apache.tomcat.util.codec.binary.Base64;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Service;
import org.springframework.util.StreamUtils;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.EncodedKeySpec;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;

@Service
@Slf4j
@RequiredArgsConstructor
public class AntragsgruenJwtDecoder {
@Value("classpath:${antragsgruen.jwt.key.public}")
private Resource publicKeyFilename;
private final JwtDecoder jwtDecoder;

private JwtDecoder jwtDecoder;

@PostConstruct
public void loadPublicKey() throws NoSuchAlgorithmException, InvalidKeySpecException, IOException {
InputStream is = this.publicKeyFilename.getInputStream();
String publicKeyString = StreamUtils
.copyToString(is, StandardCharsets.UTF_8)
public static AntragsgruenJwtDecoder create(String publicKey) throws NoSuchAlgorithmException, InvalidKeySpecException {
String publicKeyString = publicKey
.replace("-----BEGIN PUBLIC KEY-----", "")
.replaceAll(System.lineSeparator(), "")
.replace("-----END PUBLIC KEY-----", "");

byte[] keyBytes = Base64.decodeBase64(publicKeyString);
EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory kf = KeyFactory.getInstance("RSA");
RSAPublicKey publicKey = (RSAPublicKey) kf.generatePublic(keySpec);
RSAPublicKey rsaPublicKey = (RSAPublicKey) kf.generatePublic(keySpec);

this.jwtDecoder = NimbusJwtDecoder.withPublicKey(publicKey).build();
return new AntragsgruenJwtDecoder(
NimbusJwtDecoder.withPublicKey(rsaPublicKey).build()
);
}

public JwtAuthenticationToken getJwtAuthToken(String token) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package de.antragsgruen.live.multisite;

public class InstallationNotFoundException extends Exception {
public InstallationNotFoundException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@NonNullApi
@NonNullFields
package de.antragsgruen.live.multisite;

import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package de.antragsgruen.live.websocket;

import de.antragsgruen.live.multisite.AntragsgruenInstallation;
import de.antragsgruen.live.multisite.AntragsgruenInstallationProvider;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -20,8 +22,8 @@
@RequiredArgsConstructor
@Slf4j
public final class WebsocketChannelInterceptor implements ChannelInterceptor {
@NonNull private AntragsgruenJwtDecoder jwtDecoder;
@NonNull private TopicPermissionChecker topicPermissionChecker;
@NonNull private final TopicPermissionChecker topicPermissionChecker;
@NonNull private final AntragsgruenInstallationProvider installationProvider;

@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) throws MessagingException {
Expand All @@ -48,18 +50,37 @@ public Message<?> postReceive(Message<?> message, MessageChannel channel) {
return message;
}

private void onConnect(Message<?> message, StompHeaderAccessor headerAccessor) throws MessagingException {
private AntragsgruenInstallation onConnectGetInstallation(StompHeaderAccessor headerAccessor) throws Exception {
List<String> jwtHeaders = headerAccessor.getNativeHeader("installation");
jwtHeaders = Optional.ofNullable(jwtHeaders).orElse(new ArrayList<>());

for (String head: jwtHeaders) {
return this.installationProvider.getInstallation(head);
}

throw new Exception("No installation header found");
}

private JwtAuthenticationToken onConnectGetAuthenticatedToken(AntragsgruenInstallation installation, StompHeaderAccessor headerAccessor) throws Exception {
List<String> jwtHeaders = headerAccessor.getNativeHeader("jwt");
jwtHeaders = Optional.ofNullable(jwtHeaders).orElse(new ArrayList<>());

for (String head: jwtHeaders) {
try {
JwtAuthenticationToken token = this.jwtDecoder.getJwtAuthToken(head);
headerAccessor.setUser(token);
log.info("Connected Websocket: " + token.getName());
} catch (Exception e) {
throw new MessagingException("Could not authenticate JWT: " + e.getMessage());
}
return installation.getJwtDecoder().getJwtAuthToken(head);
}

throw new Exception("No jwt header found");
}

private void onConnect(Message<?> message, StompHeaderAccessor headerAccessor) throws MessagingException {
try {
AntragsgruenInstallation installation = this.onConnectGetInstallation(headerAccessor);
JwtAuthenticationToken token = this.onConnectGetAuthenticatedToken(installation, headerAccessor);
headerAccessor.setUser(token);
log.info("Connected Websocket: " + token.getName());
} catch (Exception e) {
log.warn("Could not authenticate JWT: " + e.getMessage());
throw new MessagingException("Could not authenticate JWT: " + e.getMessage());
}
}

Expand Down
6 changes: 5 additions & 1 deletion src/main/resources/application-test.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# For testing, a bundled JWT key set is used
antragsgruen:
jwt.key:
public: jwt-test-public.key
private: jwt-test-private.key
rabbitmq:
exchange:
Expand All @@ -12,6 +11,11 @@ antragsgruen:
user_dead: ${RABBITMQ_QUEUE_SPEECH:antragsgruen-queue-test-user-dead}
speech: ${RABBITMQ_QUEUE_SPEECH:antragsgruen-queue-test-speech}
speech_dead: ${RABBITMQ_QUEUE_SPEECH:antragsgruen-queue-test-speech-dead}
installations:
-
id: test
public-key: MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArGCspSkkS4juW8XfmCVbkv+CRd2MUmEPJIqwWZd33E27FIRMDOqHp44hyxFTQoUy/pCUgVSgAtfuj1Cz1Vzj3dQWKjbWPKIzjiU7tZ/rok+PjJQsYV7m3wCizOk/2UNTnn9v/6aXmLZTetEEEFxHvVDF63/KJLu0abwW4PjATSNC4lAO0oGgPXbwkovIUkP3Vdj+kAhN72UC+lYafhAWY8gwpEPA0cdBBIl15JQZ/9gdoWbsmOMv8/6hv/kMHKWtJ52HHnMLshXEOKU7H0gunyh7MiJ0THeuJnlrQAYVyl4Lho2sS4ZWIKaDWmKDujyMfSI6CujBy9koR4KuXMp+PHemOWmMSAiirCZXf7t5r98fuPmSrZLp6A8L0612LEIjcrLQWqECn/NZ9823/0lMDBiJdyJBcNBe/QUSXEc2bTf/1vScbCBKcNTmlJykgZv00B0et01rscpThWcN4xneceUNXVla1X53bYXBG/8ZgiBzq7KqsTo285RuTG+lC82UPtove+R7wh4zHR4/gc8kvsHDkiAgCJr0FjQZyGcrDpEA2w8dTgHOkIOeGTh8r9AHJKdStyKQJy524RDtvopa7v8iibOM7FMCfUiJCKcGk0thdFqQ5tMEqk6KAVT57eYCBGYphpXqzsfUDRVVxR4pdw3UzeS6rfB6FE903c8lOncCAwEAAQ==


#logging:
# level:
Expand Down
1 change: 0 additions & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ spring:

# The JWT public key's location relative to src/main/resources.
antragsgruen:
jwt.key.public: public.key
ws:
origins: ${ANTRAGSGRUEN_WS_ORIGINS:http://localhost}
heartbeat: 10000 # milliseconds. nginx proxy has a default timeout of 60s, so 10s for heartbeat should be safe.
Expand Down
3 changes: 0 additions & 3 deletions src/main/resources/resource-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@
},
{
"pattern": "^stomp\\.umd\\.min\\.js$"
},
{
"pattern": "^public\\.key$"
}
]
}
4 changes: 2 additions & 2 deletions src/test/java/de/antragsgruen/live/SpeechAdminTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class SpeechAdminTests {
public void tryToConnectWithoutAdminRole() {
StompTestConnection stompConnection = testHelper.getStompConnection(port);

stompConnection.connectAndWait("site", "con", "login-1", getRoles("WRONG_ROLE"));
stompConnection.connectAndWait("test", "site", "con", "login-1", getRoles("WRONG_ROLE"));
FutureTask<String> onError = stompConnection.subscribeAndExpectError("/admin/site/con/login-1/speech");
try {
String message = onError.get(5, TimeUnit.SECONDS);
Expand All @@ -45,7 +45,7 @@ public void tryToConnectWithoutAdminRole() {
public void sendAndConvertRabbitMQMessage_speech1() throws IOException {
StompTestConnection stompConnection = testHelper.getStompConnection(port);

stompConnection.connectAndWait("site", "con", "login-1", getRoles("ROLE_SPEECH_ADMIN"));
stompConnection.connectAndWait("test", "site", "con", "login-1", getRoles("ROLE_SPEECH_ADMIN"));
stompConnection.subscribe("/admin/site/con/login-1/speech");

testHelper.sendFileContentToRabbitMQ("sendAndConvertRabbitMQMessage_speech1_in.json", "speech.site.con");
Expand Down
Loading

0 comments on commit 902ee20

Please sign in to comment.