/*
** Module   : OAuth1.java
** Abstract : The OAuth1 configuration for the web service test.
**
** Copyright (c) 2021, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description----------------------------------------
** 001 CA  20210827 Created initial version.
** 002 CA  20210906 Allow override via command line arguments, for: web endpoint, variable initial values and 
**                  oauth1 parameters. 
*/

/*
** This program 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.
**
** This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
*/

package com.goldencode.harness.test;

import java.io.*;
import java.net.*;
import java.security.*;
import java.util.*;

import javax.crypto.*;
import javax.crypto.spec.*;

import org.apache.commons.lang3.tuple.*;
import org.apache.http.*;
import org.apache.http.client.methods.*;

import com.goldencode.harness.*;

/**
 * OAuth1 configuration for a web request.
 */
public class OAuth1
implements WebAuthorization
{
   /** The name of the encryption used. */
   private static final String HMAC_SHA256 = "HmacSHA256";

   /** The authorization header name. */
   private static final String HEADER_NAME = "Authorization";
   
   /** The OAuth consumer key parameter. */
   public static final String OAUTH_CONSUMER_KEY = "oauth_consumer_key";
   
   /** The OAuth consumer secret parameter. */
   public static final String OAUTH_CONSUMER_SECRET = "oauth_consumer_secret";
   
   /** The OAuth signature method parameter. */
   private static final String OAUTH_SIGNATURE_METHOD = "oauth_signature_method";
   
   /** The OAuth timestamp parameter. */
   private static final String OAUTH_TIMESTAMP = "oauth_timestamp";
   
   /** The OAuth nonce parameter. */
   private static final String OAUTH_NONCE = "oauth_nonce";
   
   /** The OAuth version parameter. */
   private static final String OAUTH_VERSION = "oauth_version";
   
   /** The OAuth signature parameter. */
   private static final String OAUTH_SIGNATURE = "oauth_signature";
   
   /** The OAuth token parameter. */
   public static final String OAUTH_TOKEN = "oauth_token";
   
   /** The OAuth token secret parameter. */
   public static final String OAUTH_TOKEN_SECRET = "oauth_token_secret";
   
   /** The OAuth callback parameter. */
   public static final String OAUTH_CALLBACK = "oauth_callback";
   
   /** The OAuth verifier parameter. */
   public static final String OAUTH_VERIFIER = "oauth_verifier";
   
   /** The OAuth realm parameter. */
   public static final String REALM = "realm";
   
   /** A random seed used to build the {@link #getNonce() nonce} */
   private static final Random r = new Random(System.currentTimeMillis());
   
   /** The alph-numeric characters (all lower and upper case letters, plus the 0-9 digits). */
   private static final String[] ALPHA_NUMS;
   
   /** Map with the override oauth1 parameters. */
   public static final Map<String, String> overrideParameters = new HashMap<>();

   /** OAuth version. */
   private static final String version = "1.0";
   
   /** Signature method. */
   private String signatureMethod = "HMAC-SHA256";
   
   static
   {
      ALPHA_NUMS = new String[62];
      int idx = 0;
      for (int i = 0; i <= 9; i++)
      {
         ALPHA_NUMS[idx++] = Character.toString((char) (i + (int) '0')); 
      }
      
      for (int i = 0; i < 26; i++)
      {
         ALPHA_NUMS[idx++] = Character.toString((char) (i + (int) 'a')); 
      }
      
      for (int i = 0; i < 26; i++)
      {
         ALPHA_NUMS[idx++] = Character.toString((char) (i + (int) 'A')); 
      }
   }
   
   /** The consumer key. */
   private String consumerKey;
   
   /** The consumer secret. */
   private String consumerSecret;
   
   /** The access token. */
   private String accessToken;
   
   /** The token secret. */
   private String tokenSecret;
   
   /** The realm. */
   private String realm;
   
   /** The callback. */
   private String callback;
   
   /** The verifier. */
   private String verifier;

   /**
    * Copy the default authorization settings from the configuration.
    * 
    * @param    cfg
    *           The session configuration.
    */
   public OAuth1(WebSessionSettings cfg)
   {
      String type = "oauth1";
      
      setParameter(OAUTH_CONSUMER_KEY, cfg.getAuthorizationParameter(type, OAUTH_CONSUMER_KEY));
      setParameter(OAUTH_CONSUMER_SECRET, cfg.getAuthorizationParameter(type, OAUTH_CONSUMER_SECRET));
      setParameter(OAUTH_TOKEN, cfg.getAuthorizationParameter(type, OAUTH_TOKEN));
      setParameter(OAUTH_TOKEN_SECRET, cfg.getAuthorizationParameter(type, OAUTH_TOKEN_SECRET));
      setParameter(REALM, cfg.getAuthorizationParameter(type, REALM));
      setParameter(OAUTH_CALLBACK, cfg.getAuthorizationParameter(type, OAUTH_CALLBACK));
      setParameter(OAUTH_VERIFIER, cfg.getAuthorizationParameter(type, OAUTH_VERIFIER));
   }
   
   /**
    * Set an authorization parameter.
    * 
    * @param    name
    *           The parameter name.
    * @param    value
    *           The parameter value.
    */
   @Override
   public void setParameter(String name, String value)
   {
      if (value == null || value.length() == 0)
      {
         return;
      }
      
      switch (name.toLowerCase())
      {
         case OAUTH_CONSUMER_KEY:
            this.consumerKey = value;
            break;
         case OAUTH_CONSUMER_SECRET:
            this.consumerSecret = value;
            break;
         case OAUTH_TOKEN:
            this.accessToken = value;
            break;
         case OAUTH_TOKEN_SECRET:
            this.tokenSecret = value;
            break;
         case REALM:
            this.realm = value;
            break;
         case OAUTH_CALLBACK:
            this.callback = value;
            break;
         case OAUTH_VERIFIER:
            this.verifier = value;
            break;
         default :
            throw new IllegalArgumentException("Parameter " + name + " is not valid for authorization type " +  
                                                this.getClass().getSimpleName());
      }
   }
   
   /**
    * Configure the request with the authorization.
    * <p>
    * This sets the {@link #HEADER_NAME authorization header} with the OAuth1 token.
    * 
    * @param    request
    *           The HTTP request.
    */
   @Override
   public void setAuthorization(RequestBuilder request)
   {
      overrideParameters.forEach((k, v) -> setParameter(k, v));
      
      String nonce = getNonce();
      long timestamp = getTimestamp();
      String signature = generateSignature(request.getMethod(), 
                                           request.getUri(), 
                                           request.getParameters(), 
                                           nonce, 
                                           timestamp);
      String encSignature = encryptSignature(HMAC_SHA256, signature);
      
      String oauth = getOAuthHeader(encSignature, nonce, timestamp);
      
      request.addHeader(HEADER_NAME, oauth);
   }
   
   /**
    * Build the OAuth1 token.
    * 
    * @param    signature
    *           The encrypted signature.
    * @param    nonce
    *           The nonce used in the signature.
    * @param    timestamp
    *           The timestamp used in the signature.
    *           
    * @return   The OAuth1 token.
    */
   private String getOAuthHeader(String signature, String nonce, long timestamp)
   {
      String value = "OAuth ";
      if (realm != null)
      {
         value = value + pairOf(REALM, quote(realm)) + ",";
      }
      if (consumerKey != null)
      {
         value = value + pairOf(OAUTH_CONSUMER_KEY, quote(consumerKey)) + ",";
      }
      value = value + pairOf(OAUTH_SIGNATURE_METHOD, quote(signatureMethod)) + ",";
      value = value + pairOf(OAUTH_TIMESTAMP, quote("" + timestamp)) + ",";
      value = value + pairOf(OAUTH_NONCE, quote(nonce)) + ",";
      value = value + pairOf(OAUTH_VERSION, quote(version)) + ",";
      if (callback != null)
      {
         value = value + pairOf(OAUTH_CALLBACK, quote(callback)) + ",";
      }
      if (verifier != null)
      {
         value = value + pairOf(OAUTH_VERIFIER, quote(verifier)) + ",";
      }
      value = value + pairOf(OAUTH_SIGNATURE, quote(encode(signature)));
      return value;
   }
   
   /**
    * Encrypt the signature.
    * 
    * @param    algorithm
    *           The encryption algorithm.
    * @param    signature
    *           The signature.
    *           
    * @return   The encrypted signature.
    */
   private String encryptSignature(String algorithm, String signature)
   {
      try 
      {
         String secret = encode(consumerSecret) + "&" + 
                         (tokenSecret == null || tokenSecret.isEmpty() ? "" :  encode(tokenSecret));
         byte[] keyBytes = secret.getBytes("UTF-8");
         SecretKey key = new SecretKeySpec(keyBytes, HMAC_SHA256);
         Mac mac = Mac.getInstance(HMAC_SHA256);
         mac.init(key);
         byte[] signatureBytes = mac.doFinal(signature.getBytes("UTF-8"));
         return new String(Base64.getEncoder().encode(signatureBytes));
      } 
      catch (NoSuchAlgorithmException | 
             InvalidKeyException      |
             UnsupportedEncodingException e) 
      {
         throw new RuntimeException(e);
      }
   }
   
   /**
    * Generate the signature.
    * 
    * @param    httpMethod
    *           The HTTP method for the request.
    * @param    url
    *           The request URL.
    * @param    params
    *           The request parameters.
    * @param    nonce
    *           The nonce string.
    * @param    timestamp
    *           The timestamp.
    *           
    * @return   The OAuth1 signature.
    */
   private String generateSignature(String              httpMethod, 
                                    URI                 url,
                                    List<NameValuePair> params,
                                    String              nonce,
                                    long                timestamp)
   {
      List<Pair<String, String>> signature = new ArrayList<>();
      if (params != null)
      {
         params.forEach(pair -> signature.add(Pair.of(pair.getName(), pair.getValue())));
      }
      if (realm != null)
      {
         signature.add(Pair.of(REALM, realm));
      }
      if (consumerKey != null)
      {
         signature.add(Pair.of(OAUTH_CONSUMER_KEY, consumerKey));
      }
      if (callback != null)
      {
         signature.add(Pair.of(OAUTH_CALLBACK, callback));
      }
      if (verifier != null)
      {
         signature.add(Pair.of(OAUTH_VERIFIER, verifier));
      }
      if (realm != null)
      {
         signature.add(Pair.of(REALM, realm));
      }
      if (accessToken != null)
      {
         signature.add(Pair.of(OAUTH_TOKEN, accessToken));
      }
      signature.add(Pair.of(OAUTH_NONCE, nonce));
      signature.add(Pair.of(OAUTH_SIGNATURE_METHOD, signatureMethod));
      signature.add(Pair.of(OAUTH_TIMESTAMP, "" + timestamp));
      signature.add(Pair.of(OAUTH_VERSION, version));
    
      // encode key/value
      List<Pair<String, String>> encoded = new ArrayList<>(signature.size());
      signature.forEach(p -> encoded.add(Pair.of(encode(p.getLeft()), encode(p.getRight()))));
      
      // sort by key and then value
      Collections.sort(encoded, new Comparator<Pair<String, String>>()
      {
         @Override
         public int compare(Pair<String, String> p1, Pair<String, String> p2)
         {
            int k = p1.getLeft().compareTo(p2.getLeft());
            return k == 0 ? p1.getRight().compareTo(p2.getRight()) : k;
         }
      });
      
      // produce a k1=v1&k2=v2&....&kn=vn string
      StringBuilder baseEncodedPairs = new StringBuilder();
      encoded.forEach(p -> baseEncodedPairs.append(pairOf(p.getLeft(), p.getRight())).append("&"));
      String baseEncoded = baseEncodedPairs.substring(0, baseEncodedPairs.length() - 1);

      StringBuilder baseString = new StringBuilder();
      baseString.append(httpMethod.toUpperCase())
                .append("&")
                .append(encode(url.toString()))
                .append("&")
                .append(encode(baseEncoded));
      return baseString.toString();
   }
   
   /**
    * URL-encode the specified string.
    * 
    * @param    txt
    *           The string to encode.
    *           
    * @return   The encoded string.
    */
   private static String encode(String txt)
   {
      try
      {
         return URLEncoder.encode(txt, "UTF-8");
      }
      catch (UnsupportedEncodingException exc)
      {
         throw new RuntimeException(exc);
      }
   }
   
   /**
    * Enclose the specified string double-quotes.
    *  
    * @param    str
    *           The string.
    *           
    * @return   The quoted string.
    */
   private String quote(String str)
   {
      return "\"" + str + "\"";
   }
   
   /**
    * Create a <code>name=value</code> pair.
    * 
    * @param    name
    *           The parameter name.
    * @param    val
    *           The parameter value.
    *           
    * @return   The string representation of this pair.
    */
   private String pairOf(String name, String val)
   {
      return name + "=" + val;
   }
   
   /**
    * Generate a random, 10-length alpha-numeric string.
    * 
    * @return   The nonce.
    */
   private String getNonce()
   {
      String res = "";
      for (int i = 1; i <= 10; i++)
      {
         res = res + ALPHA_NUMS[r.nextInt(ALPHA_NUMS.length)];
      }
      return res;
   }
   
   /**
    * Get the current timestamp, in seconds.
    * 
    * @return   The current timestamp, in seconds.
    */
   private long getTimestamp()
   {
      return System.currentTimeMillis() / 1000;
   }
}
