From 314d591f362a4e7557c68cfb123de958288593db Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Mon, 12 Jun 2017 21:22:46 -0400 Subject: [PATCH] Duo API and token provider --- .../Identity/AuthenticatorTokenProvider.cs | 5 - src/Core/Identity/DuoTokenProvider.cs | 90 ++++++ .../ResourceOwnerPasswordValidator.cs | 3 +- src/Core/Utilities/DuoApi.cs | 285 ++++++++++++++++++ 4 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 src/Core/Identity/DuoTokenProvider.cs create mode 100644 src/Core/Utilities/DuoApi.cs diff --git a/src/Core/Identity/AuthenticatorTokenProvider.cs b/src/Core/Identity/AuthenticatorTokenProvider.cs index 537c761916..4919079f20 100644 --- a/src/Core/Identity/AuthenticatorTokenProvider.cs +++ b/src/Core/Identity/AuthenticatorTokenProvider.cs @@ -21,11 +21,6 @@ namespace Bit.Core.Identity return Task.FromResult(canGenerate); } - public Task GetUserModifierAsync(string purpose, UserManager manager, User user) - { - return Task.FromResult(null); - } - public Task GenerateAsync(string purpose, UserManager manager, User user) { return Task.FromResult(null); diff --git a/src/Core/Identity/DuoTokenProvider.cs b/src/Core/Identity/DuoTokenProvider.cs new file mode 100644 index 0000000000..61839f4fe2 --- /dev/null +++ b/src/Core/Identity/DuoTokenProvider.cs @@ -0,0 +1,90 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Bit.Core.Models.Table; +using Bit.Core.Enums; +using Bit.Core.Utilities.Duo; +using System.Collections.Generic; +using System.Net.Http; + +namespace Bit.Core.Identity +{ + public class DuoTokenProvider : IUserTwoFactorTokenProvider + { + public Task CanGenerateTwoFactorTokenAsync(UserManager manager, User user) + { + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo); + + var canGenerate = user.TwoFactorIsEnabled(TwoFactorProviderType.Duo) + && user.TwoFactorProvider.HasValue + && user.TwoFactorProvider.Value == TwoFactorProviderType.Duo + && !string.IsNullOrWhiteSpace(provider?.MetaData["UserId"]); + + return Task.FromResult(canGenerate); + } + + /// Ex: "auto", "push", "passcode:123456", "sms", "phone" + public async Task GenerateAsync(string purpose, UserManager manager, User user) + { + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo); + var duoClient = new DuoApi(provider.MetaData["IKey"], provider.MetaData["SKey"], provider.MetaData["Host"]); + var parts = purpose.Split(':'); + + var parameters = new Dictionary + { + ["async"] = "1", + ["user_id"] = provider.MetaData["UserId"], + ["factor"] = parts[0] + }; + + if(parameters["factor"] == "passcode" && parts.Length > 1) + { + parameters["passcode"] = parts[1]; + } + else + { + parameters["device"] = "auto"; + } + + try + { + var response = await duoClient.JSONApiCallAsync>(HttpMethod.Post, + "/auth/v2/auth", parameters); + + if(response.ContainsKey("txid")) + { + var txId = response["txid"] as string; + return txId; + } + } + catch(DuoException) { } + + return null; + } + + public async Task ValidateAsync(string purpose, string token, UserManager manager, User user) + { + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo); + var duoClient = new DuoApi(provider.MetaData["IKey"], provider.MetaData["SKey"], provider.MetaData["Host"]); + + var parameters = new Dictionary + { + ["txid"] = token + }; + + try + { + var response = await duoClient.JSONApiCallAsync>(HttpMethod.Get, + "/auth/v2/auth_status", parameters); + + var result = response["result"] as string; + return string.Equals(result, "allow"); + } + catch(DuoException) + { + // TODO: We might want to return true in some cases? What if Duo is down? + } + + return false; + } + } +} diff --git a/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs b/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs index ab91486417..34787ed3fd 100644 --- a/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs +++ b/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs @@ -107,7 +107,8 @@ namespace Bit.Core.IdentityServer context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Two factor required.", new Dictionary { - { "TwoFactorProviders", providers } + { "TwoFactorProviders", providers }, + { "TwoFactorProvider", (byte)user.TwoFactorProvider.Value } }); } diff --git a/src/Core/Utilities/DuoApi.cs b/src/Core/Utilities/DuoApi.cs new file mode 100644 index 0000000000..85f5772241 --- /dev/null +++ b/src/Core/Utilities/DuoApi.cs @@ -0,0 +1,285 @@ +/* +Original source modified from https://github.com/duosecurity/duo_api_csharp + +============================================================================= +============================================================================= + +ref: https://github.com/duosecurity/duo_api_csharp/blob/master/LICENSE + +Copyright (c) 2013, Duo Security, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. The name of the author may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +using System; +using System.Collections.Generic; +using System.Net; +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using System.Text; +using System.Globalization; +using Newtonsoft.Json; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Bit.Core.Utilities.Duo +{ + public class DuoApi + { + public const string DefaultAgent = "Duo.NET, bitwarden"; + + private readonly string _ikey; + private readonly string _skey; + private readonly string _host; + private readonly string _userAgent; + + public DuoApi(string ikey, string skey, string host) + : this(ikey, skey, host, null) + { } + + protected DuoApi(string ikey, string skey, string host, string userAgent) + { + _ikey = ikey; + _skey = skey; + _host = host; + _userAgent = string.IsNullOrWhiteSpace(userAgent) ? DefaultAgent : userAgent; + } + + public async Task> ApiCallAsync(HttpMethod method, string path, + Dictionary parameters, int? timeout = null, DateTime? date = null) + { + var canonParams = CanonicalizeParams(parameters); + var query = string.Empty; + if(method != HttpMethod.Post && method != HttpMethod.Put && parameters.Count > 0) + { + query = "?" + canonParams; + } + + var url = $"https://{_host}{path}{query}"; + + var dateString = DateToRFC822(date.GetValueOrDefault(DateTime.UtcNow)); + var auth = Sign(method.ToString(), path, canonParams, dateString); + + var client = new HttpClient(); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + client.DefaultRequestHeaders.Add("Authorization", auth); + client.DefaultRequestHeaders.Add("X-Duo-Date", dateString); + client.DefaultRequestHeaders.Add("User-Agent", _userAgent); + + if(timeout.GetValueOrDefault(0) > 0) + { + client.Timeout = new TimeSpan(0, 0, 0, 0, timeout.Value); + } + + var request = new HttpRequestMessage + { + RequestUri = new Uri(url), + Method = method + }; + + if(method == HttpMethod.Post || method == HttpMethod.Put) + { + request.Content = new FormUrlEncodedContent(parameters); + } + + HttpResponseMessage response = null; + try + { + response = await client.SendAsync(request); + } + catch(WebException) + { + if(response?.Content == null) + { + throw; + } + } + + var result = await response.Content.ReadAsStringAsync(); + return new Tuple(result, response.StatusCode); + } + + public async Task JSONApiCallAsync(HttpMethod method, string path, Dictionary parameters, + int? timeout = null, DateTime? date = null) where T : class + { + var resTuple = await ApiCallAsync(method, path, parameters, timeout, date); + var res = resTuple.Item1; + HttpStatusCode statusCode = resTuple.Item2; + + try + { + var resDict = JsonConvert.DeserializeObject>(res); + var stat = resDict["stat"] as string; + if(stat == "OK") + { + return JsonConvert.DeserializeObject(resDict["response"].ToString()); + } + else + { + var code = resDict["code"] as int?; + var message = resDict["message"] as string; + + var messageDetail = string.Empty; + if(resDict.ContainsKey("message_detail")) + { + messageDetail = resDict["message_detail"] as string; + } + + throw new DuoApiException(code.GetValueOrDefault(0), statusCode, message, messageDetail); + } + } + catch(Exception e) + { + throw new DuoBadResponseException(statusCode, e); + } + } + + private string CanonicalizeParams(Dictionary parameters) + { + var ret = new List(); + foreach(var pair in parameters) + { + var p = $"{WebUtility.UrlEncode(pair.Key)}={WebUtility.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()); + } + + private string CanonicalizeRequest(string method, string path, string canon_params, string date) + { + string[] lines = { date, method.ToUpperInvariant(), _host.ToLower(), path, canon_params }; + return string.Join("\n", lines); + } + + private string Sign(string method, string path, string canon_params, string date) + { + var canon = CanonicalizeRequest(method, path, canon_params, date); + var sig = HmacSign(canon); + var auth = $"{_ikey }:{sig}"; + var authBytes = Encoding.ASCII.GetBytes(auth); + return $"Basic {Convert.ToBase64String(authBytes)}"; + } + + 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("-", "").ToLower(); + } + } + + private string DateToRFC822(DateTime date) + { + // Can't use the "zzzz" format because it adds a ":" + // between the offset's hours and minutes. + var dateString = date.ToString("ddd, dd MMM yyyy HH:mm:ss", CultureInfo.InvariantCulture); + + // TODO: Get proper timezone offset. hardcoded to UTC for now. + var offset = 0; + + string zone; + // + or -, then 0-pad, then offset, then more 0-padding. + if(offset < 0) + { + offset *= -1; + zone = "-"; + } + else + { + zone = "+"; + } + + zone += offset.ToString(CultureInfo.InvariantCulture).PadLeft(2, '0'); + dateString += (" " + zone.PadRight(5, '0')); + return dateString; + } + } + + public class DuoException : Exception + { + public HttpStatusCode Status { get; private set; } + + public DuoException(HttpStatusCode status, string message, Exception inner) + : base(message, inner) + { + Status = status; + } + } + + public class DuoApiException : DuoException + { + public int Code { get; private set; } + public string ApiMessage { get; private set; } + public string ApiMessageDetail { get; private set; } + + public DuoApiException(int code, HttpStatusCode status, string message, string messageDetail) + : base(status, FormatMessage(code, message, messageDetail), null) + { + Code = code; + ApiMessage = message; + ApiMessageDetail = messageDetail; + } + + private static string FormatMessage(int code, string message, string messageDetail) + { + return $"Duo API Error {code}: '{message}' ('{messageDetail}')."; + } + } + + public class DuoBadResponseException : DuoException + { + public DuoBadResponseException(HttpStatusCode status, Exception inner) + : base(status, FormatMessage(status, inner), inner) + { } + + private static string FormatMessage(HttpStatusCode status, Exception inner) + { + var innerMessage = "(null)"; + if(inner != null) + { + innerMessage = string.Format("'{0}'", inner.Message); + } + + return $"Got error '{innerMessage}' with HTTP status {(int)status}."; + } + } +}