From 7a24c652ac03d1954235d6b58cd6fe074301783c Mon Sep 17 00:00:00 2001 From: Andy Janata Date: Fri, 2 Mar 2018 17:24:58 -0800 Subject: [PATCH] Add ID codes for positive user identification, and minor fixups. Users can specify an identification code when they connect (8-100 characters), only if they are using HTTPS. This code is combined with their nickname and a server-side secret, hashed with SHA-256, and condensed down to 64 bits by XORing every 8th byte with each other, and finally converted to base64 (with the trailing = removed). This code is displayed in a tooltip when hovering over the user's chat (TODO: mobile way to view it). Sigils have been added to be displayed before the user's name in the chat. Admins get @, users with an ID code get +, and normal users get nothing. The IS_ADMIN field is now deprecated, as this can be determined from the user's sigil. It will be removed eventually, but is still being included in events even though the official client should not be using it anymore. Kicks and bans are now always displayed to all users, even if the server isn't transmitting quit events normally. --- WebContent/game.jsp | 7 +- WebContent/js/cah.ajax.builder.js | 13 +++- WebContent/js/cah.ajax.handlers.js | 6 +- WebContent/js/cah.app.js | 29 ++++++-- WebContent/js/cah.constants.js | 43 ++++++++---- WebContent/js/cah.log.js | 27 +++++++- WebContent/js/cah.longpoll.handlers.js | 15 ++-- build.properties.example | 4 ++ .../filtered-resources/WEB-INF/pyx.properties | 1 + .../java/net/socialgamer/cah/CahModule.java | 27 ++++++++ .../java/net/socialgamer/cah/Constants.java | 38 ++++++++++- .../socialgamer/cah/data/ConnectedUsers.java | 23 ++++--- .../java/net/socialgamer/cah/data/User.java | 43 +++++++++--- .../socialgamer/cah/handlers/ChatHandler.java | 2 + .../cah/handlers/GameChatHandler.java | 16 +++-- .../cah/handlers/NamesHandler.java | 17 +++-- .../cah/handlers/RegisterHandler.java | 36 +++++++--- .../cah/servlets/JavascriptConfigServlet.java | 4 ++ .../socialgamer/cah/util/IdCodeMangler.java | 68 +++++++++++++++++++ .../net/socialgamer/cah/data/GameTest.java | 12 ++-- 20 files changed, 346 insertions(+), 85 deletions(-) create mode 100644 src/main/java/net/socialgamer/cah/util/IdCodeMangler.java diff --git a/WebContent/game.jsp b/WebContent/game.jsp index 24adaee..2fc69d7 100644 --- a/WebContent/game.jsp +++ b/WebContent/game.jsp @@ -107,9 +107,14 @@ HttpSession hSession = request.getSession(true); The PAX panel sets have also been removed.
- Nickname: + + +
diff --git a/WebContent/js/cah.ajax.builder.js b/WebContent/js/cah.ajax.builder.js index c68fdcf..58d14d5 100644 --- a/WebContent/js/cah.ajax.builder.js +++ b/WebContent/js/cah.ajax.builder.js @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2017, Andy Janata + * Copyright (c) 2012-2018, Andy Janata * All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are permitted @@ -199,6 +199,17 @@ cah.ajax.Builder.prototype.withCardcastId = function(id) { return this; }; +/** + * @param {string} + * id The user's identification code. + * @returns {cah.ajax.Builder} This object. + */ +cah.ajax.Builder.prototype.withIdCode = function(idCode) { + this.assertNotExecuted_(); + this.data[cah.$.AjaxRequest.ID_CODE] = idCode; + return this; +}; + /** * Assert that the request from this builder has not already run. Throws an exception if it has. * diff --git a/WebContent/js/cah.ajax.handlers.js b/WebContent/js/cah.ajax.handlers.js index 169a0fa..93f851a 100644 --- a/WebContent/js/cah.ajax.handlers.js +++ b/WebContent/js/cah.ajax.handlers.js @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2017, Andy Janata + * Copyright (c) 2012-2018, Andy Janata * All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are permitted @@ -29,11 +29,13 @@ cah.ajax.SuccessHandlers[cah.$.AjaxOperation.REGISTER] = function(data) { cah.nickname = data[cah.$.AjaxResponse.NICKNAME]; + cah.idcode = data[cah.$.AjaxResponse.ID_CODE]; + cah.sigil = data[cah.$.AjaxResponse.SIGIL]; if (!cah.noPersistentId) { cah.persistentId = data[cah.$.AjaxResponse.PERSISTENT_ID]; cah.setCookie("persistent_id", cah.persistentId); } - cah.log.status("You are connected as " + cah.nickname); + cah.log.status("You are connected as " + cah.sigil + cah.nickname); $("#welcome").hide(); $("#canvass").show(); diff --git a/WebContent/js/cah.app.js b/WebContent/js/cah.app.js index 80488ef..2292bf4 100644 --- a/WebContent/js/cah.app.js +++ b/WebContent/js/cah.app.js @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2017, Andy Janata + * Copyright (c) 2012-2018, Andy Janata * All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are permitted @@ -40,8 +40,13 @@ $(document).ready(function() { $("#nickname").val($.cookie("nickname")); } $("#nicknameconfirm").click(nicknameconfirm_click); - $("#nickname").keyup(nickbox_keyup); + $("#nickname").keyup(nickname_keyup); $("#nickname").focus(); + if (document.location.protocol == "https:" || cah.INSECURE_ID_ALLOWED) { + $("#idcode").prop("disabled", false); + // re-use existing handler + $("#idcode").keyup(nickname_keyup); + } $(".chat", $("#tab-global")).keyup(chat_keyup($(".chat_submit", $("#tab-global")))); $(".chat_submit", $("#tab-global")).click(chatsubmit_click(null, $("#tab-global"))); @@ -82,7 +87,7 @@ $(window).blur(function() { * @param {jQuery.Event} * e */ -function nickbox_keyup(e) { +function nickname_keyup(e) { if (e.which == 13) { $("#nicknameconfirm").click(); e.preventDefault(); @@ -96,6 +101,10 @@ function nicknameconfirm_click() { var nickname = $.trim($("#nickname").val()); cah.setCookie("nickname", nickname); var builder = cah.Ajax.build(cah.$.AjaxOperation.REGISTER).withNickname(nickname); + var idCode = $.trim($("#idcode").val()); + if (idCode) { + builder.withIdCode(idCode); + } if (!cah.noPersistentId && cah.persistentId) { builder.withPersistentId(cah.persistentId); } @@ -153,7 +162,12 @@ function chatsubmit_click(game_id, parent_element) { ajax = cah.Ajax.build(cah.$.AjaxOperation.CHAT); } ajax = ajax.withEmote(false).withMessage(text); - cah.log.status_with_game(game_id, "<" + cah.nickname + "> " + text); + var clazz = ''; + if (cah.sigil == cah.$.Sigil.ADMIN) { + clazz = 'admin'; + } + cah.log.status_with_game(game_id, "<" + cah.sigil + cah.nickname + "> " + text, clazz, + false, cah.log.getTitleForIdCode(cah.idcode)); break; case 'me': if (game_id !== null) { @@ -162,7 +176,12 @@ function chatsubmit_click(game_id, parent_element) { ajax = cah.Ajax.build(cah.$.AjaxOperation.CHAT); } ajax = ajax.withEmote(true).withMessage(text); - cah.log.status_with_game(game_id, "* " + cah.nickname + " " + text); + var clazz = ''; + if (cah.sigil == cah.$.Sigil.ADMIN) { + clazz = 'admin'; + } + cah.log.status_with_game(game_id, "* " + cah.sigil + cah.nickname + " " + text, clazz, + false, cah.log.getTitleForIdCode(cah.idcode)); break; case 'wall': ajax = cah.Ajax.build(cah.$.AjaxOperation.CHAT).withWall(true).withMessage(text); diff --git a/WebContent/js/cah.constants.js b/WebContent/js/cah.constants.js index f7ebd84..6c24378 100644 --- a/WebContent/js/cah.constants.js +++ b/WebContent/js/cah.constants.js @@ -109,33 +109,43 @@ cah.$.WhiteCardData.TEXT = "T"; cah.$.WhiteCardData.ID = "cid"; cah.$.WhiteCardData.WATERMARK = "W"; +cah.$.Sigil = function() { + // Dummy constructor to make Eclipse auto-complete. +}; +cah.$.Sigil.prototype.dummyForAutocomplete = undefined; +cah.$.Sigil.NORMAL_USER = ""; +cah.$.Sigil.ADMIN = "@"; +cah.$.Sigil.ID_CODE = "+"; + cah.$.LongPollResponse = function() { // Dummy constructor to make Eclipse auto-complete. }; cah.$.LongPollResponse.prototype.dummyForAutocomplete = undefined; -cah.$.LongPollResponse.WALL = "wall"; cah.$.LongPollResponse.PLAY_TIMER = "Pt"; -cah.$.LongPollResponse.ROUND_WINNER = "rw"; -cah.$.LongPollResponse.EMOTE = "me"; -cah.$.LongPollResponse.CARDCAST_DECK_INFO = "cdi"; cah.$.LongPollResponse.PLAYER_INFO = "pi"; cah.$.LongPollResponse.FROM = "f"; -cah.$.LongPollResponse.GAME_ID = "gid"; cah.$.LongPollResponse.WHITE_CARDS = "wc"; cah.$.LongPollResponse.EVENT = "E"; cah.$.LongPollResponse.HAND = "h"; cah.$.LongPollResponse.ERROR_CODE = "ec"; cah.$.LongPollResponse.MESSAGE = "m"; cah.$.LongPollResponse.WINNING_CARD = "WC"; -cah.$.LongPollResponse.NICKNAME = "n"; -cah.$.LongPollResponse.BLACK_CARD = "bc"; cah.$.LongPollResponse.FROM_ADMIN = "fa"; cah.$.LongPollResponse.TIMESTAMP = "ts"; -cah.$.LongPollResponse.GAME_STATE = "gs"; cah.$.LongPollResponse.GAME_INFO = "gi"; cah.$.LongPollResponse.ERROR = "e"; -cah.$.LongPollResponse.INTERMISSION = "i"; +cah.$.LongPollResponse.ID_CODE = "idc"; cah.$.LongPollResponse.REASON = "qr"; +cah.$.LongPollResponse.WALL = "wall"; +cah.$.LongPollResponse.ROUND_WINNER = "rw"; +cah.$.LongPollResponse.SIGIL = "?"; +cah.$.LongPollResponse.EMOTE = "me"; +cah.$.LongPollResponse.CARDCAST_DECK_INFO = "cdi"; +cah.$.LongPollResponse.GAME_ID = "gid"; +cah.$.LongPollResponse.NICKNAME = "n"; +cah.$.LongPollResponse.BLACK_CARD = "bc"; +cah.$.LongPollResponse.GAME_STATE = "gs"; +cah.$.LongPollResponse.INTERMISSION = "i"; cah.$.LongPollEvent = function() { // Dummy constructor to make Eclipse auto-complete. @@ -181,6 +191,7 @@ cah.$.ErrorCode.BANNED = "B&"; cah.$.ErrorCode.WRONG_PASSWORD = "wp"; cah.$.ErrorCode.RESERVED_NICK = "rn"; cah.$.ErrorCode.TOO_MANY_GAMES = "tmg"; +cah.$.ErrorCode.INVALID_ID_CODE = "iid"; cah.$.ErrorCode.CANNOT_JOIN_ANOTHER_GAME = "cjag"; cah.$.ErrorCode.NO_MSG_SPECIFIED = "nms"; cah.$.ErrorCode.ALREADY_STARTED = "as"; @@ -213,6 +224,7 @@ cah.$.ErrorCode.INVALID_CARD = "ic"; cah.$.ErrorCode_msg = {}; cah.$.ErrorCode_msg['cii'] = "Invalid Cardcast ID. Must be exactly 5 characters."; cah.$.ErrorCode_msg['nr'] = "Not registered. Refresh the page."; +cah.$.ErrorCode_msg['iid'] = "Identification code, if provided, must be between 8 and 100 characters, inclusive."; cah.$.ErrorCode_msg['ns'] = "Session not detected. Make sure you have cookies enabled."; cah.$.ErrorCode_msg['ccf'] = "Cannot find Cardcast deck with given ID. If you just added this deck to Cardcast, wait a few minutes and try again."; cah.$.ErrorCode_msg['nyt'] = "It is not your turn to play a card."; @@ -267,6 +279,7 @@ cah.$.AjaxResponse.SERIAL = "s"; cah.$.AjaxResponse.NAMES = "nl"; cah.$.AjaxResponse.PERSISTENT_ID = "pid"; cah.$.AjaxResponse.GAMES = "gl"; +cah.$.AjaxResponse.SIGIL = "?"; cah.$.AjaxResponse.PLAYER_INFO = "pi"; cah.$.AjaxResponse.GAME_ID = "gid"; cah.$.AjaxResponse.WHITE_CARDS = "wc"; @@ -281,6 +294,7 @@ cah.$.AjaxResponse.NEXT = "next"; cah.$.AjaxResponse.GAME_INFO = "gi"; cah.$.AjaxResponse.CARD_ID = "cid"; cah.$.AjaxResponse.ERROR = "e"; +cah.$.AjaxResponse.ID_CODE = "idc"; cah.$.AjaxResponse.CARD_SETS = "css"; cah.$.AjaxRequest = function() { @@ -288,17 +302,18 @@ cah.$.AjaxRequest = function() { }; cah.$.AjaxRequest.prototype.dummyForAutocomplete = undefined; cah.$.AjaxRequest.SERIAL = "s"; -cah.$.AjaxRequest.GAME_OPTIONS = "go"; -cah.$.AjaxRequest.MESSAGE = "m"; cah.$.AjaxRequest.OP = "o"; -cah.$.AjaxRequest.NICKNAME = "n"; cah.$.AjaxRequest.WALL = "wall"; -cah.$.AjaxRequest.PASSWORD = "pw"; cah.$.AjaxRequest.PERSISTENT_ID = "pid"; cah.$.AjaxRequest.EMOTE = "me"; -cah.$.AjaxRequest.CARD_ID = "cid"; cah.$.AjaxRequest.CARDCAST_ID = "cci"; cah.$.AjaxRequest.GAME_ID = "gid"; +cah.$.AjaxRequest.GAME_OPTIONS = "go"; +cah.$.AjaxRequest.MESSAGE = "m"; +cah.$.AjaxRequest.NICKNAME = "n"; +cah.$.AjaxRequest.PASSWORD = "pw"; +cah.$.AjaxRequest.CARD_ID = "cid"; +cah.$.AjaxRequest.ID_CODE = "idc"; cah.$.AjaxOperation = function() { // Dummy constructor to make Eclipse auto-complete. diff --git a/WebContent/js/cah.log.js b/WebContent/js/cah.log.js index 3939b5a..fe64447 100644 --- a/WebContent/js/cah.log.js +++ b/WebContent/js/cah.log.js @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012, Andy Janata + * Copyright (c) 2012-2018, Andy Janata * All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are permitted @@ -70,8 +70,10 @@ cah.log.status = function(text, opt_class) { * opt_class Optional CSS class to use for this message. * @param {boolean} * opt_allow_html Allow HTML to be used. + * @param {string} + * opt_title Optional title text for span. */ -cah.log.status_with_game = function(game_or_id, text, opt_class, opt_allow_html) { +cah.log.status_with_game = function(game_or_id, text, opt_class, opt_allow_html, opt_title) { var logElement; if (game_or_id === null) { logElement = cah.log.log; @@ -89,7 +91,12 @@ cah.log.status_with_game = function(game_or_id, text, opt_class, opt_allow_html) var scroll = (logElement.prop("scrollHeight") - logElement.height() - logElement .prop("scrollTop")) <= 5; - var node = $("
"); + var node; + if (opt_title) { + node = $("
"); + } else { + node = $("
"); + } var full_msg = "[" + new Date().toLocaleTimeString() + "] " + text + "\n"; if (opt_allow_html) { $(node[0]).html(full_msg); @@ -200,3 +207,17 @@ cah.log.debug = function(text, opt_obj) { } } }; + +/** + * Get the title text to use for the given idcode, or a null if there is no idcode. + * + * @param {string} + * idcode ID code, or logical false to not have a title. + */ +cah.log.getTitleForIdCode = function(idcode) { + if (idcode) { + return "Identification code: " + idcode; + } else { + return null; + } +} diff --git a/WebContent/js/cah.longpoll.handlers.js b/WebContent/js/cah.longpoll.handlers.js index 24d5d02..ed51e10 100644 --- a/WebContent/js/cah.longpoll.handlers.js +++ b/WebContent/js/cah.longpoll.handlers.js @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012, Andy Janata All rights reserved. + * Copyright (c) 2012-2018, Andy Janata All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are permitted * provided that the following conditions are met: @@ -93,17 +93,20 @@ cah.longpoll.EventHandlers[cah.$.LongPollEvent.BANNED] = function() { cah.longpoll.EventHandlers[cah.$.LongPollEvent.CHAT] = function(data) { var clazz = undefined; + var idcode = data[cah.$.LongPollResponse.ID_CODE]; + var title = cah.log.getTitleForIdCode(idcode); + var sigil = data[cah.$.LongPollResponse.SIGIL]; var from = data[cah.$.LongPollResponse.FROM]; var show = !cah.ignoreList[from]; var game = null; - if (data[cah.$.LongPollResponse.FROM_ADMIN]) { + if (sigil == cah.$.Sigil.ADMIN) { clazz = "admin"; show = true; } if (data[cah.$.LongPollResponse.WALL]) { // treat these specially - cah.log.everyWindow( - "Global message from " + from + ": " + data[cah.$.LongPollResponse.MESSAGE], clazz); + cah.log.everyWindow("Global message from " + sigil + from + ": " + + data[cah.$.LongPollResponse.MESSAGE], clazz, false, title); } else { if (cah.$.LongPollResponse.GAME_ID in data) { game = data[cah.$.LongPollResponse.GAME_ID]; @@ -113,9 +116,9 @@ cah.longpoll.EventHandlers[cah.$.LongPollEvent.CHAT] = function(data) { if (from != cah.nickname && show) { var message = data[cah.$.LongPollResponse.MESSAGE]; if (data[cah.$.LongPollResponse.EMOTE]) { - cah.log.status_with_game(game, "* " + from + " " + message, clazz); + cah.log.status_with_game(game, "* " + sigil + from + " " + message, clazz, false, title); } else { - cah.log.status_with_game(game, "<" + from + "> " + message, clazz); + cah.log.status_with_game(game, "<" + sigil + from + "> " + message, clazz, false, title); } } } diff --git a/build.properties.example b/build.properties.example index e13d25d..638afb3 100644 --- a/build.properties.example +++ b/build.properties.example @@ -4,6 +4,10 @@ pyx.max_games=25 pyx.include_inactive_cardsets=true pyx.broadcast_connects_and_disconnects=true pyx.global_chat_enabled=true +# allow identification codes to be used without HTTPS +pyx.insecure_id_allowed=true +# set this to some secure random value, and never change it, unless you want to break all codes +pyx.id_code_salt= # for production use, use postgres #hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect diff --git a/src/main/filtered-resources/WEB-INF/pyx.properties b/src/main/filtered-resources/WEB-INF/pyx.properties index 9708c21..ef955cb 100644 --- a/src/main/filtered-resources/WEB-INF/pyx.properties +++ b/src/main/filtered-resources/WEB-INF/pyx.properties @@ -4,6 +4,7 @@ pyx.server.max_users=${pyx.max_users} pyx.server.max_games=${pyx.max_games} pyx.server.broadcast_connects_and_disconnects=${pyx.broadcast_connects_and_disconnects} pyx.server.global_chat_enabled=${pyx.global_chat_enabled} +pyx.server.id_code_salt=${pyx.id_code_salt} pyx.build=${buildNumber} # this is NOT allowed to be changed during a reload, as metrics depend on previous events diff --git a/src/main/java/net/socialgamer/cah/CahModule.java b/src/main/java/net/socialgamer/cah/CahModule.java index bd509c7..20687e3 100644 --- a/src/main/java/net/socialgamer/cah/CahModule.java +++ b/src/main/java/net/socialgamer/cah/CahModule.java @@ -168,6 +168,23 @@ public class CahModule extends AbstractModule { } } + @Provides + @InsecureIdAllowed + Boolean provideInsecureIdAllowed() { + synchronized (properties) { + return Boolean.valueOf(properties.getProperty( + "pyx.server.insecure_id_allowed", "true")); + } + } + + @Provides + @IdCodeSalt + String provideIdCodeSalt() { + synchronized (properties) { + return properties.getProperty("pyx.server.id_code_salt", ""); + } + } + @Provides @CookieDomain String getCookieDomain() { @@ -218,6 +235,16 @@ public class CahModule extends AbstractModule { public @interface GlobalChatEnabled { } + @BindingAnnotation + @Retention(RetentionPolicy.RUNTIME) + public @interface InsecureIdAllowed { + } + + @BindingAnnotation + @Retention(RetentionPolicy.RUNTIME) + public @interface IdCodeSalt { + } + @BindingAnnotation @Retention(RetentionPolicy.RUNTIME) public @interface CookieDomain { diff --git a/src/main/java/net/socialgamer/cah/Constants.java b/src/main/java/net/socialgamer/cah/Constants.java index 8f699d8..11b1e90 100644 --- a/src/main/java/net/socialgamer/cah/Constants.java +++ b/src/main/java/net/socialgamer/cah/Constants.java @@ -1,5 +1,5 @@ /** - * Copyright (c) 2012-2017, Andy Janata + * Copyright (c) 2012-2018, Andy Janata * All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are permitted @@ -215,6 +215,7 @@ public class Constants { EMOTE("me"), GAME_ID("gid"), GAME_OPTIONS("go"), + ID_CODE("idc"), MESSAGE("m"), NICKNAME("n"), OP("o"), @@ -252,6 +253,8 @@ public class Constants { GAME_OPTIONS(AjaxRequest.GAME_OPTIONS), GAMES("gl"), HAND("h"), + @DuplicationAllowed + ID_CODE(AjaxRequest.ID_CODE), /** * Whether this client is reconnecting or not. */ @@ -267,6 +270,10 @@ public class Constants { @DuplicationAllowed PERSISTENT_ID(AjaxRequest.PERSISTENT_ID), PLAYER_INFO("pi"), + /** + * Sigil to display next to user's name. + */ + SIGIL("?"), @DuplicationAllowed SERIAL(AjaxRequest.SERIAL), WHITE_CARDS("wc"); @@ -324,6 +331,8 @@ public class Constants { GAME_FULL("gf", "That game is full. Join another."), INVALID_CARD("ic", "Invalid card specified."), INVALID_GAME("ig", "Invalid game specified."), + INVALID_ID_CODE("iid", "Identification code, if provided, must be between 8 and 100 characters," + + " inclusive."), /** * TODO this probably should be pulled in from a static inside the RegisterHandler. */ @@ -468,7 +477,9 @@ public class Constants { FROM("f"), /** * A chat message is from an admin. This is going to be done with IP addresses for now. + * @deprecated Compare the SIGIL field to Sigil.ADMIN. */ + @Deprecated FROM_ADMIN("fa"), @DuplicationAllowed GAME_ID(AjaxResponse.GAME_ID), @@ -477,6 +488,8 @@ public class Constants { GAME_STATE("gs"), @DuplicationAllowed HAND(AjaxResponse.HAND), + @DuplicationAllowed + ID_CODE(AjaxRequest.ID_CODE), /** * The delay until the next game round begins. */ @@ -493,6 +506,11 @@ public class Constants { */ REASON("qr"), ROUND_WINNER("rw"), + /** + * Sigil to display next to user's name. + */ + @DuplicationAllowed + SIGIL(AjaxResponse.SIGIL), TIMESTAMP("ts"), @DuplicationAllowed WALL(AjaxRequest.WALL), @@ -516,6 +534,24 @@ public class Constants { } } + /** + * User sigils. Displayed before the user's name. + */ + public enum Sigil { + ADMIN("@"), ID_CODE("+"), NORMAL_USER(""); + + private final String sigil; + + Sigil(final String sigil) { + this.sigil = sigil; + } + + @Override + public String toString() { + return sigil; + } + } + /** * Data fields for white cards. */ diff --git a/src/main/java/net/socialgamer/cah/data/ConnectedUsers.java b/src/main/java/net/socialgamer/cah/data/ConnectedUsers.java index 5cf35bb..1588ceb 100644 --- a/src/main/java/net/socialgamer/cah/data/ConnectedUsers.java +++ b/src/main/java/net/socialgamer/cah/data/ConnectedUsers.java @@ -1,5 +1,5 @@ /** - * Copyright (c) 2012-2017, Andy Janata + * Copyright (c) 2012-2018, Andy Janata * All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are permitted @@ -35,6 +35,13 @@ import java.util.concurrent.TimeUnit; import javax.annotation.Nullable; +import org.apache.log4j.Logger; + +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; +import com.maxmind.geoip2.model.CityResponse; + import net.socialgamer.cah.CahModule.BroadcastConnectsAndDisconnects; import net.socialgamer.cah.CahModule.MaxUsers; import net.socialgamer.cah.Constants.DisconnectReason; @@ -46,13 +53,6 @@ import net.socialgamer.cah.data.QueuedMessage.MessageType; import net.socialgamer.cah.metrics.GeoIP; import net.socialgamer.cah.metrics.Metrics; -import org.apache.log4j.Logger; - -import com.google.inject.Inject; -import com.google.inject.Provider; -import com.google.inject.Singleton; -import com.maxmind.geoip2.model.CityResponse; - /** * Class that holds all users connected to the server, and provides functions to operate on said @@ -124,8 +124,8 @@ public class ConnectedUsers { user.toString(), users.size(), maxUsers)); return ErrorCode.TOO_MANY_USERS; } else { - logger.info(String.format("New user %s from %s (admin=%b)", user.toString(), - user.getHostname(), user.isAdmin())); + logger.info(String.format("New user %s from %s (admin=%b, id=%s)", user.toString(), + user.getHostname(), user.isAdmin(), user.getIdCode())); users.put(user.getNickname().toLowerCase(), user); if (broadcastConnectsAndDisconnectsProvider.get()) { final HashMap data = new HashMap(); @@ -191,7 +191,8 @@ public class ConnectedUsers { */ private void notifyRemoveUser(final User user, final DisconnectReason reason) { // Games are informed about the user leaving when the user object is marked invalid. - if (broadcastConnectsAndDisconnectsProvider.get()) { + if (broadcastConnectsAndDisconnectsProvider.get() || reason == DisconnectReason.BANNED + || reason == DisconnectReason.KICKED) { final HashMap data = new HashMap(); data.put(LongPollResponse.EVENT, LongPollEvent.PLAYER_LEAVE.toString()); data.put(LongPollResponse.NICKNAME, user.getNickname()); diff --git a/src/main/java/net/socialgamer/cah/data/User.java b/src/main/java/net/socialgamer/cah/data/User.java index abb27ea..e9ad7bc 100644 --- a/src/main/java/net/socialgamer/cah/data/User.java +++ b/src/main/java/net/socialgamer/cah/data/User.java @@ -1,5 +1,5 @@ /** - * Copyright (c) 2012-2017, Andy Janata + * Copyright (c) 2012-2018, Andy Janata * All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are permitted @@ -32,12 +32,13 @@ import java.util.concurrent.PriorityBlockingQueue; import javax.annotation.Nullable; +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; + import net.sf.uadetector.ReadableUserAgent; import net.sf.uadetector.service.UADetectorServiceFactory; import net.socialgamer.cah.CahModule.UniqueId; - -import com.google.inject.Inject; -import com.google.inject.assistedinject.Assisted; +import net.socialgamer.cah.Constants.Sigil; /** @@ -49,6 +50,8 @@ public class User { private final String nickname; + private final String idCode; + private final PriorityBlockingQueue queuedMessages; private final Object queuedMessageSynchronization = new Object(); @@ -83,6 +86,9 @@ public class User { * * @param nickname * The user's nickname. + * @param idCode + * The user's ID code, after hashing with salt and their name, or the empty string if + * none provided. * @param hostname * The user's Internet hostname (which will likely just be their IP address). * @param isAdmin @@ -91,9 +97,14 @@ public class User { * This user's persistent (cross-session) ID. * @param sessionId * The unique ID of this session for this server instance. + * @param clientLanguage + * The language of the user's web browser/client. + * @param clientAgent + * The name of the user's web browser/client. */ @Inject public User(@Assisted("nickname") final String nickname, + @Assisted("idCode") final String idCode, @Assisted("hostname") final String hostname, @Assisted final boolean isAdmin, @Assisted("persistentId") final String persistentId, @@ -101,6 +112,7 @@ public class User { @Nullable @Assisted("clientLanguage") final String clientLanguage, @Nullable @Assisted("clientAgent") final String clientAgent) { this.nickname = nickname; + this.idCode = idCode; this.hostname = hostname; this.isAdmin = isAdmin; this.persistentId = persistentId; @@ -111,10 +123,11 @@ public class User { } public interface Factory { - User create(@Assisted("nickname") String nickname, @Assisted("hostname") String hostname, - boolean isAdmin, @Assisted("persistentId") String persistentId, - @Assisted("clientLanguage") String clientLanguage, - @Assisted("clientAgent") String clientAgent); + User create(@Assisted("nickname") String nickname, @Assisted("idCode") String idCode, + @Assisted("hostname") String hostname, boolean isAdmin, + @Assisted("persistentId") String persistentId, + @Nullable @Assisted("clientLanguage") String clientLanguage, + @Nullable @Assisted("clientAgent") String clientAgent); } /** @@ -185,6 +198,20 @@ public class User { return isAdmin; } + public String getIdCode() { + return idCode; + } + + public Sigil getSigil() { + if (isAdmin) { + return Sigil.ADMIN; + } else if (!idCode.isEmpty()) { + return Sigil.ID_CODE; + } else { + return Sigil.NORMAL_USER; + } + } + public String getSessionId() { return sessionId; } diff --git a/src/main/java/net/socialgamer/cah/handlers/ChatHandler.java b/src/main/java/net/socialgamer/cah/handlers/ChatHandler.java index 082c0c4..477208d 100644 --- a/src/main/java/net/socialgamer/cah/handlers/ChatHandler.java +++ b/src/main/java/net/socialgamer/cah/handlers/ChatHandler.java @@ -106,6 +106,8 @@ public class ChatHandler extends Handler { broadcastData.put(LongPollResponse.EVENT, LongPollEvent.CHAT.toString()); broadcastData.put(LongPollResponse.FROM, user.getNickname()); broadcastData.put(LongPollResponse.MESSAGE, message); + broadcastData.put(LongPollResponse.ID_CODE, user.getIdCode()); + broadcastData.put(LongPollResponse.SIGIL, user.getSigil().toString()); if (user.isAdmin()) { broadcastData.put(LongPollResponse.FROM_ADMIN, true); } diff --git a/src/main/java/net/socialgamer/cah/handlers/GameChatHandler.java b/src/main/java/net/socialgamer/cah/handlers/GameChatHandler.java index 3256e1b..6d80221 100644 --- a/src/main/java/net/socialgamer/cah/handlers/GameChatHandler.java +++ b/src/main/java/net/socialgamer/cah/handlers/GameChatHandler.java @@ -1,16 +1,16 @@ /** - * Copyright (c) 2012, Andy Janata + * Copyright (c) 2012-2018, Andy Janata * All rights reserved. - * + * * Redistribution and use in source and binary forms, with or without modification, are permitted * provided that the following conditions are met: - * + * * * Redistributions of source code must retain the above copyright notice, this list of conditions * and the following disclaimer. * * Redistributions in binary form must reproduce the above copyright notice, this list of * conditions and the following disclaimer in the documentation and/or other materials provided * with the distribution. - * + * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR @@ -28,6 +28,8 @@ import java.util.Map; import javax.servlet.http.HttpSession; +import com.google.inject.Inject; + import net.socialgamer.cah.Constants; import net.socialgamer.cah.Constants.AjaxOperation; import net.socialgamer.cah.Constants.AjaxRequest; @@ -41,12 +43,10 @@ import net.socialgamer.cah.data.GameManager; import net.socialgamer.cah.data.QueuedMessage.MessageType; import net.socialgamer.cah.data.User; -import com.google.inject.Inject; - /** * Handler for chat messages. - * + * * @author Andy Janata (ajanata@socialgamer.net) */ public class GameChatHandler extends GameWithPlayerHandler { @@ -91,6 +91,8 @@ public class GameChatHandler extends GameWithPlayerHandler { broadcastData.put(LongPollResponse.FROM, user.getNickname()); broadcastData.put(LongPollResponse.MESSAGE, message); broadcastData.put(LongPollResponse.FROM_ADMIN, user.isAdmin()); + broadcastData.put(LongPollResponse.ID_CODE, user.getIdCode()); + broadcastData.put(LongPollResponse.SIGIL, user.getSigil().toString()); broadcastData.put(LongPollResponse.GAME_ID, game.getId()); broadcastData.put(LongPollResponse.EMOTE, emote); game.broadcastToPlayers(MessageType.CHAT, broadcastData); diff --git a/src/main/java/net/socialgamer/cah/handlers/NamesHandler.java b/src/main/java/net/socialgamer/cah/handlers/NamesHandler.java index c2d4fec..b011d10 100644 --- a/src/main/java/net/socialgamer/cah/handlers/NamesHandler.java +++ b/src/main/java/net/socialgamer/cah/handlers/NamesHandler.java @@ -1,16 +1,16 @@ /** - * Copyright (c) 2012, Andy Janata + * Copyright (c) 2012-2018, Andy Janata * All rights reserved. - * + * * Redistribution and use in source and binary forms, with or without modification, are permitted * provided that the following conditions are met: - * + * * * Redistributions of source code must retain the above copyright notice, this list of conditions * and the following disclaimer. * * Redistributions in binary form must reproduce the above copyright notice, this list of * conditions and the following disclaimer in the documentation and/or other materials provided * with the distribution. - * + * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR @@ -31,6 +31,8 @@ import java.util.Map; import javax.servlet.http.HttpSession; +import com.google.inject.Inject; + import net.socialgamer.cah.Constants.AjaxOperation; import net.socialgamer.cah.Constants.AjaxResponse; import net.socialgamer.cah.Constants.ReturnableData; @@ -38,12 +40,10 @@ import net.socialgamer.cah.RequestWrapper; import net.socialgamer.cah.data.ConnectedUsers; import net.socialgamer.cah.data.User; -import com.google.inject.Inject; - /** * Handler to get the names of all players connected to the server. - * + * * @author Andy Janata (ajanata@socialgamer.net) */ public class NamesHandler extends Handler { @@ -61,11 +61,10 @@ public class NamesHandler extends Handler { public Map handle(final RequestWrapper request, final HttpSession session) { final Map ret = new HashMap(); - // TODO once there are multiple rooms, we need which one was asked for final Collection userList = users.getUsers(); final List names = new ArrayList(userList.size()); for (final User u : userList) { - names.add(u.getNickname()); + names.add(u.getSigil() + u.getNickname()); } ret.put(AjaxResponse.NAMES, names); return ret; diff --git a/src/main/java/net/socialgamer/cah/handlers/RegisterHandler.java b/src/main/java/net/socialgamer/cah/handlers/RegisterHandler.java index a31f5c3..1632407 100644 --- a/src/main/java/net/socialgamer/cah/handlers/RegisterHandler.java +++ b/src/main/java/net/socialgamer/cah/handlers/RegisterHandler.java @@ -1,5 +1,5 @@ /** - * Copyright (c) 2012-2017, Andy Janata + * Copyright (c) 2012-2018, Andy Janata * All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are permitted @@ -30,6 +30,12 @@ import java.util.regex.Pattern; import javax.servlet.http.HttpSession; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.HttpHeaders; + +import com.google.inject.Inject; +import com.google.inject.Provider; + import net.socialgamer.cah.CahModule.BanList; import net.socialgamer.cah.CahModule.UserPersistentId; import net.socialgamer.cah.Constants; @@ -42,12 +48,7 @@ import net.socialgamer.cah.Constants.SessionAttribute; import net.socialgamer.cah.RequestWrapper; import net.socialgamer.cah.data.ConnectedUsers; import net.socialgamer.cah.data.User; - -import org.apache.commons.lang3.StringUtils; -import org.apache.http.HttpHeaders; - -import com.google.inject.Inject; -import com.google.inject.Provider; +import net.socialgamer.cah.util.IdCodeMangler; /** @@ -59,21 +60,25 @@ public class RegisterHandler extends Handler { public static final String OP = AjaxOperation.REGISTER.toString(); - private static final Pattern validName = Pattern.compile("[a-zA-Z_][a-zA-Z0-9_]{2,29}"); + private static final Pattern VALID_NAME = Pattern.compile("[a-zA-Z_][a-zA-Z0-9_]{2,29}"); + private static final int ID_CODE_MIN_LENGTH = 8; + private static final int ID_CODE_MAX_LENGTH = 100; private final ConnectedUsers users; private final Set banList; private final User.Factory userFactory; private final Provider persistentIdProvider; + private final IdCodeMangler idCodeManger; @Inject public RegisterHandler(final ConnectedUsers users, @BanList final Set banList, - final User.Factory userFactory, + final User.Factory userFactory, final IdCodeMangler idCodeMangler, @UserPersistentId final Provider persistentIdProvider) { this.users = users; this.banList = banList; this.userFactory = userFactory; this.persistentIdProvider = persistentIdProvider; + this.idCodeManger = idCodeMangler; } @Override @@ -87,9 +92,13 @@ public class RegisterHandler extends Handler { if (request.getParameter(AjaxRequest.NICKNAME) == null) { return error(ErrorCode.NO_NICK_SPECIFIED); + } else if (request.getParameter(AjaxRequest.ID_CODE) != null + && (request.getParameter(AjaxRequest.ID_CODE).trim().length() < ID_CODE_MIN_LENGTH + || request.getParameter(AjaxRequest.ID_CODE).trim().length() > ID_CODE_MAX_LENGTH)) { + return error(ErrorCode.INVALID_ID_CODE); } else { final String nick = request.getParameter(AjaxRequest.NICKNAME).trim(); - if (!validName.matcher(nick).matches()) { + if (!VALID_NAME.matcher(nick).matches()) { return error(ErrorCode.INVALID_NICK); } else if ("xyzzy".equalsIgnoreCase(nick)) { return error(ErrorCode.RESERVED_NICK); @@ -99,7 +108,10 @@ public class RegisterHandler extends Handler { persistentId = persistentIdProvider.get(); } - final User user = userFactory.create(nick, request.getRemoteAddr(), + final String mangledIdCode = idCodeManger.mangle(nick, + request.getParameter(AjaxRequest.ID_CODE)); + + final User user = userFactory.create(nick, mangledIdCode, request.getRemoteAddr(), Constants.ADMIN_IP_ADDRESSES.contains(request.getRemoteAddr()), persistentId, request.getHeader(HttpHeaders.ACCEPT_LANGUAGE), request.getHeader(HttpHeaders.USER_AGENT)); @@ -116,6 +128,8 @@ public class RegisterHandler extends Handler { data.put(AjaxResponse.NICKNAME, nick); data.put(AjaxResponse.PERSISTENT_ID, persistentId); + data.put(AjaxResponse.ID_CODE, user.getIdCode()); + data.put(AjaxResponse.SIGIL, user.getSigil().toString()); } else { return error(errorCode); } diff --git a/src/main/java/net/socialgamer/cah/servlets/JavascriptConfigServlet.java b/src/main/java/net/socialgamer/cah/servlets/JavascriptConfigServlet.java index cb08738..1a84cac 100644 --- a/src/main/java/net/socialgamer/cah/servlets/JavascriptConfigServlet.java +++ b/src/main/java/net/socialgamer/cah/servlets/JavascriptConfigServlet.java @@ -38,6 +38,7 @@ import com.google.inject.Key; import net.socialgamer.cah.CahModule.CookieDomain; import net.socialgamer.cah.CahModule.GlobalChatEnabled; +import net.socialgamer.cah.CahModule.InsecureIdAllowed; import net.socialgamer.cah.StartupUtils; @@ -77,8 +78,11 @@ public class JavascriptConfigServlet extends HttpServlet { final Injector injector = (Injector) getServletContext().getAttribute(StartupUtils.INJECTOR); final String cookieDomain = injector.getInstance(Key.get(String.class, CookieDomain.class)); final Boolean globalChatEnabled = injector.getInstance(Key.get(Boolean.class, GlobalChatEnabled.class)); + final Boolean insecureIdAllowed = injector + .getInstance(Key.get(Boolean.class, InsecureIdAllowed.class)); builder.append(String.format("cah.COOKIE_DOMAIN = '%s';\n", cookieDomain)); builder.append(String.format("cah.GLOBAL_CHAT_ENABLED = %b;\n", globalChatEnabled)); + builder.append(String.format("cah.INSECURE_ID_ALLOWED = %b;\n", insecureIdAllowed)); resp.setContentType("text/javascript"); final PrintWriter out = resp.getWriter(); diff --git a/src/main/java/net/socialgamer/cah/util/IdCodeMangler.java b/src/main/java/net/socialgamer/cah/util/IdCodeMangler.java new file mode 100644 index 0000000..839c0f2 --- /dev/null +++ b/src/main/java/net/socialgamer/cah/util/IdCodeMangler.java @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2018, Andy Janata + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted + * provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this list of conditions + * and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, this list of + * conditions and the following disclaimer in the documentation and/or other materials provided + * with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY + * WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package net.socialgamer.cah.util; + +import java.nio.charset.Charset; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +import org.apache.log4j.Logger; + +import com.google.inject.Inject; + +import net.socialgamer.cah.CahModule.IdCodeSalt; + + +public class IdCodeMangler { + private static final Logger LOG = Logger.getLogger(IdCodeMangler.class); + + private final String salt; + private final Base64.Encoder encoder = Base64.getEncoder(); + + @Inject + public IdCodeMangler(@IdCodeSalt final String salt) { + this.salt = salt; + } + + public String mangle(final String username, final String idCode) { + if (null == idCode || idCode.trim().isEmpty()) { + return ""; + } + try { + final MessageDigest md = MessageDigest.getInstance("SHA-256"); + final byte[] plaintext = (salt + username + idCode.trim()).getBytes(Charset.forName("UTF-8")); + // 32 byte output + final byte[] digest = md.digest(plaintext); + final byte[] condensed = new byte[8]; + for (int i = 0; i < 8; i++) { + condensed[i] = (byte) (digest[i] ^ digest[i + 8] ^ digest[i + 16] ^ digest[i + 24]); + } + return encoder.encodeToString(condensed).substring(0, 11); + } catch (final NoSuchAlgorithmException e) { + LOG.error("Unable to mangle ID code.", e); + return ""; + } + } +} diff --git a/src/test/java/net/socialgamer/cah/data/GameTest.java b/src/test/java/net/socialgamer/cah/data/GameTest.java index 1ca2c0c..13888e1 100644 --- a/src/test/java/net/socialgamer/cah/data/GameTest.java +++ b/src/test/java/net/socialgamer/cah/data/GameTest.java @@ -1,5 +1,5 @@ /** - * Copyright (c) 2012-2017, Andy Janata + * Copyright (c) 2012-2018, Andy Janata * All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are permitted @@ -38,13 +38,13 @@ import java.util.Collection; import java.util.HashMap; import java.util.concurrent.ScheduledThreadPoolExecutor; +import org.junit.Before; +import org.junit.Test; + import net.socialgamer.cah.data.Game.TooManyPlayersException; import net.socialgamer.cah.data.QueuedMessage.MessageType; import net.socialgamer.cah.metrics.Metrics; -import org.junit.Before; -import org.junit.Test; - /** * Tests for {@code Game}. @@ -78,8 +78,8 @@ public class GameTest { expectLastCall().once(); replay(gmMock); - final User user1 = new User("test1", "test.lan", false, "1", "1", "en-US", "JUnit"); - final User user2 = new User("test2", "test.lan", false, "2", "2", "en-US", "JUnit"); + final User user1 = new User("test1", null, "test.lan", false, "1", "1", "en-US", "JUnit"); + final User user2 = new User("test2", null, "test.lan", false, "2", "2", "en-US", "JUnit"); game.addPlayer(user1); game.addPlayer(user2);