/** * Copyright 2010 The University of Nottingham * * This file is part of lobbyservice. * * lobbyservice is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * lobbyservice is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with lobbyservice. If not, see <http://www.gnu.org/licenses/>. * */ package uk.ac.horizon.ug.lobby.browser; import java.io.IOException; import java.nio.charset.Charset; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.List; import java.util.Random; import java.util.logging.Level; import java.util.logging.Logger; import javax.crypto.Mac; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import javax.persistence.EntityManager; import javax.persistence.EntityTransaction; import javax.servlet.http.HttpServletResponse; import org.json.JSONException; import org.json.JSONObject; import com.google.appengine.api.datastore.Key; import uk.ac.horizon.ug.lobby.Constants; import uk.ac.horizon.ug.lobby.RequestException; import uk.ac.horizon.ug.lobby.model.Account; import uk.ac.horizon.ug.lobby.model.EMF; import uk.ac.horizon.ug.lobby.model.GUIDFactory; import uk.ac.horizon.ug.lobby.model.GameClient; import uk.ac.horizon.ug.lobby.model.GameClientStatus; import uk.ac.horizon.ug.lobby.model.GameClientTemplate; import uk.ac.horizon.ug.lobby.protocol.ClientRequirement; import uk.ac.horizon.ug.lobby.protocol.ClientRequirementFailureType; import uk.ac.horizon.ug.lobby.protocol.GameJoinRequest; import uk.ac.horizon.ug.lobby.protocol.GameJoinResponse; import uk.ac.horizon.ug.lobby.protocol.GameJoinResponseStatus; import uk.ac.horizon.ug.lobby.protocol.JSONUtils; /** * @author cmg * */ public class JoinUtils implements Constants { static Logger logger = Logger.getLogger(JoinUtils.class.getName()); /** info from authenticate */ public static class JoinAuthInfo { public GameClient gc = null; public Account account = null; public boolean anonymous = false; } /** client info - for create anon */ public static class ClientInfo { public String characteristicsJson; /** cons */ public ClientInfo() {} /** * @param characteristicsJson */ public ClientInfo(String characteristicsJson) { super(); this.characteristicsJson = characteristicsJson; } /** * @return the characteristicsJson */ public String getCharacteristicsJson() { return characteristicsJson; } /** * @param characteristicsJson the characteristicsJson to set */ public void setCharacteristicsJson(String characteristicsJson) { this.characteristicsJson = characteristicsJson; } } /** initial check/authenticate join request - where optional. * Never creates Clients - GameClient return may be null. * @param clientId The ID of the GameClient (from the request) * @param deviceId The (optional) ID of the device, for use as fallback default clientId * @param link The text of the request line (for request authentication) * @param auth The text of the request signature line (for request authentication) * @param clientInfo ClientInfo for create new anonymous client (optional); null if not to create * @return null if not permitted (response sent) */ public static JoinAuthInfo authenticateOptional(String clientId, String deviceId, String requestUri, String line, String auth) { try { return authenticateInternal(clientId, deviceId, null, true, requestUri, line, auth, true); } catch (Exception e) { logger.log(Level.WARNING,"Problem doing authenticateOptional - fall back to unknown", e); JoinAuthInfo jai = new JoinAuthInfo(); jai.anonymous = true; return jai; } } /** initial check/authenticate join request. * @param clientId The ID of the GameClient (from the request) * @param deviceId The (optional) ID of the device, for use as fallback default clientId * @param link The text of the request line (for request authentication) * @param auth The text of the request signature line (for request authentication) * @param clientInfo ClientInfo for create new anonymous client (optional); null if not to create * @return null if not permitted (response sent) * @throws IOException * @throws RequestException */ public static JoinAuthInfo authenticate(String clientId, String deviceId, ClientInfo clientInfo, boolean allowAnonymousClients, String requestUri, String line, String auth) throws IOException, JoinException, RequestException { return authenticateInternal(clientId, deviceId, clientInfo, allowAnonymousClients, requestUri, line, auth, false); } /** initial check/authenticate join request. * @param clientId The ID of the GameClient (from the request) * @param deviceId The (optional) ID of the device, for use as fallback default clientId * @param link The text of the request line (for request authentication) * @param auth The text of the request signature line (for request authentication) * @param clientInfo ClientInfo for create new anonymous client (optional); null if not to create * @return null if not permitted (response sent) * @throws IOException * @throws RequestException */ private static JoinAuthInfo authenticateInternal(String clientId, String deviceId, ClientInfo clientInfo, boolean allowAnonymousClients, String requestUri, String line, String auth, boolean optional) throws IOException, JoinException, RequestException { // default JoinAuthInfo jai = new JoinAuthInfo(); jai.anonymous = true; EntityManager em = EMF.get().createEntityManager(); EntityTransaction et = em.getTransaction(); et.begin(); try { GameClient gc = null; Account account = null; boolean anonymous = false; if (clientId==null) { // anonymous attempt if (!allowAnonymousClients) { if (optional) // unknown return jai; throw new JoinException(GameJoinResponseStatus.ERROR_USER_AUTHENTICATION_REQUIRED, "This game does not allow anonymous players"); } // ensure possible createAnonymousClient will be atomic wrt to the next check... et.rollback(); et.begin(); // does default client already exist? if (deviceId!=null) { clientId = deviceId; Key clientKey = GameClient.idToKey(clientId); gc = em.find(GameClient.class, clientKey); if (gc!=null) { if (gc.getAccountKey()!=null || gc.getSharedSecret()!=null) { logger.warning("Client deviceId="+deviceId+" already exists, non-anonymous"); if (optional) // fallback to unknown return jai; throw new JoinException(GameJoinResponseStatus.ERROR_CLIENT_AUTHENTICATION_REQUIRED, "This deviceId is already in use as an authenticated client"); } else { logger.info("Using default anonymous client with deviceId="+deviceId); // existing anonymous } } } if (gc==null) { if (clientInfo==null || optional) { if (optional) // unknown return jai; // implies do not create throw new JoinException(GameJoinResponseStatus.ERROR_CLIENT_AUTHENTICATION_REQUIRED, "Anonymous client does not exist"); } // won't be optional if we got here // create is done in our transaction gc = createAnonymousClient(em, clientId, clientInfo); } // COMMIT! et.commit(); et.begin(); anonymous = true; // anonymous client... } else { // identified client Key clientKey = GameClient.idToKey(clientId); gc = em.find(GameClient.class, clientKey); if (gc==null) { if (optional) // unknown return jai; throw new JoinException(GameJoinResponseStatus.ERROR_AUTHENTICATION_FAILED, "GameClient "+clientId+" unknown"); } et.rollback(); et.begin(); if (gc.getAccountKey()!=null) { account = em.find(Account.class, gc.getAccountKey()); if (account==null) { logger.warning("GameClient "+clientId+" found but Account missing: "+gc); if (optional) // unknown return jai; throw new JoinException(GameJoinResponseStatus.ERROR_AUTHENTICATION_FAILED, "This clientId is not usable"); } } else { if (!allowAnonymousClients) { if (optional) // fall back to unknown return jai; throw new JoinException(GameJoinResponseStatus.ERROR_USER_AUTHENTICATION_REQUIRED, "This game does not allow anonymous players"); } anonymous = true; } // authenticate if (!authenticateRequest(requestUri, line, gc, account, auth)) { if (optional) // fall back to unknown return jai; throw new RequestException(HttpServletResponse.SC_FORBIDDEN, "Authentication failed"); } // authenticated with gc and/or account... } jai.account = account; jai.anonymous = anonymous; jai.gc = gc; return jai; } // throws JoinException // throws RequestException finally { if (et.isActive()) et.rollback(); em.close(); } } /** default poll delay (30s?!) */ public static final int DEFAULT_POLL_INTERVAL_MS = 30000; public static void setTryLater(GameJoinResponse gjresp) { gjresp.setPlayTime(System.currentTimeMillis()+DEFAULT_POLL_INTERVAL_MS); setError(gjresp, GameJoinResponseStatus.TRY_LATER, "The game is not available right now - please try again in a minute"); } /** send 'error' in our GameJoinResponse (i.e. not HTTP error) */ private static void sendError(HttpServletResponse resp, GameJoinResponse gjresp, GameJoinResponseStatus errorStatus) throws IOException { // TODO user friendly sendError(resp, gjresp, errorStatus, errorStatus.name()); } /** send 'error' in our GameJoinResponse (i.e. not HTTP error) */ private static void sendError(HttpServletResponse resp, GameJoinResponse gjresp, GameJoinResponseStatus errorStatus, String message) throws IOException { gjresp.setStatus(errorStatus); gjresp.setMessage(message); logger.warning("Sending error response: "+gjresp); JSONUtils.sendGameJoinResponse(resp, gjresp); } /** set 'error' in our GameJoinResponse (i.e. not HTTP error) */ public static void setError(GameJoinResponse gjresp, GameJoinResponseStatus errorStatus, String message) { gjresp.setStatus(errorStatus); gjresp.setMessage(message); logger.warning("Setting error response: "+gjresp); } private static SecureRandom secureRandom; private static Random random; private static final int DEFAULT_SHARED_SECRET_BITS = 128; public static synchronized String createClientSharedSecret() { return createClientSharedSecret(DEFAULT_SHARED_SECRET_BITS); } public static synchronized String createClientSharedSecret(int bits) { if (secureRandom==null && random==null) { try { secureRandom = SecureRandom.getInstance("SHA1PRNG"); } catch (NoSuchAlgorithmException e) { logger.warning("Could not create SecureRandom: "+e.toString()); random = new Random(System.currentTimeMillis() ^ e.hashCode()); } } byte bytes[] = new byte[(bits+7)/8]; if (secureRandom!=null) { secureRandom.nextBytes(bytes); } else random.nextBytes(bytes); return toHex(bytes); } public static String toHex(byte bytes[]) { StringBuilder sb = new StringBuilder(); for (int i=0; i<bytes.length; i++) { sb.append(nibble((bytes[i] >> 4) & 0xf)); sb.append(nibble(bytes[i] & 0xf)); } return sb.toString(); } public static char nibble(int i) { if (i<10) return (char)('0'+i); else return (char)('a'+i-10); } public static byte[] parseHex(String sharedSecretHex){ byte data[] = new byte[(sharedSecretHex.length()+1)/2]; for (int i=0; i<sharedSecretHex.length(); i++) { int nibble = 0; char c = sharedSecretHex.charAt(i); if (c>='0' && c<='9') nibble = (int)(c-'0'); else if (c>='a' && c<='f') nibble = (int)(10+c-'a'); else if (c>='A' && c<='F') nibble = (int)(10+c-'A'); if ((i&1)==0) data[i/2] = (byte)(/*data[i/2] | */(nibble << 4)); else data[i/2] = (byte)(data[i/2] | (nibble)); } return data; } public static boolean authenticateRequest(String requestUri, String line, GameClient gc, Account account, String auth) { logger.warning("Authenticate "+line+" with "+auth+" for "+gc+" ("+account+")"); // v.1 HMAC-SHA1 of bytes of line (UTF-8) if (auth==null || auth.length()==0) { logger.warning("Authenticate with no auth line"); return false; } String sharedSecret = gc.getSharedSecret(); if (sharedSecret==null || sharedSecret.length()==0) { logger.warning("Authenticate with no sharedSecret for "+gc); return false; } byte[] keyBytes = parseHex(sharedSecret); SecretKey key = new SecretKeySpec(keyBytes, "HmacSHA1"); //logger.info("sharedSecret="+sharedSecret+" ("+toHex(keyBytes)+"), key="+key); Mac m; try { m = Mac.getInstance("HmacSHA1"); m.init(key); // URI should already be %escaped? m.update(requestUri.getBytes(Charset.forName("ASCII"))); m.update((byte)0); m.update(line.getBytes(Charset.forName("UTF-8"))); byte[] mac = m.doFinal(); String smac = toHex(mac); if (smac.equals(auth)) { return true; } logger.warning("HMAC did not match: "+auth+" vs "+smac); return false; } catch (Exception e) { logger.log(Level.WARNING, "Error generating HMAC", e); } return false; } /** create and persist a new anonymous GameClient within calling transaction (for consistency) */ private static GameClient createAnonymousClient(EntityManager em, String clientId, ClientInfo clientInfo) { GameClient gc = new GameClient(); gc.setCreatedTime(System.currentTimeMillis()); gc.setStatus(GameClientStatus.ANONYMOUS); if (clientId==null) clientId = GUIDFactory.newGUID(); gc.setId(clientId); //gc.setKey(GameClient.idToKey(null, clientId)); if (clientInfo.getCharacteristicsJson()!=null) { gc.setCharacteristicsJson(clientInfo.getCharacteristicsJson()); } // nickname only for slot, not client //EntityTransaction et = em.getTransaction(); em.persist(gc); logger.info("Created anonymous client "+clientId); return gc; } public static List<GameClientTemplate> getGameClientTemplates(GameJoinRequest gjreq, String gameTemplateId) { return QueryGameTemplateServlet.getGameClientTemplates(gjreq.getClientTitle(), gjreq.getCharacteristicsJson(), gameTemplateId); } public static enum SatisfiesClientRequirements { YES, NO, MAYBE } public static SatisfiesClientRequirements satisfiesClientRequirements ( JSONObject characteristics, List<ClientRequirement> crs) { boolean uncertain = false; for (ClientRequirement cr : crs) { String key = cr.getCharacteristic(); boolean satisfied = false; if (characteristics.has(key)) { try { String value = characteristics.get(key).toString(); String exp = cr.getExpression(); satisfied = satisfiesConstraint(value, exp); } catch (JSONException e) { logger.log(Level.WARNING, "Characteristics missing expected key "+key+" - should not happen"); } } else { if ("UNDEFINED".equalsIgnoreCase(cr.getExpression())) // OK! satisfied = true; } if (!satisfied) { switch (cr.getFailure()) { case Fail: return SatisfiesClientRequirements.NO; case Continue: break; case Recheck: uncertain = true; break; } } } if (uncertain) return SatisfiesClientRequirements.MAYBE; return SatisfiesClientRequirements.YES; } private static boolean satisfiesConstraint(String value, String exp) { if ("UNDEFINED".equalsIgnoreCase(exp)) return value==null; if ("TRUE".equalsIgnoreCase(exp)) { return "TRUE".equalsIgnoreCase(value); } if ("FALSE".equalsIgnoreCase(exp)) { return !"TRUE".equalsIgnoreCase(value); } if (exp.startsWith("=")) return exp.substring(1).trim().equals(value); if (exp.startsWith("IN")) { String options [] = exp.substring(2).split("[(,]"); for (int i=0; i<options.length; i++) { if (options[i].equals(value)) return true; } return false; } if (exp.startsWith(">=")) { try { double dval = Double.parseDouble(value); double eval = Double.parseDouble(exp.substring(2).trim()); return dval >= eval; } catch (NumberFormatException nfe) { logger.log(Level.WARNING, "satisfiesConstraint "+value+" vs "+exp, nfe); return false; } } if (exp.startsWith("<=")) { try { double dval = Double.parseDouble(value); double eval = Double.parseDouble(exp.substring(2).trim()); return dval <= eval; } catch (NumberFormatException nfe) { logger.log(Level.WARNING, "satisfiesConstraint "+value+" vs "+exp, nfe); return false; } } if (exp.startsWith("LIKE")) { exp = exp.substring(4).replace("()"," ").trim(); boolean wildcardAtStart = exp.startsWith("%"); boolean wildcardAtEnd = exp.endsWith("%"); exp = exp.substring(wildcardAtStart ? 1 : 0, exp.length()-(wildcardAtStart ? 1 : 0)-(wildcardAtEnd ? 1 : 0)); if (wildcardAtStart && wildcardAtEnd) { return value.contains(exp); } if (wildcardAtStart && !wildcardAtEnd) { return value.endsWith(exp); } if (!wildcardAtStart && wildcardAtEnd) { return value.startsWith(exp); } return value.equals(exp); } logger.log(Level.WARNING,"Unsupported ClientRequirement expression: "+exp); return false; } }