From e5cf6d291a26a4c3d13d4ebdccbded0f9c908d78 Mon Sep 17 00:00:00 2001 From: Snoweuph Date: Sun, 16 Feb 2025 20:56:43 +0100 Subject: [PATCH] NOTICKET: Setup and Sepcify Websocket Structure --- .forgejo/workflows/build.yml | 13 +- build.gradle.kts | 5 + .../player/session/PlayerLoginSessions.java | 67 ---------- .../server/server/JsonMessage.java | 37 +++++ .../server/server/JsonWebsocketHandler.java | 49 +++++++ .../server/server/ServerApiController.java | 7 +- .../server/server/WebSocketConfig.java | 15 --- .../connection/ConnectionWebsocketConfig.java | 24 ++++ .../ConnectionWebsocketHandler.java | 41 ++++++ .../in/RequestConnectionTokenMessage.java | 14 ++ .../out/ProvidedConnectionTokenMessage.java | 28 ++++ .../server/channels/time/TimeMessage.java | 23 ++++ .../channels/time/TimeWebsocketConfig.java | 24 ++++ .../time/TimeWebsocketHandler.java} | 39 +++--- .../towerdefence/server/session/Channel.java | 24 ++++ .../server/session/JwtService.java | 61 +++++++++ .../server/session/JwtServiceConfig.java | 14 ++ .../server/session/SessionsService.java | 63 +++++++++ src/main/resources/application.properties | 5 +- src/main/resources/spotbugs-exclude.xml | 7 +- .../towerdefence/server/IntegrationTest.java | 1 - ws/example/time_channel.sh | 17 +++ ws/ws.yml | 126 ++++++++++++++++++ 23 files changed, 593 insertions(+), 111 deletions(-) delete mode 100644 src/main/java/de/towerdefence/server/player/session/PlayerLoginSessions.java create mode 100644 src/main/java/de/towerdefence/server/server/JsonMessage.java create mode 100644 src/main/java/de/towerdefence/server/server/JsonWebsocketHandler.java delete mode 100644 src/main/java/de/towerdefence/server/server/WebSocketConfig.java create mode 100644 src/main/java/de/towerdefence/server/server/channels/connection/ConnectionWebsocketConfig.java create mode 100644 src/main/java/de/towerdefence/server/server/channels/connection/ConnectionWebsocketHandler.java create mode 100644 src/main/java/de/towerdefence/server/server/channels/connection/in/RequestConnectionTokenMessage.java create mode 100644 src/main/java/de/towerdefence/server/server/channels/connection/out/ProvidedConnectionTokenMessage.java create mode 100644 src/main/java/de/towerdefence/server/server/channels/time/TimeMessage.java create mode 100644 src/main/java/de/towerdefence/server/server/channels/time/TimeWebsocketConfig.java rename src/main/java/de/towerdefence/server/server/{ServerWebsocketHandler.java => channels/time/TimeWebsocketHandler.java} (53%) create mode 100644 src/main/java/de/towerdefence/server/session/Channel.java create mode 100644 src/main/java/de/towerdefence/server/session/JwtService.java create mode 100644 src/main/java/de/towerdefence/server/session/JwtServiceConfig.java create mode 100644 src/main/java/de/towerdefence/server/session/SessionsService.java create mode 100755 ws/example/time_channel.sh create mode 100644 ws/ws.yml diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index c3a8ef6..353c510 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -36,6 +36,11 @@ jobs: with: name: api.yml path: api/api.yml + - name: Upload Websocket Spec as Artifact + uses: "https://git.euph.dev/actions/upload-artifact@v3" + with: + name: ws.yml + path: ws/ws.yml - name: "Stop Gradle" run: gradle --stop @@ -85,6 +90,11 @@ jobs: with: name: api.yml path: release + - name: Download Websocket Spec + uses: "https://git.euph.dev/actions/download-artifact@v3" + with: + name: ws.yml + path: release - name: Create Release uses: "https://git.euph.dev/actions/release@v2" with: @@ -96,6 +106,3 @@ jobs: release-notes: | # Tower Defence - Server ${{ github.ref_name }} Read the [Documentation](https://git.euph.dev/TowerDefence/Dokumentation/wiki/Server/Config) to see how to setup the server. - - - diff --git a/build.gradle.kts b/build.gradle.kts index b2897fe..ca54b5c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -53,6 +53,11 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") developmentOnly("org.springframework.boot:spring-boot-devtools") + //JWT + implementation("io.jsonwebtoken:jjwt-api:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") + // Postgres runtimeOnly("org.postgresql:postgresql") diff --git a/src/main/java/de/towerdefence/server/player/session/PlayerLoginSessions.java b/src/main/java/de/towerdefence/server/player/session/PlayerLoginSessions.java deleted file mode 100644 index 33b56d8..0000000 --- a/src/main/java/de/towerdefence/server/player/session/PlayerLoginSessions.java +++ /dev/null @@ -1,67 +0,0 @@ -package de.towerdefence.server.player.session; - -import de.towerdefence.server.player.Player; -import org.springframework.stereotype.Component; - -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; - -@Component -public class PlayerLoginSessions { - public static final int PLAYER_LOGIN_SESSION_TOKEN_BYTE_LENGTH = 64; - private final SecureRandom random; - private final Map playerLoginSessionTokens = new HashMap<>(); - private final Map playerLoginSessionPlayers = new HashMap<>(); - private final Map> playerLoginSessionSchedule = new HashMap<>(); - private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); - - public PlayerLoginSessions() { - random = new SecureRandom(); - } - - public String createPlayerLoginSession(Player player) { - byte[] token_data = new byte[PLAYER_LOGIN_SESSION_TOKEN_BYTE_LENGTH]; - this.random.nextBytes(token_data); - String token = new String(token_data, StandardCharsets.UTF_8); - String playerName = player.getUsername(); - this.playerLoginSessionTokens.put(playerName, token); - this.playerLoginSessionPlayers.put(playerName, player); - this.playerLoginSessionSchedule.put(playerName, scheduler.schedule( - () -> { - this.playerLoginSessionTokens.remove(playerName); - this.playerLoginSessionPlayers.remove(playerName); - this.playerLoginSessionSchedule.remove(playerName); - }, - 30, - TimeUnit.SECONDS - )); - return token; - } - - /** - * @return an Optional Player. If it is empty, that Player has no valid Login Session - */ - public Optional getPlayerFromLoginSession(String username, String token) { - if (!this.playerLoginSessionTokens.containsKey(username)) { - return Optional.empty(); - } - if (!this.playerLoginSessionTokens.get(username).equals(token)) { - return Optional.empty(); - } - this.playerLoginSessionTokens.remove(username); - Player player = this.playerLoginSessionPlayers.get(username); - ScheduledFuture task = this.playerLoginSessionSchedule.get(username); - if (task != null) { - task.cancel(true); - } - this.playerLoginSessionSchedule.remove(username); - return Optional.of(player); - } -} diff --git a/src/main/java/de/towerdefence/server/server/JsonMessage.java b/src/main/java/de/towerdefence/server/server/JsonMessage.java new file mode 100644 index 0000000..c62f0e8 --- /dev/null +++ b/src/main/java/de/towerdefence/server/server/JsonMessage.java @@ -0,0 +1,37 @@ +package de.towerdefence.server.server; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.io.IOException; +import java.util.Map; + +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + + +@Getter +@AllArgsConstructor +public abstract class JsonMessage { + protected abstract String getMessageId(); + protected abstract Map getData(JsonNodeFactory factory); + + public void send(WebSocketSession session) throws IOException { + session.sendMessage(new TextMessage(getPayload())); + } + + public String getPayload() { + JsonNodeFactory factory = new JsonNodeFactory(false); + ObjectNode msg = factory.objectNode().put("$id", getMessageId()); + for (Map.Entry entry : getData(factory).entrySet()) { + if(entry.getKey().equals("$id")){ + continue; + } + msg.set(entry.getKey(), entry.getValue()); + } + return msg.toString(); + } +} diff --git a/src/main/java/de/towerdefence/server/server/JsonWebsocketHandler.java b/src/main/java/de/towerdefence/server/server/JsonWebsocketHandler.java new file mode 100644 index 0000000..5d351cb --- /dev/null +++ b/src/main/java/de/towerdefence/server/server/JsonWebsocketHandler.java @@ -0,0 +1,49 @@ +package de.towerdefence.server.server; + +import de.towerdefence.server.player.Player; +import de.towerdefence.server.session.Channel; +import de.towerdefence.server.session.SessionsService; +import lombok.AllArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +@AllArgsConstructor +public abstract class JsonWebsocketHandler extends TextWebSocketHandler { + private static final Logger logger = LoggerFactory.getLogger(JsonWebsocketHandler.class); + protected final Channel channel; + protected final SessionsService sessionsService; + protected final Map sessionPlayers = new ConcurrentHashMap<>(); + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + String jwt = session.getHandshakeHeaders().getFirst("Authorization"); + if (jwt == null){ + closeSession(session, CloseStatus.NOT_ACCEPTABLE); + return; + } + Optional player = sessionsService.getSession(jwt, channel); + if(player.isEmpty()){ + closeSession(session, CloseStatus.NOT_ACCEPTABLE); + return; + } + sessionPlayers.put(session, player.get()); + } + + protected void closeSession(WebSocketSession session, CloseStatus reason){ + if(session.isOpen()){ + try{ + session.close(reason); + } catch (Exception exception) { + logger.info("Unable to Close the Session", exception); + } + } + } + +} diff --git a/src/main/java/de/towerdefence/server/server/ServerApiController.java b/src/main/java/de/towerdefence/server/server/ServerApiController.java index b366dab..2dda3e3 100644 --- a/src/main/java/de/towerdefence/server/server/ServerApiController.java +++ b/src/main/java/de/towerdefence/server/server/ServerApiController.java @@ -9,7 +9,8 @@ import de.towerdefence.server.oas.models.ServerHealth; import de.towerdefence.server.player.Player; import de.towerdefence.server.player.PlayerRepository; import de.towerdefence.server.player.PlayerService; -import de.towerdefence.server.player.session.PlayerLoginSessions; +import de.towerdefence.server.session.Channel; +import de.towerdefence.server.session.SessionsService; import jakarta.servlet.http.HttpServletRequest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; @@ -29,7 +30,7 @@ public class ServerApiController implements ServerApi { @Autowired private PlayerService playerService; @Autowired - private PlayerLoginSessions playerLoginSessions; + private SessionsService sessionsService; @Override public Optional getObjectMapper() { @@ -70,7 +71,7 @@ public class ServerApiController implements ServerApi { } catch (NoSuchAlgorithmException e) { return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } - String token = playerLoginSessions.createPlayerLoginSession(player); + String token = sessionsService.createSession(player, Channel.CONNECTION); PlayerLoginSession session = new PlayerLoginSession(); session.setUsername(player.getUsername()); session.setToken(token); diff --git a/src/main/java/de/towerdefence/server/server/WebSocketConfig.java b/src/main/java/de/towerdefence/server/server/WebSocketConfig.java deleted file mode 100644 index 40447fb..0000000 --- a/src/main/java/de/towerdefence/server/server/WebSocketConfig.java +++ /dev/null @@ -1,15 +0,0 @@ -package de.towerdefence.server.server; - -import org.springframework.context.annotation.Configuration; -import org.springframework.web.socket.config.annotation.EnableWebSocket; -import org.springframework.web.socket.config.annotation.WebSocketConfigurer; -import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; - -@Configuration -@EnableWebSocket -public class WebSocketConfig implements WebSocketConfigurer { - @Override - public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - registry.addHandler(new ServerWebsocketHandler(), "/ws/server").setAllowedOrigins("*"); - } -} diff --git a/src/main/java/de/towerdefence/server/server/channels/connection/ConnectionWebsocketConfig.java b/src/main/java/de/towerdefence/server/server/channels/connection/ConnectionWebsocketConfig.java new file mode 100644 index 0000000..0bd5a96 --- /dev/null +++ b/src/main/java/de/towerdefence/server/server/channels/connection/ConnectionWebsocketConfig.java @@ -0,0 +1,24 @@ +package de.towerdefence.server.server.channels.connection; + +import de.towerdefence.server.session.SessionsService; +import lombok.AllArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +@AllArgsConstructor +@Configuration +@EnableWebSocket +public class ConnectionWebsocketConfig implements WebSocketConfigurer { + @Autowired + private final SessionsService sessionsService; + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler( + new ConnectionWebsocketHandler(this.sessionsService), + "/ws/connection" + ).setAllowedOrigins("*"); + } +} diff --git a/src/main/java/de/towerdefence/server/server/channels/connection/ConnectionWebsocketHandler.java b/src/main/java/de/towerdefence/server/server/channels/connection/ConnectionWebsocketHandler.java new file mode 100644 index 0000000..584c27d --- /dev/null +++ b/src/main/java/de/towerdefence/server/server/channels/connection/ConnectionWebsocketHandler.java @@ -0,0 +1,41 @@ +package de.towerdefence.server.server.channels.connection; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.towerdefence.server.player.Player; +import de.towerdefence.server.server.JsonWebsocketHandler; +import de.towerdefence.server.server.channels.connection.in.RequestConnectionTokenMessage; +import de.towerdefence.server.server.channels.connection.out.ProvidedConnectionTokenMessage; +import de.towerdefence.server.session.Channel; +import de.towerdefence.server.session.SessionsService; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +public class ConnectionWebsocketHandler extends JsonWebsocketHandler { + private final ObjectMapper objectMapper = new ObjectMapper(); + + public ConnectionWebsocketHandler(SessionsService sessionsService) { + super(Channel.CONNECTION, sessionsService); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) { + try{ + String payload = message.getPayload(); + switch ( objectMapper.readTree(payload).get("$id").asText().toLowerCase()) { + case "requestconnectiontoken" -> handleRequestConnectionToken(session, payload); + default -> this.closeSession(session, CloseStatus.BAD_DATA); + } + } catch (Exception exception) { + System.out.println(exception.getMessage()); + this.closeSession(session, CloseStatus.BAD_DATA); + } + } + + private void handleRequestConnectionToken(WebSocketSession session, String payload) throws Exception { + RequestConnectionTokenMessage msg = objectMapper.readValue(payload, RequestConnectionTokenMessage.class); + Player player = this.sessionPlayers.get(session); + String jwt = this.sessionsService.createSession(player, msg.getChannel()); + new ProvidedConnectionTokenMessage(msg.getChannel(), jwt).send(session); + } +} diff --git a/src/main/java/de/towerdefence/server/server/channels/connection/in/RequestConnectionTokenMessage.java b/src/main/java/de/towerdefence/server/server/channels/connection/in/RequestConnectionTokenMessage.java new file mode 100644 index 0000000..6e30148 --- /dev/null +++ b/src/main/java/de/towerdefence/server/server/channels/connection/in/RequestConnectionTokenMessage.java @@ -0,0 +1,14 @@ +package de.towerdefence.server.server.channels.connection.in; + +import com.fasterxml.jackson.annotation.JsonProperty; +import de.towerdefence.server.session.Channel; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@Data +@NotNull +public class RequestConnectionTokenMessage { + @JsonProperty("$id") + private String messageId; + private Channel channel; +} diff --git a/src/main/java/de/towerdefence/server/server/channels/connection/out/ProvidedConnectionTokenMessage.java b/src/main/java/de/towerdefence/server/server/channels/connection/out/ProvidedConnectionTokenMessage.java new file mode 100644 index 0000000..e89e0f1 --- /dev/null +++ b/src/main/java/de/towerdefence/server/server/channels/connection/out/ProvidedConnectionTokenMessage.java @@ -0,0 +1,28 @@ +package de.towerdefence.server.server.channels.connection.out; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import de.towerdefence.server.server.JsonMessage; +import de.towerdefence.server.session.Channel; +import lombok.AllArgsConstructor; + +import java.util.Map; + +@AllArgsConstructor +public class ProvidedConnectionTokenMessage extends JsonMessage { + private final Channel channel; + private final String token; + + @Override + protected String getMessageId() { + return "ProvidedConnectionTokenMessage"; + } + + @Override + protected Map getData(JsonNodeFactory factory) { + return Map.of( + "channel", factory.textNode(channel.getJsonName()), + "token", factory.textNode(token) + ); + } +} diff --git a/src/main/java/de/towerdefence/server/server/channels/time/TimeMessage.java b/src/main/java/de/towerdefence/server/server/channels/time/TimeMessage.java new file mode 100644 index 0000000..9093194 --- /dev/null +++ b/src/main/java/de/towerdefence/server/server/channels/time/TimeMessage.java @@ -0,0 +1,23 @@ +package de.towerdefence.server.server.channels.time; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import de.towerdefence.server.server.JsonMessage; +import lombok.AllArgsConstructor; + +import java.util.Map; + +@AllArgsConstructor +public class TimeMessage extends JsonMessage { + private final long time; + + @Override + protected String getMessageId() { + return "CurrentUnixTime"; + } + + @Override + protected Map getData(JsonNodeFactory factory) { + return Map.of("time", factory.numberNode(this.time)); + } +} diff --git a/src/main/java/de/towerdefence/server/server/channels/time/TimeWebsocketConfig.java b/src/main/java/de/towerdefence/server/server/channels/time/TimeWebsocketConfig.java new file mode 100644 index 0000000..2bb7421 --- /dev/null +++ b/src/main/java/de/towerdefence/server/server/channels/time/TimeWebsocketConfig.java @@ -0,0 +1,24 @@ +package de.towerdefence.server.server.channels.time; + +import de.towerdefence.server.session.SessionsService; +import lombok.AllArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +@AllArgsConstructor +@Configuration +@EnableWebSocket +public class TimeWebsocketConfig implements WebSocketConfigurer { + @Autowired + private final SessionsService sessionsService; + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler( + new TimeWebsocketHandler(this.sessionsService), + "/ws/time" + ).setAllowedOrigins("*"); + } +} diff --git a/src/main/java/de/towerdefence/server/server/ServerWebsocketHandler.java b/src/main/java/de/towerdefence/server/server/channels/time/TimeWebsocketHandler.java similarity index 53% rename from src/main/java/de/towerdefence/server/server/ServerWebsocketHandler.java rename to src/main/java/de/towerdefence/server/server/channels/time/TimeWebsocketHandler.java index d8eba5e..4af9371 100644 --- a/src/main/java/de/towerdefence/server/server/ServerWebsocketHandler.java +++ b/src/main/java/de/towerdefence/server/server/channels/time/TimeWebsocketHandler.java @@ -1,19 +1,27 @@ -package de.towerdefence.server.server; +package de.towerdefence.server.server.channels.time; -import org.springframework.web.socket.TextMessage; +import de.towerdefence.server.server.JsonWebsocketHandler; +import de.towerdefence.server.session.Channel; +import de.towerdefence.server.session.SessionsService; import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; import java.io.IOException; import java.util.Map; import java.util.concurrent.*; -public class ServerWebsocketHandler extends TextWebSocketHandler { - private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); +public class TimeWebsocketHandler extends JsonWebsocketHandler { + private final Map> sessionTaskMap = new ConcurrentHashMap<>(); + private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + + public TimeWebsocketHandler(SessionsService sessionsService) { + super(Channel.TIME, sessionsService); + } @Override - public void afterConnectionEstablished(WebSocketSession session) throws Exception { + public void afterConnectionEstablished(WebSocketSession session) { + super.afterConnectionEstablished(session); + ScheduledFuture scheduledTask = scheduler.scheduleAtFixedRate( () -> sendCurrentTime(session), 0, @@ -23,25 +31,16 @@ public class ServerWebsocketHandler extends TextWebSocketHandler { sessionTaskMap.put(session, scheduledTask); } - @Override - public void handleTextMessage(WebSocketSession session, TextMessage message) { - try { - String responseMessage = "You are Connected to the Tower Defence Server Websocket"; - session.sendMessage(new TextMessage(responseMessage)); - } catch (Exception e) { - e.printStackTrace(); - } - } - private void sendCurrentTime(WebSocketSession session) { ScheduledFuture task = sessionTaskMap.get(session); try { - session.sendMessage(new TextMessage(String.valueOf(System.currentTimeMillis()))); - } catch (IllegalStateException | IOException e) { + if(!session.isOpen()){ + throw new RuntimeException("Session is not open"); + } + new TimeMessage(System.currentTimeMillis()).send(session); + } catch (RuntimeException | IOException e) { task.cancel(true); sessionTaskMap.remove(session); - } catch (Exception e) { - e.printStackTrace(); } } } diff --git a/src/main/java/de/towerdefence/server/session/Channel.java b/src/main/java/de/towerdefence/server/session/Channel.java new file mode 100644 index 0000000..3e4aece --- /dev/null +++ b/src/main/java/de/towerdefence/server/session/Channel.java @@ -0,0 +1,24 @@ +package de.towerdefence.server.session; + +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum Channel { + CONNECTION("connection"), + TIME("time"); + + private final String jsonName; + + @JsonCreator + public static Channel fromJsonName(String jsonName) { + for (Channel channel : Channel.values()) { + if (channel.getJsonName().equalsIgnoreCase(jsonName)) { + return channel; + } + } + throw new IllegalArgumentException("Unknown channel: " + jsonName); + } +} diff --git a/src/main/java/de/towerdefence/server/session/JwtService.java b/src/main/java/de/towerdefence/server/session/JwtService.java new file mode 100644 index 0000000..d20c20c --- /dev/null +++ b/src/main/java/de/towerdefence/server/session/JwtService.java @@ -0,0 +1,61 @@ +package de.towerdefence.server.session; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.WeakKeyException; +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.Optional; + +@Service +public class JwtService { + + private final SecretKey secretKey; + + public JwtService(JwtServiceConfig config) throws WeakKeyException { + this.secretKey = Keys.hmacShaKeyFor(config.getSecret().getBytes(StandardCharsets.UTF_8)); + } + + public String generateToken(String username, Channel channel, long ttl) { + long now = System.currentTimeMillis(); + Date issueDate = new Date(now); + Date expirationDate = new Date(now + ttl * 1000); + + return Jwts.builder() + .subject(username) + .claim("channel", channel.getJsonName()) + .issuedAt(issueDate) + .expiration(expirationDate) + .signWith(secretKey) + .compact(); + } + + public Optional verifyToken(String token, Channel channel) { + Claims claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + + if (claims.getExpiration().before(new Date())) { + return Optional.empty(); + } + + Channel tokenChannel; + try { + tokenChannel = Channel.fromJsonName(claims.get("channel", String.class)); + } catch (IllegalArgumentException ignored) { + return Optional.empty(); + } + if(!channel.equals(tokenChannel)) { + return Optional.empty(); + } + + return Optional.of(claims.getSubject()); + } +} diff --git a/src/main/java/de/towerdefence/server/session/JwtServiceConfig.java b/src/main/java/de/towerdefence/server/session/JwtServiceConfig.java new file mode 100644 index 0000000..3a079fa --- /dev/null +++ b/src/main/java/de/towerdefence/server/session/JwtServiceConfig.java @@ -0,0 +1,14 @@ +package de.towerdefence.server.session; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "jwt") +public class JwtServiceConfig { + private String secret; +} diff --git a/src/main/java/de/towerdefence/server/session/SessionsService.java b/src/main/java/de/towerdefence/server/session/SessionsService.java new file mode 100644 index 0000000..ee15fd8 --- /dev/null +++ b/src/main/java/de/towerdefence/server/session/SessionsService.java @@ -0,0 +1,63 @@ +package de.towerdefence.server.session; + +import de.towerdefence.server.player.Player; +import lombok.AllArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.*; + +@Service +@AllArgsConstructor +public class SessionsService { + private static final int TIME_TO_LIVE_SECONDS = 30; + + private final Map tokenGrants = new ConcurrentHashMap<>(); + private final Map sessions = new ConcurrentHashMap<>(); + private final Map> tokenGarbageCollectors = new ConcurrentHashMap<>(); + + @Autowired + private final JwtService jwtService; + private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + + public String createSession(Player player, Channel channel){ + String jwt = jwtService.generateToken(player.getUsername(), channel, TIME_TO_LIVE_SECONDS); + if(tokenGrants.containsKey(jwt)){ + throw new IllegalStateException("The exact same JWT allready exists"); + } + tokenGrants.put(jwt, channel); + sessions.put(jwt, player); + this.tokenGarbageCollectors.put(jwt, scheduler.schedule(() -> { + tokenGrants.remove(jwt); + sessions.remove(jwt); + tokenGarbageCollectors.remove(jwt); + }, TIME_TO_LIVE_SECONDS, TimeUnit.SECONDS)); + return jwt; + } + + public Optional getSession(String jwt, Channel channel){ + Channel grantedChannel = tokenGrants.get(jwt); + if (grantedChannel == null || !grantedChannel.equals(channel)) { + return Optional.empty(); + } + Optional username = jwtService.verifyToken(jwt, channel); + if (username.isEmpty()) { + return Optional.empty(); + } + Player player = sessions.get(jwt); + if (!Objects.equals(player.getUsername(), username.get())) { + return Optional.empty(); + } + ScheduledFuture garbageCollector = tokenGarbageCollectors.get(jwt); + if (garbageCollector != null && !garbageCollector.isCancelled() && !garbageCollector.isDone()) { + garbageCollector.cancel(false); + } + tokenGarbageCollectors.remove(jwt); + tokenGrants.remove(jwt); + sessions.remove(jwt); + return Optional.of(player); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index eb3a310..ed22d5f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,8 +9,11 @@ spring.datasource.username=${TD_DB_USER:td_user} spring.datasource.password=${TD_DB_PASS:td123} spring.jpa.hibernate.ddl-auto=create-drop +# Signing JWT +jwt.secret=i-am-secret-key-that-you-wont-guess + # TODO: Replace with our own IAM (After completion of the project) -# JWT Auth +# JWT Auth for Keycloak spring.security.oauth2.client.registration.keycloak.client-id=employee-management-service spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code spring.security.oauth2.client.registration.keycloak.scope=openid diff --git a/src/main/resources/spotbugs-exclude.xml b/src/main/resources/spotbugs-exclude.xml index 91bc76f..a574128 100644 --- a/src/main/resources/spotbugs-exclude.xml +++ b/src/main/resources/spotbugs-exclude.xml @@ -2,11 +2,16 @@ xmlns="https://raw.githubusercontent.com/spotbugs/spotbugs/4.8.6/spotbugs/etc/findbugsfilter.xsd"> - + + + + + + diff --git a/src/test/java/de/towerdefence/server/IntegrationTest.java b/src/test/java/de/towerdefence/server/IntegrationTest.java index 5964f75..dbf41fa 100644 --- a/src/test/java/de/towerdefence/server/IntegrationTest.java +++ b/src/test/java/de/towerdefence/server/IntegrationTest.java @@ -2,7 +2,6 @@ package de.towerdefence.server; import com.fasterxml.jackson.databind.ObjectMapper; import de.towerdefence.server.player.PlayerRepository; -import de.towerdefence.server.player.PlayerService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; diff --git a/ws/example/time_channel.sh b/ws/example/time_channel.sh new file mode 100755 index 0000000..3e29209 --- /dev/null +++ b/ws/example/time_channel.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +response=$(curl -s -X 'POST' \ + 'http://localhost:8080/api/v1/player/login' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "username": "Player1", + "password": "1234" +}') + +token=$(echo "$response" | jq -r .token) +payload='{"$id": "RequestConnectionToken", "channel": "time"}' +response=$(echo "$payload" | websocat ws://localhost:8080/ws/connection -H "Authorization: $token") + +time_token=$(echo "$response" | jq -r .token) +websocat ws://localhost:8080/ws/time -H "Authorization: $time_token" diff --git a/ws/ws.yml b/ws/ws.yml new file mode 100644 index 0000000..8a8e7c8 --- /dev/null +++ b/ws/ws.yml @@ -0,0 +1,126 @@ +asyncapi: 3.0.0 +info: + title: Game Server + version: 0.0.1 + description: | + This is the Websocket Specification for the Tower Defence Game.
+ Because of the limitations of Async API, we expect that the actual json, + which is send as payload to always contain a field called `$id` with + the corresponding `messageId`.
+ The `messageId` should be handled case insensitive. +defaultContentType: application/json +servers: + localhost: + host: localhost:8080 + protocol: ws + pathname: /ws + security: + - $ref: "#/components/securitySchemes/JwtAuth" + +channels: + connection: + title: Connection + description: | + The Base Channel used for: + - Authentication + - Receiving Tokens for other channels + - Reconnection + messages: + RequestConnectionToken: + description: | + A Message telling the Server, that + you want an Connection Token for a + Specific Channel + payload: + type: object + additionalProperties: false + properties: + $id: + type: string + format: messageId + channel: + type: string + enum: + - time + required: + - $id + - channel + ProvidedConnectionToken: + description: | + A Message telling the Server, that + you want an Connection Token for a + Specific Channel + payload: + type: object + additionalProperties: false + properties: + $id: + type: string + format: messageId + channel: + type: string + enum: + - time + token: + $ref: "#/components/schemas/JWT" + required: + - $id + - channel + - token + time: + title: Time + description: | + A Simple example channel for receiving + the current Unix time + messages: + CurrentUnixTime: + description: The Current time in Unix Time + payload: + type: object + additionalProperties: false + properties: + $id: + type: string + format: messageId + time: + type: integer + format: int64 + required: + - $id + - time + +operations: + requestConnectionToken: + title: RequestConnectionToken + action: send + channel: + $ref: "#/channels/connection" + messages: + - $ref: "#/channels/connection/messages/RequestConnectionToken" + reply: + channel: + $ref: "#/channels/connection" + messages: + - $ref: "#/channels/connection/messages/ProvidedConnectionToken" + updateTime: + title: Updates of the current Unix Time + action: receive + channel: + $ref: "#/channels/time" + messages: + - $ref: "#/channels/time/messages/CurrentUnixTime" + +components: + securitySchemes: + JwtAuth: + name: Authorization + description: | + A JWT Token has to be provided in the Handshake Header.
+ This Field is expected to be called `Authorization`.
+ It is expected to not have a prefix like `bearer`. + type: httpApiKey + in: header + schemas: + JWT: + type: string + format: jwt