package learnositysdk.request;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Arrays;
import java.util.Iterator;
import java.util.Map;
import java.util.TimeZone;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringEscapeUtils;
import org.json.JSONObject;
import org.json.JSONArray;
import java.util.ArrayList;
/**
*--------------------------------------------------------------------------
* Learnosity SDK - Init
*--------------------------------------------------------------------------
*
* Used to generate the necessary security and request data (in the
* correct format) to integrate with any of the Learnosity API services.
*
*/
public class Init {
/**
* Which Learnosity service to generate a request packet for.
* Valid values (see also `$validServices`):
* - assess
* - author
* - data
* - items
* - questions
* - reports
*/
private String service;
/**
* The consumer secret as provided by Learnosity. This is your private key
* known only by the client (you) and Learnosity, which must not be exposed
* either by sending it to the browser or across the network.
* It should never be distributed publicly.
*/
private String secret;
/**
* A JSONObject of security details. This typically contains:
* - consumer_key
* - domain (optional depending on which service is being initialised)
* - timestamp (optional)
* - user_id (optional depending on which service is being initialised)
*
* It's important that the consumer secret is NOT a part of this array.
*/
private JSONObject securityPacket;
/**
* An optional JSONObject of request parameters used as part
* of the service (API) initialisation.
*/
private JSONObject requestPacket;
/**
* If `requestPacket` is used, `requestString` will be the string
* (JSON) representation of that. It's used to create the signature
* and returned as part of the service initialisation data.
*/
private String requestString = "";
/**
* An optional value used to define what type of request is being
* made. This is only required for certain requests made to the
* Data API (http://docs.learnosity.com/dataapi/)
*/
private String action = "";
/**
* Most services add the request packet (if passed) to the signature
* for security reasons. This flag can override that behaviour for
* services that don't require this.
*/
private boolean signRequestData = true;
/**
* Key names that are valid in the securityPacket, they are also in
* the correct order for signature generation.
*/
private final String[] validSecurityKeys = new String[] {"consumer_key", "domain", "timestamp", "user_id"};
/**
* Valid strings for service
*/
private String[] validServices = new String[] {"assess", "author", "data", "items", "questions", "reports", "events"};
/**
* Instantiate this class with all security and request data. It
* will be used to create a signature.
*
* @param service the service to be used
* @param securityPacket any object which can be used to instantiate a json.org.JSONObject or a json.org.JSONObject
* @param secret the private key
* @throws Exception if any of the passed arguments are invalid
*/
public Init (String service, Object securityPacket, String secret) throws Exception
{
// First validate and set the arguments
this.validateRequiredArgs(service, securityPacket, secret);
// Set any service specific options
this.setServiceOptions();
// Generate the signature based on the arguments provided
this.securityPacket.put("signature", this.generateSignature());
}
/**
* Instantiate this class with all security and request data. It
* will be used to create a signature.
*
* @param service the service to be used
* @param securityPacket the security information. Can be a json.org.JSONObject or an object of any type which is valid to instantiate
* a json.org.JSONObject
* @param secret the private key
* @param requestPacket an object which can be parsed into a JSONObject
* @throws Exception if any of the passed arguments are invalid
*/
public Init (String service, Object securityPacket, String secret, Object requestPacket) throws Exception
{
// First validate and set the arguments
this.validateRequiredArgs(service, securityPacket, secret);
this.validateRequestPacket(requestPacket);
// Set any service specific options
this.setServiceOptions();
// Generate the signature based on the arguments provided
this.securityPacket.put("signature", this.generateSignature());
}
/**
* Setter method for action. If an action is required, it should be set before generate() is called
* @param action the required action (e.g. get or post)
*/
public void setAction(String action) throws Exception
{
this.action = action;
// Re-generate the signature, as an action is now set
this.securityPacket.put("signature", this.generateSignature());
}
/**
* Generate the data necessary to make a request to one of the
* Learnosity products/services.
*
* @return A JSON string
*/
public String generate() throws Exception
{
JSONObject output = new JSONObject();
String outputString = "";
if (this.service.equals("assess") ||
this.service.equals("author") ||
this.service.equals("data") ||
this.service.equals("items") ||
this.service.equals("reports")) {
// Add the security packet (with signature) to the output
output.put("security", this.securityPacket);
// Add the action if necessary (Data API)
if (!this.action.isEmpty()) {
output.put("action", this.action);
}
if (this.service.equals("data")) {
return output.getJSONObject("security").toString();
} else if (this.service.equals("assess")) {
return this.requestString;
}
outputString = output.toString();
// Add the request packet if available
if (this.requestString != "") {
outputString = outputString.substring(0, outputString.length() - 1) + ",";
outputString = outputString + "\"request\":" + this.requestString + "}";
}
} else if (this.service.equals("questions")) {
// Make a copy of security packet (with signature) to the root of output
output = new JSONObject(this.securityPacket, JSONObject.getNames(this.securityPacket));
// Remove the `domain` key from the security packet
output.remove("domain");
outputString = output.toString();
// Merge the request packet if necessary. Note: to make sure we don't change the
// order of key/value pairs in the json, we manipulate the json string instead of
// the json object and then parsing into a string
if (this.requestString != "") {
outputString = outputString.substring(0, outputString.length() - 1) + ",";
outputString = outputString + this.requestString.substring(1);
}
} else if (this.service.equals("events")) {
// Add the security packet (with signature) to the output
output.put("security", this.securityPacket);
outputString = output.toString();
// Add the request packet as key 'config' if available
if (this.requestString != "") {
outputString = outputString.substring(0, outputString.length() - 1) + ",";
outputString = outputString + "\"config\":" + this.requestString + "}";
}
}
return outputString;
}
/**
* Generate a signature hash for the request, this includes:
* - the security credentials
* - the `request` packet (a JSON string) if passed
* - the `action` value if passed
*
* @return A signature hash for the request authentication
*/
public String generateSignature() throws Exception
{
ArrayList<String> signatureArray = new ArrayList<String>();
// Create a pre-hash string based on the security credentials
// The order is important
for (String key : this.validSecurityKeys) {
if (this.securityPacket.has(key)) {
signatureArray.add(this.securityPacket.getString(key));
}
}
// Add the secret
signatureArray.add(this.secret);
// Add the requestPacket if necessary
if (this.signRequestData && !this.requestString.isEmpty()) {
signatureArray.add(this.requestString);
}
// Add the action if necessary
if (!this.action.isEmpty()) {
signatureArray.add(this.action);
}
return this.hashValue(signatureArray);
}
/**
* Hash an array value
*
* @param value the array to hash
*
* @return string The hashed string
*/
private String hashValue(ArrayList<String> value)
{
String valueString = "";
for (String entry : value) {
if (valueString.equals("")) {
valueString = entry;
} else {
valueString += "_" + entry;
}
}
return DigestUtils.sha256Hex(valueString);
}
/**
* Set any options for services that aren't generic
*/
private void setServiceOptions() throws Exception
{
if (this.service.equals("assess") ||
this.service.equals("questions")) {
this.signRequestData = false;
// The Assess API holds data for the Questions API that includes
// security information and a signature. Retrieve the security
// information from $this and generate a signature for the
// Questions API
if (this.service.equals("assess") &&
this.requestPacket != null &&
this.requestPacket.has("questionsApiActivity")) {
JSONObject questionsApi = this.requestPacket.getJSONObject("questionsApiActivity");
String domain = "assess.learnosity.com";
ArrayList<String> signatureArray = new ArrayList<String>();
if (this.securityPacket.has("domain")) {
domain = this.securityPacket.getString("domain");
} else if (questionsApi.has("domain")) {
domain = questionsApi.getString("domain");
}
for (String key : new String[] {"consumer_key" , "timestamp" , "user_id"}) {
questionsApi.put(key, this.securityPacket.getString(key));
}
signatureArray.add(this.securityPacket.getString("consumer_key"));
signatureArray.add(domain);
signatureArray.add(this.securityPacket.getString("timestamp"));
signatureArray.add(this.securityPacket.getString("user_id"));
signatureArray.add(this.secret);
questionsApi.put("signature", this.hashValue(signatureArray));
}
} else if (this.service.equals("items")) {
// The Items API requires a user_id, so we make sure it's a part
// of the security packet as we share the signature in some cases
if (!this.securityPacket.has("user_id") &&
this.requestPacket.has("user_id")) {
this.securityPacket.put("user_id", this.requestPacket.getString("user_id"));
}
} else if (this.service.equals("events")) {
this.signRequestData = false;
JSONObject hashedUsers = new JSONObject();
if (this.requestPacket.has("users")) {
JSONArray users = this.requestPacket.getJSONArray("users");
for (int i = 0; i < users.length(); i++) {
String user = users.getString(i);
String stringToHash = user + this.secret;
String userHash = DigestUtils.sha256Hex(stringToHash);
hashedUsers.put(user, userHash);
}
this.requestPacket.put("users", hashedUsers);
this.requestString = this.requestPacket.toString();
}
}
}
/**
* Validate the required parameters of the constructor and set them if ok
*
* @param service
* @param securityPacket
* @param secret
* @throws Exception
*/
private void validateRequiredArgs(String service, Object securityPacket, String secret) throws Exception
{
if (service.isEmpty()) {
throw new Exception("The `service` argument wasn't found or was empty");
} else if (!Arrays.asList(this.validServices).contains(service.toLowerCase())) {
throw new Exception("The service provided " + service + " is not valid");
}
this.service = service;
// In case the user gave us a securityPacket String, convert to a JSONOBject
this.validateSecurityPacket(securityPacket);
if (secret.isEmpty()) {
throw new Exception("The `secret` argument must be a valid string");
}
this.secret = secret;
}
/**
* Validates the request packet argument
* @param requestPacket
* @throws Exception
*/
private void validateRequestPacket(Object requestPacket) throws Exception
{
if (requestPacket instanceof JSONObject) {
this.requestPacket = new JSONObject(requestPacket.toString());
this.requestString = requestPacket.toString();
} else {
if (requestPacket instanceof String) {
this.requestPacket = new JSONObject((String)requestPacket);
this.requestString = (String)requestPacket;
} else if (requestPacket instanceof Map) {
this.requestPacket = new JSONObject((Map)requestPacket);
this.requestString = this.requestPacket.toString();
} else {
// Try to make a JSONObject out of a hopefully valid java bean
this.requestPacket = new JSONObject(requestPacket);
this.requestString = this.requestPacket.toString();
}
}
// JSONObject.toString escapes forward slashes. Undo that, in order to avoid changes to the string
this.requestString = this.requestString.replace("\\/", "/");
// unescape any escape sequences created by JSONObject.toString
this.requestString = StringEscapeUtils.unescapeJava(this.requestString);
if (this.requestPacket.length() == 0) {
throw new Exception("The requestPacket cannot be empty.");
}
}
/**
* Validate the security packet argument
* @param securityPacket
* @throws Exception
*/
private void validateSecurityPacket (Object securityPacket) throws Exception
{
if (securityPacket instanceof JSONObject) {
this.securityPacket = new JSONObject(securityPacket.toString());
} else {
if (securityPacket instanceof String) {
this.securityPacket = new JSONObject((String)securityPacket);
} else if (securityPacket instanceof Map) {
this.securityPacket = new JSONObject((Map)securityPacket);
} else {
// Try to make a JSONObject out of a hopefully valid java bean
this.securityPacket = new JSONObject(securityPacket);
}
}
if (this.service.equals("questions") && !this.securityPacket.has("user_id")) {
throw new Exception("If using the questions api, a user id needs to be specified");
}
if (this.securityPacket.length() == 0) {
throw new Exception("The security packet argument cannot be empty");
}
Iterator<String> keyIter = this.securityPacket.keys();
while (keyIter.hasNext()) {
String key = keyIter.next();
if (!Arrays.asList(this.validSecurityKeys).contains(key)) {
throw new Exception("Invalid key found in the security packet: " + key);
}
}
if (!this.securityPacket.has("timestamp")) {
DateFormat dateFormat = new SimpleDateFormat("yyyyMMdd-HHmm");
dateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
Date date = new Date();
this.securityPacket.put("timestamp", dateFormat.format(date));
}
}
}