diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/dev/robothanzo/werewolf/WerewolfApplication.java b/src/main/java/dev/robothanzo/werewolf/WerewolfApplication.java index ac99992..4e200e8 100644 --- a/src/main/java/dev/robothanzo/werewolf/WerewolfApplication.java +++ b/src/main/java/dev/robothanzo/werewolf/WerewolfApplication.java @@ -88,7 +88,10 @@ public static void extractSoundFiles() { new File(WerewolfApplication.class.getProtectionDomain().getCodeSource().getLocation().toURI())); File soundFolder = new File("sounds"); if (!soundFolder.exists()) { - soundFolder.mkdir(); + if (!soundFolder.mkdir()) { + log.error("Failed to create sounds directory"); + return; + } } // Logic to clean and extract (simplified from original to avoid full deletion // risk if not intent) diff --git a/src/main/java/dev/robothanzo/werewolf/config/SessionConfig.java b/src/main/java/dev/robothanzo/werewolf/config/SessionConfig.java index 632b78d..806aeb9 100644 --- a/src/main/java/dev/robothanzo/werewolf/config/SessionConfig.java +++ b/src/main/java/dev/robothanzo/werewolf/config/SessionConfig.java @@ -1,9 +1,26 @@ package dev.robothanzo.werewolf.config; import org.mongodb.spring.session.config.annotation.web.http.EnableMongoHttpSession; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.session.web.http.CookieSerializer; +import org.springframework.session.web.http.DefaultCookieSerializer; @Configuration @EnableMongoHttpSession(collectionName = "http_sessions") public class SessionConfig { + + @Bean + public CookieSerializer cookieSerializer() { + DefaultCookieSerializer serializer = new DefaultCookieSerializer(); + serializer.setSameSite("Lax"); + serializer.setUseHttpOnlyCookie(true); + + // Use secure cookies for HTTPS environments (production) + String dashboardUrl = System.getenv().getOrDefault("DASHBOARD_URL", "http://localhost:5173"); + boolean isSecureEnvironment = dashboardUrl.startsWith("https://"); + serializer.setUseSecureCookie(isSecureEnvironment); + + return serializer; + } } diff --git a/src/main/java/dev/robothanzo/werewolf/config/WebSocketConfig.java b/src/main/java/dev/robothanzo/werewolf/config/WebSocketConfig.java index 6223483..166af46 100644 --- a/src/main/java/dev/robothanzo/werewolf/config/WebSocketConfig.java +++ b/src/main/java/dev/robothanzo/werewolf/config/WebSocketConfig.java @@ -20,6 +20,9 @@ public WebSocketConfig(GlobalWebSocketHandler globalWebSocketHandler) { public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(globalWebSocketHandler, "/ws") .addInterceptors(new org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor()) - .setAllowedOrigins("*"); + .setAllowedOrigins( + "http://localhost:5173", + "https://wolf.robothanzo.dev", + "http://wolf.robothanzo.dev"); } } diff --git a/src/main/java/dev/robothanzo/werewolf/controller/AuthController.java b/src/main/java/dev/robothanzo/werewolf/controller/AuthController.java index 91336eb..02eed05 100644 --- a/src/main/java/dev/robothanzo/werewolf/controller/AuthController.java +++ b/src/main/java/dev/robothanzo/werewolf/controller/AuthController.java @@ -7,6 +7,7 @@ import io.mokulu.discord.oauth.DiscordOAuth; import io.mokulu.discord.oauth.model.TokensResponse; import io.mokulu.discord.oauth.model.User; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; @@ -41,13 +42,20 @@ public void login(@RequestParam(name = "guild_id", required = false) String guil } @GetMapping("/callback") - public void callback(@RequestParam String code, @RequestParam String state, HttpSession session, - HttpServletResponse response) throws IOException { + public void callback(@RequestParam String code, @RequestParam String state, + HttpServletRequest request, HttpServletResponse response) throws IOException { try { TokensResponse tokenResponse = discordOAuth.getTokens(code); DiscordAPI discordAPI = new DiscordAPI(tokenResponse.getAccessToken()); User user = discordAPI.fetchUser(); + // Prevent session fixation: invalidate old session and create new one + HttpSession oldSession = request.getSession(false); + if (oldSession != null) { + oldSession.invalidate(); + } + HttpSession session = request.getSession(true); + // Store user in Session AuthSession authSession = AuthSession.builder() .userId(user.getId()) @@ -60,7 +68,7 @@ public void callback(@RequestParam String code, @RequestParam String state, Http if (!"no_guild".equals(state)) { try { - // Validate it's a number + // Validate it's a number - prevents open redirect attacks long gid = Long.parseLong(state); authSession.setGuildId(state); // Store as String @@ -72,14 +80,22 @@ public void callback(@RequestParam String code, @RequestParam String state, Http } else { authSession.setRole(UserRole.SPECTATOR); } + + session.setAttribute("user", authSession); + response.sendRedirect( + System.getenv().getOrDefault("DASHBOARD_URL", "http://localhost:5173") + "/server/" + state); + } catch (NumberFormatException e) { + // Invalid guild ID format - redirect to server selection instead + log.warn("Invalid guild ID in OAuth state: {}", state); + authSession.setRole(UserRole.PENDING); + session.setAttribute("user", authSession); + response.sendRedirect(System.getenv().getOrDefault("DASHBOARD_URL", "http://localhost:5173") + "/"); } catch (Exception e) { log.warn("Failed to set initial guild info: {}", state, e); authSession.setRole(UserRole.PENDING); + session.setAttribute("user", authSession); + response.sendRedirect(System.getenv().getOrDefault("DASHBOARD_URL", "http://localhost:5173") + "/"); } - - session.setAttribute("user", authSession); - response.sendRedirect( - System.getenv().getOrDefault("DASHBOARD_URL", "http://localhost:5173") + "/server/" + state); } else { authSession.setRole(UserRole.PENDING); session.setAttribute("user", authSession); diff --git a/src/main/java/dev/robothanzo/werewolf/listeners/ButtonListener.java b/src/main/java/dev/robothanzo/werewolf/listeners/ButtonListener.java index 23b9dfc..ec09c08 100644 --- a/src/main/java/dev/robothanzo/werewolf/listeners/ButtonListener.java +++ b/src/main/java/dev/robothanzo/werewolf/listeners/ButtonListener.java @@ -85,14 +85,18 @@ public void onButtonInteraction(@NotNull ButtonInteractionEvent event) { event.getHook().editOriginal(":x: 你曾經參選過或正在參選,不得投票").queue(); return; } - Candidate electedCandidate = candidates - .get(Integer.parseInt(customId.replaceAll("votePolice", ""))); - if (electedCandidate != null) { - handleVote(event, candidates, electedCandidate); - // Broadcast update immediately - WerewolfApplication.gameSessionService.broadcastSessionUpdate(session); - } else { - event.getHook().editOriginal(":x: 找不到候選人").queue(); + try { + int candidateId = Integer.parseInt(customId.replace("votePolice", "")); + Candidate electedCandidate = candidates.get(candidateId); + if (electedCandidate != null) { + handleVote(event, candidates, electedCandidate); + // Broadcast update immediately + WerewolfApplication.gameSessionService.broadcastSessionUpdate(session); + } else { + event.getHook().editOriginal(":x: 找不到候選人").queue(); + } + } catch (NumberFormatException e) { + event.getHook().editOriginal(":x: 無效的投票選項").queue(); } } else { event.getHook().editOriginal(":x: 投票已過期").queue(); @@ -106,13 +110,17 @@ public void onButtonInteraction(@NotNull ButtonInteractionEvent event) { event.getHook().editOriginal(":x: 你正在和別人進行放逐辯論,不得投票").queue(); return; } - Map candidates = Poll.expelCandidates - .get(Objects.requireNonNull(event.getGuild()).getIdLong()); - Candidate electedCandidate = candidates - .get(Integer.parseInt(customId.replaceAll("voteExpel", ""))); - handleVote(event, candidates, electedCandidate); - // Broadcast update immediately for expel (user requested realtime voting) - WerewolfApplication.gameSessionService.broadcastSessionUpdate(session); + try { + Map candidates = Poll.expelCandidates + .get(Objects.requireNonNull(event.getGuild()).getIdLong()); + int candidateId = Integer.parseInt(customId.replace("voteExpel", "")); + Candidate electedCandidate = candidates.get(candidateId); + handleVote(event, candidates, electedCandidate); + // Broadcast update immediately for expel (user requested realtime voting) + WerewolfApplication.gameSessionService.broadcastSessionUpdate(session); + } catch (NumberFormatException e) { + event.getHook().editOriginal(":x: 無效的投票選項").queue(); + } } else { event.getHook().editOriginal(":x: 投票已過期").queue(); } diff --git a/src/main/java/dev/robothanzo/werewolf/service/impl/GameSessionServiceImpl.java b/src/main/java/dev/robothanzo/werewolf/service/impl/GameSessionServiceImpl.java index d2c211f..cbd8141 100644 --- a/src/main/java/dev/robothanzo/werewolf/service/impl/GameSessionServiceImpl.java +++ b/src/main/java/dev/robothanzo/werewolf/service/impl/GameSessionServiceImpl.java @@ -247,9 +247,14 @@ public List> playersToJSON(Session session) { } players.sort((a, b) -> { - int idA = Integer.parseInt((String) a.get("id")); - int idB = Integer.parseInt((String) b.get("id")); - return Integer.compare(idA, idB); + try { + int idA = Integer.parseInt((String) a.get("id")); + int idB = Integer.parseInt((String) b.get("id")); + return Integer.compare(idA, idB); + } catch (NumberFormatException e) { + // If parsing fails, treat as equal or use string comparison as fallback + return 0; + } }); return players; diff --git a/src/main/java/dev/robothanzo/werewolf/utils/IdentityUtils.java b/src/main/java/dev/robothanzo/werewolf/utils/IdentityUtils.java index 3d3c09e..10c8f1a 100644 --- a/src/main/java/dev/robothanzo/werewolf/utils/IdentityUtils.java +++ b/src/main/java/dev/robothanzo/werewolf/utils/IdentityUtils.java @@ -2,15 +2,40 @@ import dev.robothanzo.werewolf.database.documents.AuthSession; import dev.robothanzo.werewolf.database.documents.UserRole; +import dev.robothanzo.werewolf.service.DiscordService; +import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; +import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; @Component +@RequiredArgsConstructor public class IdentityUtils { + private final DiscordService discordService; + + // Simple cache to reduce Discord API calls - entries expire after they're created + private static class MembershipCacheEntry { + final boolean isMember; + final long timestamp; + + MembershipCacheEntry(boolean isMember) { + this.isMember = isMember; + this.timestamp = System.currentTimeMillis(); + } + + boolean isExpired() { + // Cache for 5 minutes + return System.currentTimeMillis() - timestamp > 300_000; + } + } + + private final Map membershipCache = new ConcurrentHashMap<>(); + public Optional getCurrentUser() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth != null && auth.getPrincipal() instanceof AuthSession) { @@ -21,7 +46,30 @@ public Optional getCurrentUser() { public boolean hasAccess(long guildId) { return getCurrentUser() - .map(user -> String.valueOf(guildId).equals(user.getGuildId())) + .map(user -> { + // First check if the guild ID matches what's stored in the session + if (!String.valueOf(guildId).equals(user.getGuildId())) { + return false; + } + + // Check cache first + String cacheKey = guildId + ":" + user.getUserId(); + MembershipCacheEntry cached = membershipCache.get(cacheKey); + if (cached != null && !cached.isExpired()) { + return cached.isMember; + } + + // Cache miss or expired - verify the user is still actually a member of the guild + boolean isMember = discordService.getMember(guildId, user.getUserId()) != null; + membershipCache.put(cacheKey, new MembershipCacheEntry(isMember)); + + // Clean up expired entries periodically (simple approach) + if (membershipCache.size() > 1000) { + membershipCache.entrySet().removeIf(e -> e.getValue().isExpired()); + } + + return isMember; + }) .orElse(false); }