// FIXME: Update this file to be null safe and then delete the line below #nullable disable using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Settings; using Bit.Core.Tokens; using Microsoft.AspNetCore.Http; using Duo = DuoUniversal; namespace Bit.Core.Auth.Identity.TokenProviders; /// /// OrganizationDuo and Duo TwoFactorProviderTypes both use the same flows so both of those Token Providers will /// have this class injected to utilize these methods /// public interface IDuoUniversalTokenService { /// /// Generates the Duo Auth URL for the user to be redirected to Duo for 2FA. This /// Auth URL also lets the Duo Service know where to redirect the user back to after /// the 2FA process is complete. /// /// A not null valid Duo.Client /// This service creates the state token for added security /// currently active user /// a URL in string format string GenerateAuthUrl( Duo.Client duoClient, IDataProtectorTokenFactory tokenDataFactory, User user); /// /// Makes the request to Duo to validate the authCode and state token /// /// A not null valid Duo.Client /// Factory for decrypting the state /// self /// token received from the client /// boolean based on result from Duo Task RequestDuoValidationAsync( Duo.Client duoClient, IDataProtectorTokenFactory tokenDataFactory, User user, string token); /// /// Generates a Duo.Client object for use with Duo SDK v4. This method is to validate a Duo configuration /// when adding or updating the configuration. This method makes a web request to Duo to verify the configuration. /// Throws exception if configuration is invalid. /// /// Duo client Secret /// Duo client Id /// Duo host /// Boolean Task ValidateDuoConfiguration(string clientSecret, string clientId, string host); /// /// Checks provider for the correct Duo metadata: ClientId, ClientSecret, and Host. Does no validation on the data. /// it is assumed to be correct. The only way to have the data written to the Database is after verification /// occurs. /// /// Host being checked for proper data /// true if all three are present; false if one is missing or the host is incorrect bool HasProperDuoMetadata(TwoFactorProvider provider); /// /// Generates a Duo.Client object for use with Duo SDK v4. This combines the health check and the client generation. /// This method is made public so that it is easier to test. If the method was private then there would not be an /// easy way to mock the response. Since this makes a web request it is difficult to mock. /// /// TwoFactorProvider Duo or OrganizationDuo /// Duo.Client object or null Task BuildDuoTwoFactorClientAsync(TwoFactorProvider provider); } public class DuoUniversalTokenService( ICurrentContext currentContext, GlobalSettings globalSettings) : IDuoUniversalTokenService { private readonly ICurrentContext _currentContext = currentContext; private readonly GlobalSettings _globalSettings = globalSettings; public string GenerateAuthUrl( Duo.Client duoClient, IDataProtectorTokenFactory tokenDataFactory, User user) { var state = tokenDataFactory.Protect(new DuoUserStateTokenable(user)); var authUrl = duoClient.GenerateAuthUri(user.Email, state); return authUrl; } public async Task RequestDuoValidationAsync( Duo.Client duoClient, IDataProtectorTokenFactory tokenDataFactory, User user, string token) { var parts = token.Split("|"); var authCode = parts[0]; var state = parts[1]; tokenDataFactory.TryUnprotect(state, out var tokenable); if (!tokenable.Valid || !tokenable.TokenIsValid(user)) { return false; } // duoClient compares the email from the received IdToken with user.Email to verify a bad actor hasn't used // their authCode with a victims credentials var res = await duoClient.ExchangeAuthorizationCodeFor2faResult(authCode, user.Email); // If the result of the exchange doesn't throw an exception and it's not null, then it's valid return res.AuthResult.Result == "allow"; } public async Task ValidateDuoConfiguration(string clientSecret, string clientId, string host) { // Do some simple checks to ensure data integrity if (!ValidDuoHost(host) || string.IsNullOrWhiteSpace(clientSecret) || string.IsNullOrWhiteSpace(clientId)) { return false; } // The AuthURI is not important for this health check so we pass in a non-empty string var client = new Duo.ClientBuilder(clientId, clientSecret, host, "non-empty").Build(); // This could throw an exception, the false flag will allow the exception to bubble up return await client.DoHealthCheck(false); } public bool HasProperDuoMetadata(TwoFactorProvider provider) { return provider?.MetaData != null && provider.MetaData.ContainsKey("ClientId") && provider.MetaData.ContainsKey("ClientSecret") && provider.MetaData.ContainsKey("Host") && ValidDuoHost((string)provider.MetaData["Host"]); } /// /// Checks the host string to make sure it meets Duo's Guidelines before attempting to create a Duo.Client. /// /// string representing the Duo Host /// true if the host is valid false otherwise public static bool ValidDuoHost(string host) { if (Uri.TryCreate($"https://{host}", UriKind.Absolute, out var uri)) { return (string.IsNullOrWhiteSpace(uri.PathAndQuery) || uri.PathAndQuery == "/") && uri.Host.StartsWith("api-") && (uri.Host.EndsWith(".duosecurity.com") || uri.Host.EndsWith(".duofederal.com")); } return false; } private static bool IsBitwardenCloudHost(string host) { if (string.IsNullOrWhiteSpace(host)) { return false; } var normalizedHost = host.ToLowerInvariant(); return normalizedHost.EndsWith("bitwarden.com") || normalizedHost.EndsWith("bitwarden.eu") || normalizedHost.EndsWith("bitwarden.pw"); } private static bool IsLocalRequestHost(string host) { if (string.IsNullOrWhiteSpace(host)) { return false; } var normalizedHost = host.ToLowerInvariant(); return normalizedHost == "localhost" || normalizedHost == "127.0.0.1" || normalizedHost == "::1" || normalizedHost.EndsWith(".localhost"); } private static string GetDeeplinkSchemeOverride(HttpContext httpContext) { if (httpContext == null) { return null; } var host = httpContext.Request?.Host.Host; // Only allow overrides when developing/testing locally to avoid abuse in production if (!IsLocalRequestHost(host)) { return null; } // Querystring has precedence over header for manual local testing var overrideFromQuery = httpContext.Request?.Query["deeplinkScheme"].FirstOrDefault(); var overrideFromHeader = httpContext.Request?.Headers["Bitwarden-Deeplink-Scheme"].FirstOrDefault(); var candidate = (overrideFromQuery ?? overrideFromHeader)?.Trim().ToLowerInvariant(); // Allow only the two supported values return candidate is "https" or "bitwarden" ? candidate : null; } public async Task BuildDuoTwoFactorClientAsync(TwoFactorProvider provider) { // Fetch Client name from header value since duo auth can be initiated from multiple clients and we want // to redirect back to the initiating client _currentContext.HttpContext.Request.Headers.TryGetValue("Bitwarden-Client-Name", out var bitwardenClientName); var requestHost = _currentContext.HttpContext?.Request?.Host.Host; var deeplinkScheme = GetDeeplinkSchemeOverride(_currentContext.HttpContext) ?? (IsBitwardenCloudHost(requestHost) ? "https" : "bitwarden"); var redirectUri = string.Format("{0}/duo-redirect-connector.html?client={1}&deeplinkScheme={2}", _globalSettings.BaseServiceUri.Vault, bitwardenClientName.FirstOrDefault() ?? "web", deeplinkScheme); var client = new Duo.ClientBuilder( (string)provider.MetaData["ClientId"], (string)provider.MetaData["ClientSecret"], (string)provider.MetaData["Host"], redirectUri).Build(); if (!await client.DoHealthCheck(false)) { return null; } return client; } }