From 1a856fb2ab1e58a2e579aed6d23f8a527de4c356 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 20 Dec 2018 15:21:01 -0500 Subject: [PATCH] duo api class --- src/Core/Utilities/DuoApi.cs | 278 +++++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 src/Core/Utilities/DuoApi.cs diff --git a/src/Core/Utilities/DuoApi.cs b/src/Core/Utilities/DuoApi.cs new file mode 100644 index 000000000..820692652 --- /dev/null +++ b/src/Core/Utilities/DuoApi.cs @@ -0,0 +1,278 @@ +/* +Original source modified from https://github.com/duosecurity/duo_api_csharp + +============================================================================= +============================================================================= + +Copyright (c) 2018 Duo Security +All rights reserved +*/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using System.Text; +using System.Web; +using System.Globalization; +using Newtonsoft.Json; + +namespace Bit.Core.Utilities.Duo +{ + public class DuoApi + { + private const string UrlScheme = "https"; + private const string UserAgent = "Bitwarden_DuoAPICSharp/1.0 (.NET Core)"; + + private readonly string _host; + private readonly string _ikey; + private readonly string _skey; + + public DuoApi(string ikey, string skey, string host) + { + _ikey = ikey; + _skey = skey; + _host = host; + } + + public static string CanonicalizeParams(Dictionary parameters) + { + var ret = new List(); + foreach(var pair in parameters) + { + var p = string.Format("{0}={1}", HttpUtility.UrlEncode(pair.Key), HttpUtility.UrlEncode(pair.Value)); + // Signatures require upper-case hex digits. + p = Regex.Replace(p, "(%[0-9A-Fa-f][0-9A-Fa-f])", c => c.Value.ToUpperInvariant()); + // Escape only the expected characters. + p = Regex.Replace(p, "([!'()*])", c => "%" + Convert.ToByte(c.Value[0]).ToString("X")); + p = p.Replace("%7E", "~"); + // UrlEncode converts space (" ") to "+". The + // signature algorithm requires "%20" instead. Actual + // + has already been replaced with %2B. + p = p.Replace("+", "%20"); + ret.Add(p); + } + + ret.Sort(StringComparer.Ordinal); + return string.Join("&", ret.ToArray()); + } + + protected string CanonicalizeRequest(string method, string path, string canonParams, string date) + { + string[] lines = { + date, + method.ToUpperInvariant(), + _host.ToLower(), + path, + canonParams, + }; + return string.Join("\n", lines); + } + + public string Sign(string method, string path, string canonParams, string date) + { + var canon = CanonicalizeRequest(method, path, canonParams, date); + var sig = HmacSign(canon); + var auth = string.Concat(_ikey, ':', sig); + return string.Concat("Basic ", Encode64(auth)); + } + + public string ApiCall(string method, string path, Dictionary parameters) + { + return ApiCall(method, path, parameters, 0, out var statusCode); + } + + /// The request timeout, in milliseconds. + /// Specify 0 to use the system-default timeout. Use caution if + /// you choose to specify a custom timeout - some API + /// calls (particularly in the Auth APIs) will not + /// return a response until an out-of-band authentication process + /// has completed. In some cases, this may take as much as a + /// small number of minutes. + public string ApiCall(string method, string path, Dictionary parameters, int timeout, + out HttpStatusCode statusCode) + { + var canonParams = CanonicalizeParams(parameters); + var query = string.Empty; + if(!method.Equals("POST") && !method.Equals("PUT")) + { + if(parameters.Count > 0) + { + query = "?" + canonParams; + } + } + var url = string.Format("{0}://{1}{2}{3}", UrlScheme, _host, path, query); + + var dateString = RFC822UtcNow(); + var auth = Sign(method, path, canonParams, dateString); + + var request = (HttpWebRequest)WebRequest.Create(url); + request.Method = method; + request.Accept = "application/json"; + request.Headers.Add("Authorization", auth); + request.Headers.Add("X-Duo-Date", dateString); + request.UserAgent = UserAgent; + + if(method.Equals("POST") || method.Equals("PUT")) + { + var data = Encoding.UTF8.GetBytes(canonParams); + request.ContentType = "application/x-www-form-urlencoded"; + request.ContentLength = data.Length; + using(var requestStream = request.GetRequestStream()) + { + requestStream.Write(data, 0, data.Length); + } + } + if(timeout > 0) + { + request.Timeout = timeout; + } + + // Do the request and process the result. + HttpWebResponse response; + try + { + response = (HttpWebResponse)request.GetResponse(); + } + catch(WebException ex) + { + response = (HttpWebResponse)ex.Response; + if(response == null) + { + throw; + } + } + using(var reader = new StreamReader(response.GetResponseStream())) + { + statusCode = response.StatusCode; + return reader.ReadToEnd(); + } + } + + public T JSONApiCall(string method, string path, Dictionary parameters) + where T : class + { + return JSONApiCall(method, path, parameters, 0); + } + + /// The request timeout, in milliseconds. + /// Specify 0 to use the system-default timeout. Use caution if + /// you choose to specify a custom timeout - some API + /// calls (particularly in the Auth APIs) will not + /// return a response until an out-of-band authentication process + /// has completed. In some cases, this may take as much as a + /// small number of minutes. + public T JSONApiCall(string method, string path, Dictionary parameters, int timeout) + where T : class + { + var res = ApiCall(method, path, parameters, timeout, out var statusCode); + try + { + var dict = JsonConvert.DeserializeObject>(res); + if(dict["stat"] as string == "OK") + { + return dict["response"] as T; + } + else + { + var check = dict["code"] as int?; + var code = check.GetValueOrDefault(0); + var messageDetail = string.Empty; + if(dict.ContainsKey("message_detail")) + { + messageDetail = dict["message_detail"] as string; + } + throw new ApiException(code, (int)statusCode, dict["message"] as string, messageDetail); + } + } + catch(ApiException) + { + throw; + } + catch(Exception e) + { + throw new BadResponseException((int)statusCode, e); + } + } + + private string HmacSign(string data) + { + var keyBytes = Encoding.ASCII.GetBytes(_skey); + var dataBytes = Encoding.ASCII.GetBytes(data); + + using(var hmac = new HMACSHA1(keyBytes)) + { + var hash = hmac.ComputeHash(dataBytes); + var hex = BitConverter.ToString(hash); + return hex.Replace("-", string.Empty).ToLower(); + } + } + + private static string Encode64(string plaintext) + { + var plaintextBytes = Encoding.ASCII.GetBytes(plaintext); + return Convert.ToBase64String(plaintextBytes); + } + + private static string RFC822UtcNow() + { + // Can't use the "zzzz" format because it adds a ":" + // between the offset's hours and minutes. + var dateString = DateTime.UtcNow.ToString("ddd, dd MMM yyyy HH:mm:ss", CultureInfo.InvariantCulture); + var offset = 0; + var zone = "+" + offset.ToString(CultureInfo.InvariantCulture).PadLeft(2, '0'); + dateString += " " + zone.PadRight(5, '0'); + return dateString; + } + } + + public class DuoException : Exception + { + public int HttpStatus { get; private set; } + + public DuoException(int httpStatus, string message, Exception inner) + : base(message, inner) + { + HttpStatus = httpStatus; + } + } + + public class ApiException : DuoException + { + public int Code { get; private set; } + public string ApiMessage { get; private set; } + public string ApiMessageDetail { get; private set; } + + public ApiException(int code, int httpStatus, string apiMessage, string apiMessageDetail) + : base(httpStatus, FormatMessage(code, apiMessage, apiMessageDetail), null) + { + Code = code; + ApiMessage = apiMessage; + ApiMessageDetail = apiMessageDetail; + } + + private static string FormatMessage(int code, string apiMessage, string apiMessageDetail) + { + return string.Format("Duo API Error {0}: '{1}' ('{2}')", code, apiMessage, apiMessageDetail); + } + } + + public class BadResponseException : DuoException + { + public BadResponseException(int httpStatus, Exception inner) + : base(httpStatus, FormatMessage(httpStatus, inner), inner) + { } + + private static string FormatMessage(int httpStatus, Exception inner) + { + var innerMessage = "(null)"; + if(inner != null) + { + innerMessage = string.Format("'{0}'", inner.Message); + } + return string.Format("Got error {0} with HTTP Status {1}", innerMessage, httpStatus); + } + } +}