使用AAD / MFA凭据连接到多个Azure数据库

时间:2020-07-09 13:35:50

标签: c# sql-server .net-core azure-active-directory azure-sql-database

我正在尝试编写一个netcore控制台应用程序,该应用程序可以连接到多个Azure SQL数据库,并针对它们执行一些脚本。我们的公司要求数据库具有MFA登录名的Azure AD。

我已经成功使用信息here使其成功登录:

设置

static void Main(string[] args)
{
    var provider = new ActiveDirectoryAuthProvider();

    SqlAuthenticationProvider.SetProvider(
        SqlAuthenticationMethod.ActiveDirectoryIntegrated,
        //SC.SqlAuthenticationMethod.ActiveDirectoryInteractive,
        //SC.SqlAuthenticationMethod.ActiveDirectoryIntegrated,  // Alternatives.
        //SC.SqlAuthenticationMethod.ActiveDirectoryPassword,
        provider);
}

public class ActiveDirectoryAuthProvider : SqlAuthenticationProvider
{
    // Program._ more static values that you set!
    private readonly string _clientId = "MyClientID";

    public override async TT.Task<SC.SqlAuthenticationToken>
        AcquireTokenAsync(SC.SqlAuthenticationParameters parameters)
    {
        AD.AuthenticationContext authContext =
            new AD.AuthenticationContext(parameters.Authority);
        authContext.CorrelationId = parameters.ConnectionId;
        AD.AuthenticationResult result;

        switch (parameters.AuthenticationMethod)
        {
             case SC.SqlAuthenticationMethod.ActiveDirectoryIntegrated:
                Console.WriteLine("In method 'AcquireTokenAsync', case_1 == '.ActiveDirectoryIntegrated'.");
                Console.WriteLine($"Resource: {parameters.Resource}");

                result = await authContext.AcquireTokenAsync(
                    parameters.Resource,
                    _clientId,
                    new AD.UserCredential(GlobalSettings.CredentialsSettings.Username));
                break;

            default: throw new InvalidOperationException();
        }           

        return new SC.SqlAuthenticationToken(result.AccessToken, result.ExpiresOn);
    }

    public override bool IsSupported(SC.SqlAuthenticationMethod authenticationMethod)
    {
        return authenticationMethod == SC.SqlAuthenticationMethod.ActiveDirectoryIntegrated
            || authenticationMethod == SC.SqlAuthenticationMethod.ActiveDirectoryInteractive;
    }
}

连接

private SqlConnection GetConnection()
{
    var builder = new SqlConnectionStringBuilder();
    builder.DataSource = "MyServer";            
    builder.Encrypt = true;
    builder.TrustServerCertificate = true;
    builder.PersistSecurityInfo = true;
    builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive;
    builder.InitialCatalog = "MyDatabase";

    var conn = new SqlConnection(builder.ToString());
    conn.Open();

    return conn;        
}

这有效,并且我可以根据需要运行查询。但是,只要该应用程序连接到新数据库(在同一地址),它就会打开一个浏览器窗口,登录到login.microsoftonline.com,要求我选择我的帐户/登录。

是否有任何方法要求所有数据库仅一次进行此浏览器身份验证?它们都在同一个Azure SQL实例上。

1 个答案:

答案 0 :(得分:1)

因此,代码中包含一些PEBKAC。尽管该类正在使用builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive;,但该类实际上正在尝试使用ActiveDirectoryIntegrated。因此,我的广告课从未真正受到欢迎。另外,在示例代码中,它实际上也永远不会起作用,因为case语句存在于ActiveDirectoryIntegrated-我已经将其剥离到本地副本上。

我实际上需要使用正确的ActiveDirectoryInteractive代码来进行连接。完成后,就可以对系统进行一次身份验证。这样一来,所有数据库连接即可正常工作,而无需其他浏览器检查。

设置

static void Main(string[] args)
{
    var provider = new ActiveDirectoryAuthProvider();

    SqlAuthenticationProvider.SetProvider(
        SqlAuthenticationMethod.ActiveDirectoryInteractive,
        //SC.SqlAuthenticationMethod.ActiveDirectoryIntegrated,  // Alternatives.
        //SC.SqlAuthenticationMethod.ActiveDirectoryPassword,
        provider);
}

ActiveDirectoryAuthProvider

public class ActiveDirectoryAuthProvider : SqlAuthenticationProvider
{
    private readonly string _clientId = "MyClientID";

    private Uri _redirectURL { get; set; } = new Uri("http://localhost:8089");

    private AD.AuthenticationContext AuthContext { get; set; }

    private TokenCache Cache { get; set; }

    public ActiveDirectoryAuthProvider()
    {
        Cache = new TokenCache();
    }

    public override async TT.Task<SC.SqlAuthenticationToken> AcquireTokenAsync(SC.SqlAuthenticationParameters parameters)
    {
        var authContext = AuthContext ?? new AD.AuthenticationContext(parameters.Authority, Cache);
        authContext.CorrelationId = parameters.ConnectionId;
        AD.AuthenticationResult result;

        try
        {
            result = await authContext.AcquireTokenSilentAsync(
                parameters.Resource,
                _clientId);     
        }
        catch (AdalSilentTokenAcquisitionException)
        {
            result = await authContext.AcquireTokenAsync(
                parameters.Resource,
                _clientId,
                _redirectURL, 
                new AD.PlatformParameters(PromptBehavior.Auto, new CustomWebUi()), 
                new UserIdentifier(parameters.UserId, UserIdentifierType.RequiredDisplayableId));
        }         

        var token = new SC.SqlAuthenticationToken(result.AccessToken, result.ExpiresOn);

        return token;
    }

    public override bool IsSupported(SC.SqlAuthenticationMethod authenticationMethod)
    {
        return authenticationMethod == SC.SqlAuthenticationMethod.ActiveDirectoryInteractive;
    }
}

这里有些不同:

  1. 我添加了一个内存中令牌缓存
  2. 我已将AuthContext移至该类的一个属性,以在两次运行之间将其保留在内存中
  3. 我已将_redirectURL属性设置为http://localhost:8089
  4. 在恢复之前,我已经为令牌添加了一个静默检查

最后,我创建了自己的ICustomWebUi实现,用于处理加载浏览器登录名和响应的情况:

CustomWebUi

internal class CustomWebUi : ICustomWebUi
{
    public async Task<Uri> AcquireAuthorizationCodeAsync(Uri authorizationUri, Uri redirectUri)
    {
        using (var listener = new SingleMessageTcpListener(redirectUri.Port))
        {
            Uri authCode = null;
            var listenerTask = listener.ListenToSingleRequestAndRespondAsync(u => {
                authCode = u;
                
                return @"
<html>
<body>
    <p>Successfully Authenticated, you may now close this window</p>
</body>
</html>";
            }, System.Threading.CancellationToken.None);

            var ps = new ProcessStartInfo(authorizationUri.ToString())
            { 
                UseShellExecute = true, 
                Verb = "open" 
            };
            Process.Start(ps);

            await listenerTask;

            return authCode;
        }            
    }
}

因为我已将重定向设置回本地主机,并且此代码位于控制台应用程序中,所以我需要侦听端口的响应并将其捕获到应用程序中,然后向浏览器显示一个值以指示该响应一切正常。

要监听端口,我使用了MS Github抄写的监听器类:

SingleMessageTcpListener

/// <summary>
/// This object is responsible for listening to a single TCP request, on localhost:port, 
/// extracting the uri, parsing 
/// </summary>
/// <remarks>
/// The underlying TCP listener might capture multiple requests, but only the first one is handled.
///
/// Cribbed this class from https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/9e0f57b53edfdcf027cbff401d3ca6c02e95ef1b/tests/devapps/NetCoreTestApp/Experimental/SingleMessageTcpListener.cs
/// </remarks>
internal class SingleMessageTcpListener : IDisposable
{
    private readonly int _port;
    private readonly System.Net.Sockets.TcpListener _tcpListener;

    public SingleMessageTcpListener(int port)
    {
        if (port < 1 || port == 80)
        {
            throw new ArgumentOutOfRangeException("Expected a valid port number, > 0, not 80");
        }

        _port = port;
        _tcpListener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, _port);
        

    }

    public async Task ListenToSingleRequestAndRespondAsync(
        Func<Uri, string> responseProducer,
        CancellationToken cancellationToken)
    {
        cancellationToken.Register(() => _tcpListener.Stop());
        _tcpListener.Start();

        TcpClient tcpClient = null;
        try
        {
            tcpClient =
                await AcceptTcpClientAsync(cancellationToken)
                .ConfigureAwait(false);

            await ExtractUriAndRespondAsync(tcpClient, responseProducer, cancellationToken).ConfigureAwait(false);

        }
        finally
        {
            tcpClient?.Close();
        }
    }

    /// <summary>
    /// AcceptTcpClientAsync does not natively support cancellation, so use this wrapper. Make sure
    /// the cancellation token is registered to stop the listener.
    /// </summary>
    /// <remarks>See https://stackoverflow.com/questions/19220957/tcplistener-how-to-stop-listening-while-awaiting-accepttcpclientasync</remarks>
    private async Task<TcpClient> AcceptTcpClientAsync(CancellationToken token)
    {
        try
        {
            return await _tcpListener.AcceptTcpClientAsync().ConfigureAwait(false);
        }
        catch (Exception ex) when (token.IsCancellationRequested)
        {
            throw new OperationCanceledException("Cancellation was requested while awaiting TCP client connection.", ex);
        }
    }

    private async Task ExtractUriAndRespondAsync(
        TcpClient tcpClient,
        Func<Uri, string> responseProducer,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        string httpRequest = await GetTcpResponseAsync(tcpClient, cancellationToken).ConfigureAwait(false);
        Uri uri = ExtractUriFromHttpRequest(httpRequest);

        // write an "OK, please close the browser message" 
        await WriteResponseAsync(responseProducer(uri), tcpClient.GetStream(), cancellationToken)
            .ConfigureAwait(false);
    }

    private Uri ExtractUriFromHttpRequest(string httpRequest)
    {
        string regexp = @"GET \/\?(.*) HTTP";
        string getQuery = null;
        Regex r1 = new Regex(regexp);
        Match match = r1.Match(httpRequest);
        if (!match.Success)
        {
            throw new InvalidOperationException("Not a GET query");
        }

        getQuery = match.Groups[1].Value;
        UriBuilder uriBuilder = new UriBuilder();
        uriBuilder.Query = getQuery;
        uriBuilder.Port = _port;

        return uriBuilder.Uri;
    }

    private static async Task<string> GetTcpResponseAsync(TcpClient client, CancellationToken cancellationToken)
    {
        NetworkStream networkStream = client.GetStream();

        byte[] readBuffer = new byte[1024];
        StringBuilder stringBuilder = new StringBuilder();
        int numberOfBytesRead = 0;

        // Incoming message may be larger than the buffer size. 
        do
        {
            numberOfBytesRead = await networkStream.ReadAsync(readBuffer, 0, readBuffer.Length, cancellationToken)
                .ConfigureAwait(false);

            string s = Encoding.ASCII.GetString(readBuffer, 0, numberOfBytesRead);
            stringBuilder.Append(s);

        }
        while (networkStream.DataAvailable);

        return stringBuilder.ToString();
    }

    private async Task WriteResponseAsync(
        string message,
        NetworkStream stream,
        CancellationToken cancellationToken)
    {
        string fullResponse = $"HTTP/1.1 200 OK\r\n\r\n{message}";
        var response = Encoding.ASCII.GetBytes(fullResponse);
        await stream.WriteAsync(response, 0, response.Length, cancellationToken).ConfigureAwait(false);
        await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
    }

    public void Dispose()
    {
        _tcpListener?.Stop();
    }
}

所有这些都准备就绪,当连接到资源上的第一个数据库时,浏览器将打开,并且令牌将在连接之间重用。