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.
This commit is contained in:
Andy Janata 2018-03-02 17:24:58 -08:00
parent dc31b1f2ce
commit 7a24c652ac
20 changed files with 346 additions and 85 deletions

View File

@ -107,9 +107,14 @@ HttpSession hSession = request.getSession(true);
The PAX panel sets have also been removed.</li>
</ul>
<div id="nickbox">
Nickname:
<label for="nickname">Nickname:</label>
<input type="text" id="nickname" value="" maxlength="30" role="textbox"
aria-label="Enter your nickname." data-lpignore="true" />
<label for="idcode">
<dfn title="Only available via HTTPS. Provide a secret identification code to positively identify yourself in the chat.">
Optional identification code:</dfn></label>
<input type="password" id="idcode" value="" maxlength="100" disabled="disabled"
aria-label="Optionally enter and identification code." />
<input type="button" id="nicknameconfirm" value="Set" />
<span id="nickbox_error" class="error"></span>
</div>

View File

@ -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.
*

View File

@ -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();

View File

@ -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);

View File

@ -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.

View File

@ -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 = $("<span></span><br/>");
var node;
if (opt_title) {
node = $("<span title ='" + opt_title + "'></span><br/>");
} else {
node = $("<span></span><br/>");
}
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;
}
}

View File

@ -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);
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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.
*/

View File

@ -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<ReturnableData, Object> data = new HashMap<ReturnableData, Object>();
@ -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<ReturnableData, Object> data = new HashMap<ReturnableData, Object>();
data.put(LongPollResponse.EVENT, LongPollEvent.PLAYER_LEAVE.toString());
data.put(LongPollResponse.NICKNAME, user.getNickname());

View File

@ -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<QueuedMessage> 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;
}

View File

@ -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);
}

View File

@ -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
@ -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,8 +43,6 @@ 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.
@ -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);

View File

@ -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
@ -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,8 +40,6 @@ 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.
@ -61,11 +61,10 @@ public class NamesHandler extends Handler {
public Map<ReturnableData, Object> handle(final RequestWrapper request,
final HttpSession session) {
final Map<ReturnableData, Object> ret = new HashMap<ReturnableData, Object>();
// TODO once there are multiple rooms, we need which one was asked for
final Collection<User> userList = users.getUsers();
final List<String> names = new ArrayList<String>(userList.size());
for (final User u : userList) {
names.add(u.getNickname());
names.add(u.getSigil() + u.getNickname());
}
ret.put(AjaxResponse.NAMES, names);
return ret;

View File

@ -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<String> banList;
private final User.Factory userFactory;
private final Provider<String> persistentIdProvider;
private final IdCodeMangler idCodeManger;
@Inject
public RegisterHandler(final ConnectedUsers users, @BanList final Set<String> banList,
final User.Factory userFactory,
final User.Factory userFactory, final IdCodeMangler idCodeMangler,
@UserPersistentId final Provider<String> 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);
}

View File

@ -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();

View File

@ -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 "";
}
}
}

View File

@ -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);