Skip to content

Commit

Permalink
Finish initial support for multiple installations
Browse files Browse the repository at this point in the history
  • Loading branch information
CatoTH committed Mar 16, 2024
1 parent 26ee182 commit 8a3834d
Show file tree
Hide file tree
Showing 16 changed files with 127 additions and 83 deletions.
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ Users are connecting to the Live Server via Websocket/STOMP when using an intera

- The central Antragsgrün system authenticates users through traditional means (cookie-based sessions generated during username/password- or SAML-based login).
- It creates a JWT, signed using a private key (RS256), containing information about:
- The installation ID, as the Issuer of the token.
- The ID of the user as Subject of the token. If the user is logged in, it has the shape of `login-123`. If not, a session-token like `anonymous-qVnRU4NFICsBGtnWfi0dzGgWcKGlQoiN` will be used.
- If the user has specific admin privileges (like to administer speech queues), a role is added to the payload. Currently, only ROLE_SPEECH_ADMIN is supported.
- The site and the consultation the token is valid for, as the payload of the token.
- The site and consultation the token is valid for, as the payload of the token.
- We web browser connects to the websocket / STOMP server of this Live Server. The authentication and authorization is checked at the following places:
- When connecting, the validity of the JWT is checked on a protocol level (as part of [WebsocketChannelInterceptor](src/main/java/de/antragsgruen/live/websocket/WebsocketChannelInterceptor.java)).
- The site and consultation association is checked when subscribing to topics - the site subdomain and consultation path has to be in the topic name and equal to information provided in the JWT.
- The installation, site and consultation association is checked when subscribing to topics - the installation, site subdomain and consultation path has to be in the topic name and equal to information provided in the JWT.
- 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.

Expand Down Expand Up @@ -67,6 +68,14 @@ Hint: this is only meant for local development. On production, you want to secur

### Configuration via Environment Variables

One or multiple installations can be configured through environment variables. Each installation needs to have an ID and a public key. Mind that the numbering needs to be consecutive, starting with zero.

| Environment Variable Name | Explanation |
| --------------------------------------- | ------------------------------------------------------------ |
| ANTRAGSGRUEN_INSTALLATIONS_0_ID | Unique ID of the Antragsgrün installation |
| ANTRAGSGRUEN_INSTALLATIONS_0_PUBLIC_KEY | Public RSA Key. Refer to the [README in the Central System](https://github.com/CatoTH/antragsgruen?tab=readme-ov-file#jwt-key-signing) on how to generate one. |
| ... | ... |

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
12 changes: 7 additions & 5 deletions src/main/java/de/antragsgruen/live/LiveHandlerBase.java
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
package de.antragsgruen.live;

import de.antragsgruen.live.multisite.ConsultationScope;
import de.antragsgruen.live.websocket.TopicPermissionChecker;
import org.springframework.messaging.simp.user.SimpUserRegistry;

public abstract class LiveHandlerBase {
protected String[] findRelevantUserIds(SimpUserRegistry userRegistry, String subdomain, String consultation, String role, String module) {
// 1) First find all subscriptions with a destination matching /[role]/[site]/[consultation]/[userid]/[module]
// e.g. /user/site/consultation/login-1/speech
protected String[] findRelevantUserIds(SimpUserRegistry userRegistry, ConsultationScope scope, String role, String module) {
// 1) First find all subscriptions with a destination matching /[role]/[installation]/[site]/[consultation]/[userid]/[module]
// e.g. /user/installation/site/consultation/login-1/speech
// 2) Extract the User ID from the destinations
// 3) Return unique User IDs (we only need to send messages to each user once)
return userRegistry.findSubscriptions(subscription -> {
String[] parts = subscription.getDestination().split("/");
return parts.length == TopicPermissionChecker.USER_PARTS_LENGTH
&& role.equals(parts[TopicPermissionChecker.USER_PART_ROLE])
&& module.equals(parts[TopicPermissionChecker.USER_PART_MODULE])
&& subdomain.equals(parts[TopicPermissionChecker.USER_PART_SITE])
&& consultation.equals(parts[TopicPermissionChecker.USER_PART_CONSULTATION]);
&& scope.installation().equals(parts[TopicPermissionChecker.USER_PART_INSTALLATION])
&& scope.site().equals(parts[TopicPermissionChecker.USER_PART_SITE])
&& scope.consultation().equals(parts[TopicPermissionChecker.USER_PART_CONSULTATION]);
}).stream().map(subscription -> {
String[] parts = subscription.getDestination().split("/");
return parts[TopicPermissionChecker.USER_PART_USER];
Expand Down
7 changes: 4 additions & 3 deletions src/main/java/de/antragsgruen/live/SpeechAdminHandler.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package de.antragsgruen.live;

import de.antragsgruen.live.mapper.SpeechAdminMapper;
import de.antragsgruen.live.multisite.ConsultationScope;
import de.antragsgruen.live.rabbitmq.dto.MQSpeechQueue;
import de.antragsgruen.live.websocket.Sender;
import de.antragsgruen.live.websocket.dto.WSSpeechQueueAdmin;
Expand All @@ -17,15 +18,15 @@ public final class SpeechAdminHandler extends LiveHandlerBase {
private @NonNull Sender sender;
private @NonNull SimpUserRegistry userRegistry;

public void onSpeechEvent(String subdomain, String consultation, MQSpeechQueue mqQueue) {
String[] users = findRelevantUserIds(userRegistry, subdomain, consultation, Sender.ROLE_ADMIN, Sender.USER_CHANNEL_SPEECH);
public void onSpeechEvent(ConsultationScope scope, MQSpeechQueue mqQueue) {
String[] users = findRelevantUserIds(userRegistry, scope, Sender.ROLE_ADMIN, Sender.USER_CHANNEL_SPEECH);

log.info("Sending speech admin event to " + users.length + " (out of " + userRegistry.getUserCount() + ") user(s)");

for (String userId : users) {
WSSpeechQueueAdmin wsQueue = SpeechAdminMapper.convertQueue(mqQueue);

sender.sendToUser(subdomain, consultation, userId, Sender.ROLE_ADMIN, Sender.USER_CHANNEL_SPEECH, wsQueue);
sender.sendToUser(scope, userId, Sender.ROLE_ADMIN, Sender.USER_CHANNEL_SPEECH, wsQueue);
}
}
}
7 changes: 4 additions & 3 deletions src/main/java/de/antragsgruen/live/SpeechUserHandler.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package de.antragsgruen.live;

import de.antragsgruen.live.mapper.SpeechUserMapper;
import de.antragsgruen.live.multisite.ConsultationScope;
import de.antragsgruen.live.rabbitmq.dto.MQSpeechQueue;
import de.antragsgruen.live.websocket.Sender;
import de.antragsgruen.live.websocket.dto.WSSpeechQueueUser;
Expand All @@ -17,15 +18,15 @@ public final class SpeechUserHandler extends LiveHandlerBase {
private @NonNull Sender sender;
private @NonNull SimpUserRegistry userRegistry;

public void onSpeechEvent(String subdomain, String consultation, MQSpeechQueue mqQueue) {
String[] users = findRelevantUserIds(userRegistry, subdomain, consultation, Sender.ROLE_USER, Sender.USER_CHANNEL_SPEECH);
public void onSpeechEvent(ConsultationScope scope, MQSpeechQueue mqQueue) {
String[] users = findRelevantUserIds(userRegistry, scope, Sender.ROLE_USER, Sender.USER_CHANNEL_SPEECH);

log.info("Sending speech user event to " + users.length + " (out of " + userRegistry.getUserCount() + ") user(s)");

for (String userId : users) {
WSSpeechQueueUser wsQueue = SpeechUserMapper.convertQueue(mqQueue, userId);

sender.sendToUser(subdomain, consultation, userId, Sender.ROLE_USER, Sender.USER_CHANNEL_SPEECH, wsQueue);
sender.sendToUser(scope, userId, Sender.ROLE_USER, Sender.USER_CHANNEL_SPEECH, wsQueue);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package de.antragsgruen.live.multisite;

public record ConsultationScope(
String installation,
String site,
String consultation
) {
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package de.antragsgruen.live.rabbitmq;

import de.antragsgruen.live.SpeechAdminHandler;
import de.antragsgruen.live.multisite.ConsultationScope;
import de.antragsgruen.live.rabbitmq.dto.MQSpeechQueue;
import de.antragsgruen.live.SpeechUserHandler;
import lombok.NonNull;
Expand All @@ -14,22 +15,29 @@
@Service
@RequiredArgsConstructor
public final class SpeechMessageReceiver {
private static final int RK_PARTS_LENGTH = 3;
private static final int RK_PARTS_TOPIC = 0;
private static final int RK_PARTS_SITE = 1;
private static final int RK_PARTS_CONSULTATION = 2;
private static final int RK_PARTS_LENGTH = 4;
private static final int RK_PART_TOPIC = 0;
private static final int RK_PART_INSTALLATION = 1;
private static final int RK_PART_SITE = 2;
private static final int RK_PART_CONSULTATION = 3;

@NonNull private SpeechUserHandler speechUserHandler;
@NonNull private SpeechAdminHandler speechAdminHandler;

@RabbitListener(queues = {"${antragsgruen.rabbitmq.queue.speech}"})
public void receiveMessage(MQSpeechQueue event, @Header(AmqpHeaders.RECEIVED_ROUTING_KEY) String routingKey) {
String[] routingKeyParts = routingKey.split("\\.");
if (routingKeyParts.length != RK_PARTS_LENGTH || !"speech".equals(routingKeyParts[RK_PARTS_TOPIC])) {
if (routingKeyParts.length != RK_PARTS_LENGTH || !"speech".equals(routingKeyParts[RK_PART_TOPIC])) {
throw new AmqpRejectAndDontRequeueException("Invalid routing key: " + routingKey);
}

speechUserHandler.onSpeechEvent(routingKeyParts[RK_PARTS_SITE], routingKeyParts[RK_PARTS_CONSULTATION], event);
speechAdminHandler.onSpeechEvent(routingKeyParts[RK_PARTS_SITE], routingKeyParts[RK_PARTS_CONSULTATION], event);
ConsultationScope scope = new ConsultationScope(
routingKeyParts[RK_PART_INSTALLATION],
routingKeyParts[RK_PART_SITE],
routingKeyParts[RK_PART_CONSULTATION]
);

speechUserHandler.onSpeechEvent(scope, event);
speechAdminHandler.onSpeechEvent(scope, event);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package de.antragsgruen.live.rabbitmq;

import de.antragsgruen.live.multisite.ConsultationScope;
import de.antragsgruen.live.rabbitmq.dto.MQUserEvent;
import de.antragsgruen.live.websocket.Sender;
import lombok.NonNull;
Expand All @@ -15,10 +16,11 @@
@RequiredArgsConstructor
@Slf4j
public final class UserMessageReceiver {
private static final int RK_PARTS_LENGTH = 4;
private static final int RK_PART_SITE = 1;
private static final int RK_PART_CONSULTATION = 2;
private static final int RK_PART_USER = 3;
private static final int RK_PARTS_LENGTH = 5;
private static final int RK_PART_INSTALLATION = 1;
private static final int RK_PART_SITE = 2;
private static final int RK_PART_CONSULTATION = 3;
private static final int RK_PART_USER = 4;

@NonNull private Sender sender;

Expand All @@ -29,15 +31,14 @@ public void receiveMessage(MQUserEvent event, @Header(AmqpHeaders.RECEIVED_ROUTI
throw new AmqpRejectAndDontRequeueException("Invalid routing key: " + routingKey);
}

log.warn("Received user message: " + routingKey + " => " + event.username());

sender.sendToUser(
ConsultationScope scope = new ConsultationScope(
routingKeyParts[RK_PART_INSTALLATION],
routingKeyParts[RK_PART_SITE],
routingKeyParts[RK_PART_CONSULTATION],
routingKeyParts[RK_PART_USER],
Sender.ROLE_USER,
Sender.USER_CHANNEL_DEFAULT,
event.username()
routingKeyParts[RK_PART_CONSULTATION]
);

log.warn("Received user message: " + routingKey + " => " + event.username());

sender.sendToUser(scope, routingKeyParts[RK_PART_USER], Sender.ROLE_USER, Sender.USER_CHANNEL_DEFAULT, event.username());
}
}
9 changes: 5 additions & 4 deletions src/main/java/de/antragsgruen/live/websocket/Sender.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package de.antragsgruen.live.websocket;

import de.antragsgruen.live.multisite.ConsultationScope;
import de.antragsgruen.live.websocket.dto.WSGreeting;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
Expand All @@ -18,16 +19,16 @@ public class Sender {
public static final String ROLE_USER = "user";
public static final String ROLE_ADMIN = "admin";

public void sendToUser(String site, String consultation, String user, String role, String channel, Object message) {
String target = "/" + role + "/" + site + "/" + consultation + "/" + user + "/" + channel;
public void sendToUser(ConsultationScope scope, String user, String role, String channel, Object message) {
String target = "/" + role + "/" + scope.installation() + "/" + scope.site() + "/" + scope.consultation() + "/" + user + "/" + channel;

log.debug("Sending to: " + target + " / " + message.toString());

this.messagingTemplate.convertAndSend(target, message);
}

public void sendToConsultation(String site, String consultation, String message) {
String target = "/topic/" + site + "/" + consultation + "/update";
public void sendToConsultation(ConsultationScope scope, String message) {
String target = "/topic/" + scope.installation() + "/" + scope.site() + "/" + scope.consultation() + "/update";
WSGreeting object = new WSGreeting(message);
this.messagingTemplate.convertAndSend(target, object);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package de.antragsgruen.live.websocket;

import de.antragsgruen.live.multisite.ConsultationScope;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.Nullable;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
Expand All @@ -13,23 +14,25 @@
public class TopicPermissionChecker {
private static final String ROLE_SPEECH_ADMIN = "ROLE_SPEECH_ADMIN";

public static final int USER_PARTS_LENGTH = 6;
public static final int USER_PARTS_LENGTH = 7;
public static final int USER_PART_ROLE = 1;
public static final int USER_PART_SITE = 2;
public static final int USER_PART_CONSULTATION = 3;
public static final int USER_PART_USER = 4;
public static final int USER_PART_MODULE = 5;
public static final int USER_PART_INSTALLATION = 2;
public static final int USER_PART_SITE = 3;
public static final int USER_PART_CONSULTATION = 4;
public static final int USER_PART_USER = 5;
public static final int USER_PART_MODULE = 6;

public static final int TOPIC_PARTS_LENGTH = 5;
public static final int TOPIC_PARTS_LENGTH = 6;
public static final int TOPIC_PART_TOPIC = 1;
public static final int TOPIC_PART_SITE = 2;
public static final int TOPIC_PART_CONSULTATION = 3;
public static final int TOPIC_PART_INSTALLATION = 2;
public static final int TOPIC_PART_SITE = 3;
public static final int TOPIC_PART_CONSULTATION = 4;

/**
* Supported destination patterns:
* - /user/[subdomain]/[consultation]/[userid]/speech
* - /admin/[subdomain]/[consultation]/[userid]/speech
* - /topic/[subdomain]/[consultation]/[...]
* - /user/[installation]/[subdomain]/[consultation]/[userid]/speech
* - /admin/[installation]/[subdomain]/[consultation]/[userid]/speech
* - /topic/[installation]/[subdomain]/[consultation]/[...]
*/
public boolean canSubscribeToDestination(JwtAuthenticationToken jwtToken, @Nullable String destination) {
if (destination == null) {
Expand All @@ -41,36 +44,46 @@ public boolean canSubscribeToDestination(JwtAuthenticationToken jwtToken, @Nulla
return false;
}

ConsultationScope scope = null;
boolean additionalPermissionsPassed = true;

if ("topic".equals(pathParts[TOPIC_PART_TOPIC]) && pathParts.length == TOPIC_PARTS_LENGTH) {
return jwtIsForCorrectSiteAndConsultation(jwtToken, pathParts[TOPIC_PART_SITE], pathParts[TOPIC_PART_CONSULTATION]);
scope = new ConsultationScope(pathParts[TOPIC_PART_INSTALLATION], pathParts[TOPIC_PART_SITE], pathParts[TOPIC_PART_CONSULTATION]);
}
if (Sender.ROLE_USER.equals(pathParts[USER_PART_ROLE]) && pathParts.length == USER_PARTS_LENGTH) {
return jwtIsForCorrectSiteAndConsultation(jwtToken, pathParts[USER_PART_SITE], pathParts[USER_PART_CONSULTATION])
&& pathParts[USER_PART_USER].equals(jwtToken.getName());
scope = new ConsultationScope(pathParts[USER_PART_INSTALLATION], pathParts[USER_PART_SITE], pathParts[USER_PART_CONSULTATION]);
additionalPermissionsPassed = pathParts[USER_PART_USER].equals(jwtToken.getName());
}
if (Sender.ROLE_ADMIN.equals(pathParts[USER_PART_ROLE]) && pathParts.length == USER_PARTS_LENGTH) {
return jwtIsForCorrectSiteAndConsultation(jwtToken, pathParts[USER_PART_SITE], pathParts[USER_PART_CONSULTATION])
&& jwtHasRoleForTopic(jwtToken, pathParts[USER_PART_MODULE])
scope = new ConsultationScope(pathParts[USER_PART_INSTALLATION], pathParts[USER_PART_SITE], pathParts[USER_PART_CONSULTATION]);
additionalPermissionsPassed = jwtHasRoleForTopic(jwtToken, pathParts[USER_PART_MODULE])
&& pathParts[USER_PART_USER].equals(jwtToken.getName());
}

return false;
return (scope != null && jwtIsForCorrectConsultation(jwtToken, scope) && additionalPermissionsPassed);
}

private boolean jwtIsForCorrectSiteAndConsultation(JwtAuthenticationToken jwtToken, String site, String consultation) {
private boolean jwtIsForCorrectConsultation(JwtAuthenticationToken jwtToken, ConsultationScope scope) {
Object payload = jwtToken.getTokenAttributes().get("payload");
if (!(payload instanceof Map<?, ?> payloadMap)) {
log.warn("No payload found");
return false;
}

if (!payloadMap.containsKey("site") || payloadMap.get("site") == null || !payloadMap.get("site").equals(site)) {
log.warn("Incorrect site provided: " + site, payloadMap);
String issuer = jwtToken.getToken().getClaim("iss");
if (issuer == null || issuer.isEmpty() || !issuer.equals(scope.installation())) {
log.warn("Incorrect installation provided: " + scope.installation(), payloadMap);
return false;
}

if (!payloadMap.containsKey("site") || payloadMap.get("site") == null || !payloadMap.get("site").equals(scope.site())) {
log.warn("Incorrect site provided: " + scope.site(), payloadMap);
return false;
}

if (!payloadMap.containsKey("consultation") || payloadMap.get("consultation") == null || !payloadMap.get("consultation").equals(consultation)) {
log.warn("Incorrect consultation provided: " + consultation, payloadMap);
if (!payloadMap.containsKey("consultation") || payloadMap.get("consultation") == null
|| !payloadMap.get("consultation").equals(scope.consultation())) {
log.warn("Incorrect consultation provided: " + scope.consultation(), payloadMap);
return false;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public Message<?> preSend(Message<?> message, MessageChannel channel) throws Mes
}

public Message<?> postReceive(Message<?> message, MessageChannel channel) {
log.warn("preReceive", message);
log.warn("postReceive", message);

return message;
}
Expand Down
Loading

0 comments on commit 8a3834d

Please sign in to comment.