Java tutorial
/******************************************************************************* * Copyright (c) 2016 IBM Corp. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package org.gameontext.map.auth; import java.io.FileInputStream; import java.io.IOException; import java.security.Key; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.UnrecoverableKeyException; import java.security.cert.CertificateException; import java.time.Duration; import java.time.Instant; import java.util.Date; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.logging.Level; import javax.annotation.PostConstruct; import javax.annotation.Resource; import javax.enterprise.context.ApplicationScoped; import javax.inject.Inject; import javax.net.ssl.SSLContext; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Response; import org.apache.http.HttpResponse; import org.apache.http.client.HttpClient; import org.apache.http.client.HttpResponseException; import org.apache.http.client.methods.HttpGet; import org.apache.http.conn.ssl.NoopHostnameVerifier; import org.apache.http.impl.client.BasicResponseHandler; import org.apache.http.impl.client.HttpClientBuilder; import org.gameontext.map.Log; import org.gameontext.map.kafka.Kafka; import org.gameontext.map.kafka.KafkaEventHandler; import org.gameontext.signed.SignedRequestSecretProvider; import org.gameontext.signed.TimestampedKey; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.netflix.hystrix.HystrixCommand; import com.netflix.hystrix.HystrixCommandGroupKey; import com.netflix.hystrix.exception.HystrixBadRequestException; import com.netflix.hystrix.exception.HystrixRuntimeException; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; /** * A wrapped/encapsulation of outbound REST requests to the player service. * <p> * The URL for the player service is injected via CDI: {@code <jndiEntry />} * elements defined in server.xml maps the environment variable to the JNDI * value. * </p> * <p> * CDI will create this (the {@code PlayerClient} as an application scoped bean. * This bean will be created when the application starts, and can be injected * into other CDI-managed beans for as long as the application is valid. * </p> * * @see ApplicationScoped */ @ApplicationScoped public class PlayerClient implements SignedRequestSecretProvider, KafkaEventHandler { private static final Duration hours24 = Duration.ofHours(24); /** The Key to Sign JWT's with (once it's loaded) */ private Key signingKey = null; /** Cache of player API keys */ private ConcurrentMap<String, TimestampedKey> playerSecrets = new ConcurrentHashMap<>(); /** Listen for secret updates */ @Inject Kafka kafka; /** * The player URL injected from JNDI via CDI. * * @see {@code playerUrl} in * {@code /map-wlpcfg/servers/gameon-map/server.xml} */ @Resource(lookup = "playerUrl") String playerLocation; // Keystore info for jwt parsing / creation. @Resource(lookup = "jwtKeyStore") String keyStore; @Resource(lookup = "jwtKeyStorePassword") String keyStorePW; @Resource(lookup = "jwtKeyStoreAlias") String keyStoreAlias; @Resource(lookup = "registrationSecret") String registrationSecret; @Resource(lookup = "systemId") String SYSTEM_ID; @Resource(lookup = "sweepId") String sweepId; @Resource(lookup = "sweepSecret") String sweepSecret; String systemSecret; /** * Initialize kafka consumer */ @PostConstruct protected void init() { Log.log(Level.INFO, this, "PostConstruct: Subscribing to playerEvents"); kafka.subscribe(this); } public boolean isHealthy() { String secret = systemSecret; if (secret == null) { secret = systemSecret = getSecretForId(SYSTEM_ID); } return secret != null; } /** * Obtain the key we'll use to sign the jwts we use to talk to Player endpoints. * * @throws IOException * if there are any issues with the keystore processing. */ private synchronized void getKeyStoreInfo() { try { // load up the keystore.. FileInputStream is = new FileInputStream(keyStore); KeyStore signingKeystore = KeyStore.getInstance(KeyStore.getDefaultType()); signingKeystore.load(is, keyStorePW.toCharArray()); // grab the key we'll use to sign signingKey = signingKeystore.getKey(keyStoreAlias, keyStorePW.toCharArray()); } catch (KeyStoreException | NoSuchAlgorithmException | CertificateException | UnrecoverableKeyException | IOException e) { throw new IllegalStateException("Unable to get required keystore: " + keyStore, e); } } /** * Obtain a JWT for the player id that can be used to invoke player REST services. * * We can create this, because we have access to the private certificate * required to sign such a JWT. * * @param playerId The id to build the JWT for * @return The JWT as a string. * @throws IOException */ private String buildClientJwtForId(String playerId) throws IOException { // grab the key if needed if (signingKey == null) getKeyStoreInfo(); Claims onwardsClaims = Jwts.claims(); // Set the subject using the "id" field from our claims map. onwardsClaims.setSubject(playerId); // We'll use this claim to know this is a user token onwardsClaims.setAudience("client"); // we set creation time to 24hrs ago, to avoid timezone issues // client JWT has 24 hrs validity from now. Instant timePoint = Instant.now(); onwardsClaims.setIssuedAt(Date.from(timePoint.minus(hours24))); onwardsClaims.setExpiration(Date.from(timePoint.plus(hours24))); // finally build the new jwt, using the claims we just built, signing it // with our signing key, and adding a key hint as kid to the encryption // header, which is optional, but can be used by the receivers of the // jwt to know which key they should verifiy it with. String newJwt = Jwts.builder().setHeaderParam("kid", "playerssl").setClaims(onwardsClaims) .signWith(SignatureAlgorithm.RS256, signingKey).compact(); return newJwt; } /** * Obtain the apiKey for the given id, using a local cache to avoid hitting couchdb too much. */ @Override public String getSecretForId(String id) { //first.. handle our built-in key if (SYSTEM_ID.equals(id)) { return registrationSecret; } else if (sweepId.equals(id)) { return sweepSecret; } String playerSecret = null; TimestampedKey timedKey = playerSecrets.get(id); if (timedKey != null) { playerSecret = timedKey.getKey(); if (!timedKey.hasExpired()) { // CACHED VALUE! the id has been seen, and hasn't expired. Shortcut! Log.log(Level.FINER, "Map using cached key for {0}", id); return playerSecret; } } TimestampedKey newKey = new TimestampedKey(hours24); Log.log(Level.FINER, "Map asking player service for key for id {0}", id); try { playerSecret = getPlayerSecret(id); newKey.setKey(playerSecret); } catch (WebApplicationException e) { if (playerSecret != null) { // we have a stale value, return it return playerSecret; } // no dice at all, rethrow throw e; } // replace expired timedKey with newKey always. playerSecrets.put(id, newKey); // return fetched playerSecret return playerSecret; } /** * Obtain sharedSecret for player id. * * @param playerId * The player id * @return The apiKey for the player */ private String getPlayerSecret(String playerId) throws WebApplicationException { try { String jwt = buildClientJwtForId(playerId); return new GetPlayerSecretCommand(jwt, playerId, playerLocation).execute(); } catch (HystrixRuntimeException e) { //unwrap hysterix exceptions.. Throwable cause = e.getCause(); if (cause instanceof WebApplicationException) { WebApplicationException wae = (WebApplicationException) cause; throw wae; } else { throw new WebApplicationException("Unknown error during communication with player service", cause); } } catch (HystrixBadRequestException e) { throw new WebApplicationException("Internal issue communicating with player service", e); } catch (IOException io) { Log.log(Level.FINEST, this, "Unexpected exception getting token for playerService: {0}", io); throw new WebApplicationException("Token Error communicating with Player service", Response.Status.INTERNAL_SERVER_ERROR); } } private static class GetPlayerSecretCommand extends HystrixCommand<String> { private String jwt; private String playerId; private String playerLocation; public GetPlayerSecretCommand(String jwt, String playerId, String playerLocation) { super(HystrixCommandGroupKey.Factory.asKey("Player")); this.jwt = jwt; this.playerId = playerId; this.playerLocation = playerLocation; } @Override protected String run() { try { HttpClient client = null; if ("development".equals(System.getenv("MAP_PLAYER_MODE"))) { System.out .println("Using development mode player connection. (DefaultSSL,NoHostNameValidation)"); HttpClientBuilder b = HttpClientBuilder.create(); //use the default ssl context, we have a trust store configured for player cert. SSLContext sslContext = SSLContext.getDefault(); //use a very trusting truststore.. (not needed..) //SSLContext sslContext = new SSLContextBuilder().loadTrustMaterial(null, new TrustSelfSignedStrategy()).build(); b.setSSLContext(sslContext); //disable hostname validation, because we'll need to access the cert via a different hostname. b.setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE); client = b.build(); } else { client = HttpClientBuilder.create().build(); } HttpGet hg = new HttpGet(playerLocation + "/" + playerId); hg.addHeader("gameon-jwt", jwt); Log.log(Level.FINEST, this, "Building web target: {0}", hg.getURI().toString()); // Make GET request using the specified target, get result as a // string containing JSON HttpResponse r = client.execute(hg); String result = new BasicResponseHandler().handleResponse(r); // Parse the JSON response, and retrieve the apiKey field value. ObjectMapper om = new ObjectMapper(); JsonNode jn = om.readValue(result, JsonNode.class); Log.log(Level.FINER, this, "Got player record for {0} from player service", playerId); JsonNode creds = jn.get("credentials").get("sharedSecret"); return creds.textValue(); } catch (HttpResponseException hre) { Log.log(Level.FINEST, this, "Error communicating with player service: {0} {1}", hre.getStatusCode(), hre.getMessage()); throw new WebApplicationException("Error communicating with Player service", Response.Status.INTERNAL_SERVER_ERROR); } catch (IOException | NoSuchAlgorithmException e) { Log.log(Level.FINEST, this, "Unexpected exception getting secret from playerService: {0}", e); throw new WebApplicationException("Error communicating with Player service", Response.Status.INTERNAL_SERVER_ERROR); } catch (WebApplicationException wae) { Log.log(Level.FINEST, this, "Error processing response: {0}", wae.getResponse()); throw wae; } } } @Override public String getEventType() { return "UPDATE_APIKEY"; } @Override public void handleEvent(String key, JsonNode eventData) { Log.log(Level.FINEST, this, "Dropping cached key for {0}", key); playerSecrets.remove(key); } }