mirror of
https://github.com/bitwarden/server.git
synced 2025-01-22 21:51:22 +01:00
Duo API and token provider
This commit is contained in:
parent
e0bbd93990
commit
314d591f36
@ -21,11 +21,6 @@ namespace Bit.Core.Identity
|
|||||||
return Task.FromResult(canGenerate);
|
return Task.FromResult(canGenerate);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<string> GetUserModifierAsync(string purpose, UserManager<User> manager, User user)
|
|
||||||
{
|
|
||||||
return Task.FromResult<string>(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
|
public Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
|
||||||
{
|
{
|
||||||
return Task.FromResult<string>(null);
|
return Task.FromResult<string>(null);
|
||||||
|
90
src/Core/Identity/DuoTokenProvider.cs
Normal file
90
src/Core/Identity/DuoTokenProvider.cs
Normal file
@ -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<User>
|
||||||
|
{
|
||||||
|
public Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="purpose">Ex: "auto", "push", "passcode:123456", "sms", "phone"</param>
|
||||||
|
public async Task<string> GenerateAsync(string purpose, UserManager<User> 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<string, string>
|
||||||
|
{
|
||||||
|
["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<Dictionary<string, object>>(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<bool> ValidateAsync(string purpose, string token, UserManager<User> 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<string, string>
|
||||||
|
{
|
||||||
|
["txid"] = token
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await duoClient.JSONApiCallAsync<Dictionary<string, object>>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -107,7 +107,8 @@ namespace Bit.Core.IdentityServer
|
|||||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Two factor required.",
|
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Two factor required.",
|
||||||
new Dictionary<string, object>
|
new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
{ "TwoFactorProviders", providers }
|
{ "TwoFactorProviders", providers },
|
||||||
|
{ "TwoFactorProvider", (byte)user.TwoFactorProvider.Value }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
285
src/Core/Utilities/DuoApi.cs
Normal file
285
src/Core/Utilities/DuoApi.cs
Normal file
@ -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<Tuple<string, HttpStatusCode>> ApiCallAsync(HttpMethod method, string path,
|
||||||
|
Dictionary<string, string> 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<string, HttpStatusCode>(result, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T> JSONApiCallAsync<T>(HttpMethod method, string path, Dictionary<string, string> 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<Dictionary<string, object>>(res);
|
||||||
|
var stat = resDict["stat"] as string;
|
||||||
|
if(stat == "OK")
|
||||||
|
{
|
||||||
|
return JsonConvert.DeserializeObject<T>(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<string, string> parameters)
|
||||||
|
{
|
||||||
|
var ret = new List<string>();
|
||||||
|
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}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user