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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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.",
|
||||
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